diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
index 7a0f59c3c2b..b3db357fe02 100644
--- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
@@ -1974,7 +1974,10 @@ async def authorize(
response_type=response_type,
session_user_id=_session_cookie_user_id(request),
lookup_consent_teams=lookup_consent_teams,
+ scope=scope,
)
+ if scope == "proxy:admin":
+ raise HTTPException(400, "proxy:admin requires the gateway URL as its resource")
return aggregate_authorize(
request=request,
client_id=client_id,
@@ -2159,7 +2162,7 @@ async def authorize_complete(
async def revoke_endpoint(request: Request, token: str = Form(...), client_id: str = Form(...)) -> Response:
"""RFC 7009 revocation for the gateway's refresh tokens (``lite logout``): 200 for a known
client whatever the token's state, 503 when the shared single-use record cannot be written;
- access tokens expire on their own."""
+ native/MCP access tokens expire normally; delegated access is revoked immediately."""
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
master_key,
user_api_key_cache,
@@ -2648,6 +2651,25 @@ def _jwt_auth_issuers() -> list:
return issuers
+@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/oauth/api")
+def oauth_authorization_server_api(request: Request) -> dict[str, str | tuple[str, ...]]:
+ base: Final = get_request_base_url(request)
+ return {
+ "issuer": f"{base}/oauth/api",
+ "authorization_endpoint": f"{base}/authorize",
+ "token_endpoint": f"{base}/token",
+ "registration_endpoint": f"{base}/register",
+ "revocation_endpoint": f"{base}/revoke",
+ "introspection_endpoint": f"{base}/introspect",
+ "response_types_supported": ("code",),
+ "grant_types_supported": ("authorization_code", "refresh_token"),
+ "scopes_supported": ("proxy:admin",),
+ "code_challenge_methods_supported": ("S256",),
+ "token_endpoint_auth_methods_supported": ("none",),
+ "revocation_endpoint_auth_methods_supported": ("none",),
+ }
+
+
@router.get("/.well-known/oauth-protected-resource")
def oauth_protected_resource_root(request: Request) -> dict[str, str | tuple[str, ...]]:
request_base_url: Final = get_request_base_url(request)
diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
index e66504af47a..bb7c7ad9a23 100644
--- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
+++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
@@ -26,8 +26,8 @@ this track) and then walks the flow implemented here:
:mod:`.outbound_credentials.session_token`, re-validating that the litellm user is
still active first; the ``refresh_token`` grant rotates the pair the same way.
-Nothing here stores state server-side except the single-use code guard (a TTL cache
-entry). Every sealed value is authenticated encryption over the proxy salt/master key
+Server-side state consists of single-use markers and delegated application token families.
+Every sealed value is authenticated encryption over the proxy salt/master key
family, opened totally (bad input maps to an OAuth error, never a raise), and every
identity is a stable reference re-validated live at mint, refresh, and (in the admission
PR) tool-call time. Upstream server credentials never appear anywhere in this flow; they
@@ -72,6 +72,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credent
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
SESSION_ISSUER,
SESSION_REFRESH_TTL_SECONDS,
+ DelegatedGrant,
MintedSessionToken,
OpenedSessionToken,
SessionAudience,
@@ -84,6 +85,14 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token i
open_session_refresh_token,
open_session_token,
)
+from litellm.proxy.auth.delegated_oauth import (
+ delegated_identity,
+ delegated_user,
+ delegation_active,
+ is_delegated_callback,
+ issue_delegated_tokens,
+ revoke_delegation,
+)
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
@@ -302,6 +311,7 @@ class _ConnectFlow(BaseModel):
exp: int
resource_server_id: str | None = None
audience: SessionAudience | None = None
+ delegation: DelegatedGrant | None = None
class _GatewayAuthCode(BaseModel):
@@ -321,6 +331,7 @@ class _GatewayAuthCode(BaseModel):
resource_server_id: str | None = None
audience: SessionAudience | None = None
team_id: str | None = None
+ delegation: DelegatedGrant | None = None
def is_gateway_dcr_client_id(client_id: str | None) -> bool:
@@ -563,24 +574,30 @@ async def native_client_authorize(
response_type: str | None,
session_user_id: str | None,
lookup_consent_teams: LookupConsentTeams,
+ scope: str | None = None,
) -> Response:
- """The authorize verb for a native client that named the proxy API itself as its
- RFC 8707 ``resource``: the same client, redirect, PKCE, and sign-in checks as the
- aggregate verb plus a loopback-only redirect (the credential this grant mints is the
- user's personal proxy key, which belongs on their own machine and never behind a hosted
- callback), then the consent page rendered right here (no connect-page interlude, since
- there is no per-server vaulting to do) with the flow sealed into the per-flow cookie
- and its handle carried only in the form, never in a URL."""
+ """Authorize the proxy API through existing sign-in and consent. Native clients use
+ loopback callbacks; explicit admin delegation requires an approved hosted callback."""
rejected: Final = _rejected_authorize_request(
client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type
)
if rejected is not None:
return rejected
- if not is_loopback_redirect_host(urlparse(redirect_uri)):
+ delegated: Final = scope == "proxy:admin"
+ if scope not in (None, "", "proxy:admin"):
+ return _oauth_error(400, "invalid_scope", "unsupported proxy API scope")
+ if delegated and not is_delegated_callback(redirect_uri):
+ return _oauth_error(400, "invalid_request", "the application callback is not approved by this gateway")
+ if not delegated and not is_loopback_redirect_host(urlparse(redirect_uri)):
return _oauth_error(400, "invalid_request", "a proxy-API grant may only redirect to a loopback address")
base_url: Final = get_request_base_url(request)
if session_user_id is None:
return _login_redirect(base_url, request)
+ if delegated:
+ try:
+ await delegated_user(session_user_id)
+ except HTTPException as exc:
+ return _oauth_error(exc.status_code, "access_denied", str(exc.detail))
teams: Final = await lookup_consent_teams(session_user_id)
if not isinstance(teams, tuple):
return _consent_lookup_failure_response(teams)
@@ -593,6 +610,16 @@ async def native_client_authorize(
code_challenge=code_challenge or "",
resource_server_id=None,
audience=PROXY_API_AUDIENCE,
+ delegation=(
+ DelegatedGrant(
+ grant_id=secrets.token_urlsafe(24),
+ resource=canonicalize_url_identity(base_url),
+ redirect_uri=redirect_uri,
+ expires_at=int(datetime.now(timezone.utc).timestamp()) + SESSION_REFRESH_TTL_SECONDS,
+ )
+ if delegated
+ else None
+ ),
)
page: Final = render_native_client_consent_page(
client_origin=_origin_only(redirect_uri),
@@ -600,6 +627,7 @@ async def native_client_authorize(
teams=tuple((team.team_id, team.team_alias or team.team_id) for team in teams),
flow_handle=handle,
complete_url=f"{base_url}/authorize/complete",
+ delegated=delegated,
)
response: Final = HTMLResponse(page, headers=_CONSENT_PAGE_HEADERS)
_set_flow_cookie(response, request, handle, flow)
@@ -705,6 +733,7 @@ def _new_connect_flow(
code_challenge: str,
resource_server_id: str | None,
audience: SessionAudience | None,
+ delegation: DelegatedGrant | None = None,
) -> _ConnectFlow:
now: Final = datetime.now(timezone.utc)
return _ConnectFlow(
@@ -717,6 +746,7 @@ def _new_connect_flow(
exp=int(now.timestamp()) + CONNECT_FLOW_TTL_SECONDS,
resource_server_id=resource_server_id,
audience=audience,
+ delegation=delegation,
)
@@ -876,6 +906,8 @@ async def complete_connect_flow(
opened: Final = _open_flow_for(request, flow_handle, session_user_id, now)
if isinstance(opened, Response):
return opened
+ if opened.delegation is not None and decision is None:
+ return _oauth_error(400, "invalid_request", "an explicit approval or denial is required")
if decision != "deny":
described: Final = await _describe_opened_flow(opened, lookup_vendor_credential, lookup_server_reachability)
if isinstance(described, Response):
@@ -927,6 +959,7 @@ def _approved_flow_response(flow: _ConnectFlow, delivery: str | None, team_id: s
resource_server_id=flow.resource_server_id,
audience=flow.audience,
team_id=(team_id or None) if flow.audience == PROXY_API_AUDIENCE else None,
+ delegation=flow.delegation,
),
)
callback_url: Final = _append_query_params(flow.redirect_uri, (("code", code), *_state_param(flow)))
@@ -1256,8 +1289,8 @@ async def aggregate_token(
class _GrantIssuer:
"""The tail every grant shares once its own proof (code + PKCE, or a refresh token)
has checked out: revalidate the user live, claim the single-use marker, mint. The
- claim comes AFTER revalidation and minting so a transient DB 503 never burns a
- still-valid code or refresh token, and fails closed when it cannot be recorded."""
+ Native and MCP grants claim after revalidation. Delegated grants atomically create
+ a revocable token family as their single-use admission."""
def __init__(
self,
@@ -1280,6 +1313,11 @@ class _GrantIssuer:
async def __call__(
self, principal: SessionPrincipal, claim_key: str, claim_ttl_seconds: int, replayed: str
) -> Response:
+ if principal.delegation is not None:
+ target_refusal: Final = self._delegated_target_refusal(principal)
+ if target_refusal is not None:
+ return target_refusal
+ return await issue_delegated_tokens(principal, self._keys, self._now)
match principal.audience:
case None:
return await self._issue_session_pair(principal, claim_key, claim_ttl_seconds, replayed)
@@ -1288,6 +1326,20 @@ class _GrantIssuer:
case _:
assert_never(principal.audience)
+ def _delegated_target_refusal(self, principal: SessionPrincipal) -> Response | None:
+ if principal.delegation is None or (
+ principal.audience != PROXY_API_AUDIENCE
+ or principal.delegation.resource != canonicalize_url_identity(get_request_base_url(self._request))
+ ):
+ return _oauth_error(400, "invalid_target", "the grant belongs to a different gateway")
+ return self._proxy_api_target_refusal()
+
+ async def refresh_delegation(self, principal: SessionPrincipal, jti: str) -> Response:
+ refusal: Final = self._delegated_target_refusal(principal)
+ if refusal is not None:
+ return refusal
+ return await issue_delegated_tokens(principal, self._keys, self._now, previous_refresh_jti=jti)
+
async def _issue_session_pair(
self, principal: SessionPrincipal, claim_key: str, claim_ttl_seconds: int, replayed: str
) -> Response:
@@ -1380,6 +1432,7 @@ async def _authorization_code_grant(
resource_server_id=parsed.resource_server_id,
audience=parsed.audience,
team_id=parsed.team_id,
+ delegation=parsed.delegation,
),
claim_key=f"{_USED_CODE_CACHE_PREFIX}{parsed.jti}",
claim_ttl_seconds=parsed.exp - int(now.timestamp()) + _CLAIM_TTL_BUFFER_SECONDS,
@@ -1403,6 +1456,8 @@ async def _refresh_token_grant(
return _oauth_error(400, "invalid_grant", "the refresh token is invalid for this client")
if _resource_conflicts_with_scope(request, resource, opened.principal.resource_server_id):
return _oauth_error(400, "invalid_target", "resource does not match the scope this token was issued for")
+ if opened.principal.delegation is not None:
+ return await issue.refresh_delegation(opened.principal, opened.jti)
# Refresh-token rotation (OAuth 2.0 Security BCP section 4.13): the presented refresh token is
# single-use, so a captured or replayed refresh token cannot mint a second pair after the
# legitimate holder rotated.
@@ -1441,14 +1496,8 @@ async def _token_exchange_grant(
async def revoke_refresh_token(token: str, client_id: str, master_key: str | None, cache: DualCache) -> Response:
- """RFC 7009 revocation for the gateway's refresh tokens: burn the presented token's
- ``jti`` so neither the holder nor a thief can rotate it again. Access tokens are
- stateless and expire on their own (the proxy-API credential within
- ``CLI_JWT_EXPIRATION_HOURS``), so per RFC 7009 section 2.2 an unrecognized or already
- dead token still answers 200; only an unknown client is refused. A live token whose
- burn could not be recorded in the shared backend answers 503 (section 2.2.1), so the
- client knows the token still stands and retries instead of reporting a logout that
- never happened."""
+ """RFC 7009: revoke a delegated family or burn a native/MCP refresh token's JTI.
+ Invalid tokens return 200; unavailable shared storage returns 503 for retry."""
if not is_gateway_dcr_client_id(client_id) or open_gateway_dcr_client(client_id) is None:
return _oauth_error(401, "invalid_client", "unknown or malformed client_id")
if master_key is None:
@@ -1459,6 +1508,18 @@ async def revoke_refresh_token(token: str, client_id: str, master_key: str | Non
verbose_logger.error("mcp_gateway_dcr revoke rejected: %s", keys.detail)
return _oauth_error(500, "server_error", "the gateway session signing configuration is invalid")
now: Final = datetime.now(timezone.utc)
+ candidate: Final = (
+ open_session_token(token, keys, now, for_revocation=True)
+ if is_session_token(token)
+ else open_session_refresh_token(token, keys, now, for_revocation=True)
+ )
+ if isinstance(candidate, OpenedSessionToken) and candidate.principal.delegation is not None:
+ if candidate.principal.client_id == client_id:
+ try:
+ await revoke_delegation(candidate.principal)
+ except HTTPException as exc:
+ return _oauth_error(exc.status_code, "temporarily_unavailable", str(exc.detail))
+ return Response(content="{}", media_type="application/json", headers=TOKEN_NO_CACHE_HEADERS)
opened: Final = open_session_refresh_bearer(token, keys, now, expected_client_id=client_id)
if isinstance(opened, SessionRefreshOpened):
burned: Final = await _SingleUseGuard(cache).claim(
@@ -1485,6 +1546,8 @@ def _active_introspection_response(opened: OpenedSessionToken) -> Response:
("team_id", principal.team_id),
("resource_server_id", principal.resource_server_id),
("audience", principal.audience),
+ ("scope", principal.delegation.scope if principal.delegation is not None else None),
+ ("aud", principal.delegation.resource if principal.delegation is not None else None),
)
if value is not None
}
@@ -1492,7 +1555,7 @@ def _active_introspection_response(opened: OpenedSessionToken) -> Response:
status_code=200,
content={
"active": True,
- "iss": SESSION_ISSUER,
+ "iss": f"{principal.delegation.resource}/oauth/api" if principal.delegation is not None else SESSION_ISSUER,
"sub": principal.user_id,
"client_id": principal.client_id,
"jti": opened.jti,
@@ -1535,6 +1598,16 @@ async def introspect_gateway_token(
return _inactive_introspection_response()
if not isinstance(opened, OpenedSessionToken):
return _inactive_introspection_response()
+ if opened.principal.delegation is not None:
+ try:
+ if not await delegation_active(opened.principal, opened.jti if opened.kind == "session_refresh" else None):
+ return _inactive_introspection_response()
+ await delegated_identity(opened.principal)
+ except HTTPException as exc:
+ if exc.status_code >= 500:
+ return _oauth_error(503, "temporarily_unavailable", str(exc.detail))
+ return _inactive_introspection_response()
+ return _active_introspection_response(opened)
if opened.kind == "session_refresh":
peeked: Final = await _SingleUseGuard(cache).peek(f"{_USED_REFRESH_CACHE_PREFIX}{opened.jti}")
if peeked == "unavailable":
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_credentials.py
index df2bbdba345..ae26646b0da 100644
--- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_credentials.py
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_credentials.py
@@ -233,7 +233,7 @@ def resolve_session_bearer(
if not is_session_token(candidate):
return NotSessionBearer()
opened: Final = open_session_token(candidate, keys, now)
- if isinstance(opened, OpenedSessionToken):
+ if isinstance(opened, OpenedSessionToken) and opened.principal.audience is None:
return SessionBearerAdmitted(principal=opened.principal)
return SessionBearerInvalid(expired=isinstance(opened, SessionExpired))
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py
index 0fa750a4c4a..a167c7405d4 100644
--- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py
@@ -1,4 +1,4 @@
-"""Identity-only session tokens for the gateway-level (aggregate ``/mcp``) DCR front door.
+"""Identity-only session tokens for gateway OAuth clients.
A DCR client that signs in through LiteLLM SSO holds ONE bearer that carries ONLY a
litellm identity; unlike the :mod:`.envelope` bridge bearer it seals no upstream
@@ -6,7 +6,7 @@ credential, because the custody model vaults every upstream token server-side in
``LiteLLM_MCPUserCredentials`` and egress resolves them by user at call time. The token
is therefore a stable REFERENCE, not an authorization: admission reloads the live user
record and policy on every request, so deactivating the user (or their team) kills
-outstanding sessions immediately without a revocation store.
+outstanding sessions immediately. Delegated API sessions additionally require a live grant family.
Wire shape: ``llm_session_`` (access) / ``llm_srefresh_`` (refresh) + a JWT signed with
the injected key material: HS256 under the default master-key-derived secret (the same
@@ -90,11 +90,17 @@ on open, so a signature-valid token of one kind cannot be replayed as the other
wire prefix is swapped (the prefix is not part of the signed payload; this claim is)."""
SessionAudience = Literal["proxy_api"]
-"""The non-MCP audience a session REFRESH token can be minted for. ``None`` (the default and
-the only value ever on an MCP wire) means the aggregate MCP gateway; ``"proxy_api"`` means the
-refresh grant re-mints the proxy-API CLI credential instead of an MCP session pair. The audience
-is read only from the signed claims, never from the request, so a token of one audience can
-never be redeemed as the other."""
+"""``None`` identifies MCP sessions. ``proxy_api`` identifies native CLI refresh grants
+or delegated API token pairs. Admission and renewal use only this signed audience."""
+
+
+class DelegatedGrant(BaseModel):
+ model_config = ConfigDict(frozen=True, strict=True, extra="forbid")
+ grant_id: str = Field(min_length=1)
+ resource: str = Field(min_length=1)
+ redirect_uri: str = Field(min_length=1)
+ expires_at: int
+ scope: Literal["proxy:admin"] = "proxy:admin"
class SessionPrincipal(BaseModel):
@@ -119,6 +125,7 @@ class SessionPrincipal(BaseModel):
resource_server_id: str | None = None
audience: SessionAudience | None = None
team_id: str | None = None
+ delegation: DelegatedGrant | None = None
class SessionKeys(BaseModel):
@@ -302,6 +309,7 @@ class _SessionClaims(BaseModel):
resource_server_id: str | None = None
audience: SessionAudience | None = None
team_id: str | None = None
+ delegation: DelegatedGrant | None = None
def is_session_token(candidate: str) -> bool:
@@ -361,19 +369,30 @@ def open_session_token(
candidate: str,
keys: SessionSigningKeys,
now: datetime,
+ *,
+ for_revocation: bool = False,
) -> OpenedSessionToken | SessionTokenOpenError:
"""Validate a session ACCESS ``candidate`` and recover the principal.
Never raises for bad input: every invalid, expired, tampered, or wrong-kind candidate
maps to a distinct ``SessionTokenOpenError`` variant.
"""
- return _open(candidate, prefix=SESSION_TOKEN_PREFIX, expected_kind="session", keys=keys, now=now)
+ return _open(
+ candidate,
+ prefix=SESSION_TOKEN_PREFIX,
+ expected_kind="session",
+ keys=keys,
+ now=now,
+ for_revocation=for_revocation,
+ )
def open_session_refresh_token(
candidate: str,
keys: SessionSigningKeys,
now: datetime,
+ *,
+ for_revocation: bool = False,
) -> OpenedSessionToken | SessionTokenOpenError:
"""Validate a session REFRESH ``candidate`` and recover the principal.
@@ -381,7 +400,14 @@ def open_session_refresh_token(
``kind="session_refresh"`` claim is required, so an access token re-prefixed as a
refresh one is rejected as ``SessionMalformed``.
"""
- return _open(candidate, prefix=SESSION_REFRESH_PREFIX, expected_kind="session_refresh", keys=keys, now=now)
+ return _open(
+ candidate,
+ prefix=SESSION_REFRESH_PREFIX,
+ expected_kind="session_refresh",
+ keys=keys,
+ now=now,
+ for_revocation=for_revocation,
+ )
def _mint(
@@ -394,10 +420,15 @@ def _mint(
) -> MintedSessionToken | SessionTokenTooLarge:
"""Sign the claims for either token kind and enforce the size cap. Shared by both mints
so the JWT shape, issuer, and size guard cannot drift between access and refresh."""
+ bounded_expiry: Final = (
+ min(expires_at, datetime.fromtimestamp(principal.delegation.expires_at, tz=now.tzinfo))
+ if principal.delegation is not None
+ else expires_at
+ )
claims: Final = _SessionClaims(
iss=SESSION_ISSUER,
iat=int(now.timestamp()),
- exp=int(expires_at.timestamp()),
+ exp=int(bounded_expiry.timestamp()),
jti=secrets.token_urlsafe(16),
kind=kind,
user_id=principal.user_id,
@@ -405,12 +436,13 @@ def _mint(
resource_server_id=principal.resource_server_id,
audience=principal.audience,
team_id=principal.team_id,
+ delegation=principal.delegation,
)
token: Final = prefix + _sign_claims(claims, keys)
size_bytes: Final = len(token.encode("utf-8"))
if size_bytes > MAX_SESSION_TOKEN_BYTES:
return SessionTokenTooLarge(size_bytes=size_bytes, max_bytes=MAX_SESSION_TOKEN_BYTES)
- return MintedSessionToken(token=SecretStr(token), expires_at=expires_at)
+ return MintedSessionToken(token=SecretStr(token), expires_at=bounded_expiry)
def _sign_claims(claims: _SessionClaims, keys: SessionSigningKeys) -> str:
@@ -434,6 +466,7 @@ def _open(
expected_kind: SessionTokenKind,
keys: SessionSigningKeys,
now: datetime,
+ for_revocation: bool = False,
) -> OpenedSessionToken | SessionTokenOpenError:
"""Prefix-route, size-bound, signature-verify, kind-check, and expiry-check an
attacker-controlled candidate, shared by both openers so the security gate is identical
@@ -452,7 +485,11 @@ def _open(
return claims
if claims.kind != expected_kind:
return SessionMalformed()
- if now.timestamp() >= claims.exp:
+ if claims.delegation is not None and (claims.audience != "proxy_api" or claims.resource_server_id is not None):
+ return SessionMalformed()
+ if claims.delegation is not None and now.timestamp() >= claims.delegation.expires_at:
+ return SessionExpired()
+ if now.timestamp() >= claims.exp and not (for_revocation and claims.delegation is not None):
return SessionExpired()
return OpenedSessionToken(
principal=SessionPrincipal(
@@ -461,6 +498,7 @@ def _open(
resource_server_id=claims.resource_server_id,
audience=claims.audience,
team_id=claims.team_id,
+ delegation=claims.delegation,
),
jti=claims.jti,
kind=claims.kind,
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 5b3736d22a9..9e9b8e7efc3 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -32598,6 +32598,41 @@
]
}
},
+ "/.well-known/oauth-authorization-server/oauth/api": {
+ "get": {
+ "operationId": "oauth_authorization_server_api__well_known_oauth_authorization_server_oauth_api_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "title": "Response Oauth Authorization Server Api Well Known Oauth Authorization Server Oauth Api Get",
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Oauth Authorization Server Api",
+ "tags": [
+ "mcp_byok_oauth"
+ ]
+ }
+ },
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
@@ -35018,6 +35053,41 @@
]
}
},
+ "/.well-known/oauth-authorization-server/oauth/api": {
+ "get": {
+ "operationId": "oauth_authorization_server_api__well_known_oauth_authorization_server_oauth_api_get_2",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ ]
+ },
+ "title": "Response Oauth Authorization Server Api Well Known Oauth Authorization Server Oauth Api Get",
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Oauth Authorization Server Api",
+ "tags": [
+ "mcp_discoverable"
+ ]
+ }
+ },
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
@@ -35986,7 +36056,7 @@
},
"/revoke": {
"post": {
- "description": "RFC 7009 revocation for the gateway's refresh tokens (``lite logout``): 200 for a known\nclient whatever the token's state, 503 when the shared single-use record cannot be written;\naccess tokens expire on their own.",
+ "description": "RFC 7009 revocation for the gateway's refresh tokens (``lite logout``): 200 for a known\nclient whatever the token's state, 503 when the shared single-use record cannot be written;\nnative/MCP access tokens expire normally; delegated access is revoked immediately.",
"operationId": "revoke_endpoint_revoke_post",
"requestBody": {
"content": {
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index dbd6f28a183..e6de6d0142f 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -2344,9 +2344,13 @@ async def _fetch_team_membership_from_db(
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
+ *,
+ use_writer: bool = False,
) -> LiteLLM_TeamMembership | None:
_ = parent_otel_span, proxy_logging_obj
- response: Final = await _dictable_table(TeamMembershipRepository(prisma_client), "team_membership").find_unique(
+ response: Final = await _dictable_table(
+ TeamMembershipRepository(prisma_client, use_writer=use_writer), "team_membership"
+ ).find_unique(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
include={"litellm_budget_table": True},
)
@@ -2398,12 +2402,20 @@ async def get_team_membership(
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
+ *,
+ check_db_only: bool = False,
) -> Optional["LiteLLM_TeamMembership"]:
"""
Returns team membership object if user is member of team.
Do a isolated check for team membership vs. doing a combined key + team + user + team-membership check, as key might come in frequently for different users/teams. Larger call will slowdown query time. This way we get to cache the constant (key/team/user info) and only update based on the changing value (team membership).
"""
+ if check_db_only:
+ if prisma_client is None:
+ raise HTTPException(status_code=503, detail="The gateway database is unavailable")
+ return await _fetch_team_membership_from_db(
+ user_id, team_id, prisma_client, user_api_key_cache, parent_otel_span, proxy_logging_obj, use_writer=True
+ )
if user_id is None or team_id is None:
return None
@@ -3181,7 +3193,7 @@ async def _get_team_object_from_user_api_key_cache(
use_writer: bool = False,
) -> LiteLLM_TeamTableCachedObj:
db_access_time_key: Final = key
- should_check_db: Final = _should_check_db(
+ should_check_db: Final = use_writer or _should_check_db(
key=db_access_time_key,
last_db_access_time=last_db_access_time,
db_cache_expiry=db_cache_expiry,
diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py
index a59a12d6807..fbf86f86173 100644
--- a/litellm/proxy/auth/auth_exception_handler.py
+++ b/litellm/proxy/auth/auth_exception_handler.py
@@ -155,6 +155,7 @@ class UserAPIKeyAuthExceptionHandler:
if (
PrismaDBExceptionHandler.should_allow_request_on_db_unavailable()
+ and not (resolved_identity is not None and resolved_identity.requires_fresh_policy)
and PrismaDBExceptionHandler.is_database_connection_error(e)
):
# log this as a DB failure on prometheus
diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py
index 7ef82009184..fffda2e98df 100644
--- a/litellm/proxy/auth/auth_utils.py
+++ b/litellm/proxy/auth/auth_utils.py
@@ -33,6 +33,7 @@ from litellm.proxy._types import *
from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_metadata
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
+ LITELLM_PROVIDER_PASS_THROUGH_ENDPOINT_MARKER,
)
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS, Deployment
from litellm.types.utils import CustomPricingLiteLLMParams
@@ -1983,8 +1984,10 @@ def request_dispatched_to_pass_through_endpoint(request: Request | None) -> bool
def request_dispatched_to_provider_pass_through(request: Request) -> bool:
- """Built-in provider pass-through handlers (``/anthropic/{endpoint:path}``, ...) bind ``endpoint``."""
- return "endpoint" in request.path_params
+ return (
+ getattr(request.scope.get("endpoint"), LITELLM_PROVIDER_PASS_THROUGH_ENDPOINT_MARKER, False) is True
+ or "endpoint" in request.path_params
+ )
def get_model_from_request(
diff --git a/litellm/proxy/auth/delegated_oauth.py b/litellm/proxy/auth/delegated_oauth.py
new file mode 100644
index 00000000000..81856ce13d4
--- /dev/null
+++ b/litellm/proxy/auth/delegated_oauth.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+import os
+from collections.abc import Mapping
+from datetime import datetime, timezone
+from typing import Final, Literal
+from urllib.parse import urlparse
+
+from fastapi import HTTPException, Request
+from fastapi.responses import JSONResponse, Response
+from pydantic import BaseModel, Field, TypeAdapter
+
+from litellm.proxy._experimental.mcp_server.bridge_token_flow import load_active_user_by_id
+from litellm.proxy._experimental.mcp_server.oauth_utils import (
+ TOKEN_NO_CACHE_HEADERS,
+ canonical_resource_uri,
+ get_request_base_url,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
+ SessionSigningConfigError,
+ active_session_signing_keys,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
+ MintedSessionToken,
+ OpenedSessionToken,
+ SessionPrincipal,
+ SessionSigningKeys,
+ mint_session_refresh_token,
+ mint_session_token,
+ open_session_refresh_token,
+ open_session_token,
+)
+from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth, hash_token
+from litellm.proxy.auth.auth_checks import get_object_permission, get_team_membership, get_team_object
+from litellm.proxy.auth.resolvers.grants import user_models
+from litellm.proxy.auth.route_checks import RouteChecks
+from litellm.proxy.auth.team_grants import team_grants
+
+_FAMILY_SCRIPT: Final = """
+local current = redis.call('GET', KEYS[1])
+local revoked = '!revoked'
+if ARGV[1] == 'revoke' then
+ redis.call('SET', KEYS[1], revoked, 'EX', ARGV[4]); return 1
+end
+if ARGV[1] == 'active' then
+ return current and current ~= revoked and (ARGV[2] == '' or current == ARGV[2]) and 1 or 0
+end
+if ARGV[1] == 'create' then
+ if current then return 0 end
+elseif not current or current == revoked then
+ return 0
+elseif current ~= ARGV[2] then
+ redis.call('SET', KEYS[1], revoked, 'EX', ARGV[4]); return 0
+end
+redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[4])
+return 1
+"""
+
+
+class _AuthenticationPolicy(BaseModel):
+ general_settings: Mapping[str, object] = Field(default_factory=dict)
+ user_custom_auth: object = None
+
+
+class _UserBudget(BaseModel):
+ model_max_budget: Mapping[str, object] | None = None
+
+
+def is_delegated_callback(redirect_uri: str) -> bool:
+ try:
+ parsed: Final = urlparse(redirect_uri)
+ except ValueError:
+ return False
+ return (
+ redirect_uri.isprintable()
+ and parsed.scheme == "https"
+ and bool(parsed.hostname)
+ and not parsed.username
+ and not parsed.password
+ and not parsed.fragment
+ and redirect_uri
+ in tuple(value.strip() for value in os.getenv("LITELLM_OAUTH_ADMIN_REDIRECT_URIS", "").split(","))
+ )
+
+
+async def _family_transition(
+ principal: SessionPrincipal,
+ operation: Literal["create", "rotate", "active", "revoke"],
+ previous: str = "",
+ replacement: str = "",
+) -> bool:
+ from litellm.proxy.proxy_server import redis_usage_cache
+
+ delegation: Final = principal.delegation
+ if delegation is None or principal.audience != "proxy_api":
+ return False
+ ttl: Final = delegation.expires_at - int(datetime.now(timezone.utc).timestamp())
+ if ttl <= 0:
+ return False
+ if redis_usage_cache is None:
+ raise HTTPException(503, "Delegated OAuth requires shared Redis coordination")
+ key: Final = "oauth:delegation:" + delegation.grant_id
+ try:
+ result: Final = TypeAdapter(int).validate_python(
+ await redis_usage_cache.async_register_script(_FAMILY_SCRIPT)(
+ keys=(key,), args=(operation, previous, replacement, ttl)
+ ),
+ strict=True,
+ )
+ except Exception as exc:
+ raise HTTPException(503, "Delegated OAuth coordination is unavailable") from exc
+ return result == 1
+
+
+async def delegation_active(principal: SessionPrincipal, refresh_jti: str | None = None) -> bool:
+ return (
+ principal.delegation is not None
+ and is_delegated_callback(principal.delegation.redirect_uri)
+ and await _family_transition(principal, "active", refresh_jti or "")
+ )
+
+
+async def revoke_delegation(principal: SessionPrincipal) -> None:
+ await _family_transition(principal, "revoke")
+
+
+async def delegated_user(user_id: str) -> LiteLLM_UserTable:
+ from litellm.proxy import proxy_server
+
+ policy: Final = _AuthenticationPolicy.model_validate(vars(proxy_server))
+ if policy.user_custom_auth is not None or any(
+ policy.general_settings.get(flag) is True for flag in ("enable_oauth2_auth", "enable_oauth2_proxy_auth")
+ ):
+ raise HTTPException(503, "Delegated OAuth requires the gateway database authentication policy")
+ user: Final = await load_active_user_by_id(user_id, source="database")
+ if isinstance(user, str):
+ raise HTTPException(503 if user in ("unavailable", "faulted", "unresolvable") else 401, "User is unavailable")
+ if user.user_role != LitellmUserRoles.PROXY_ADMIN:
+ raise HTTPException(403, "Delegated admin access requires an active proxy admin")
+ return user
+
+
+async def delegated_identity(principal: SessionPrincipal) -> UserAPIKeyAuth:
+ from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
+
+ try:
+ user: Final = await delegated_user(principal.user_id)
+ if (principal.team_id is None and user.teams) or (
+ principal.team_id is not None and principal.team_id not in user.teams
+ ):
+ raise HTTPException(403, "The selected team is no longer available; authorize again")
+ team: Final = (
+ await get_team_object(principal.team_id, prisma_client, user_api_key_cache, check_db_only=True)
+ if principal.team_id is not None
+ else None
+ )
+ if team is not None and (
+ team.blocked or not any(member.user_id == user.user_id for member in team.members_with_roles)
+ ):
+ raise HTTPException(403, "The selected team is blocked or its membership has been removed")
+ membership: Final = (
+ await get_team_membership(user.user_id, team.team_id, prisma_client, user_api_key_cache, check_db_only=True)
+ if team is not None
+ else None
+ )
+ permission: Final = (
+ await get_object_permission(
+ user.object_permission_id, prisma_client, user_api_key_cache, check_db_only=True
+ )
+ if user.object_permission_id is not None
+ else None
+ )
+ identity: Final = UserAPIKeyAuth.model_validate(
+ {
+ **team_grants(team, membership, user.user_id),
+ "user_id": user.user_id,
+ "user_role": user.user_role,
+ "user_email": user.user_email,
+ "team_id": principal.team_id,
+ "org_id": team.organization_id if team is not None else user.organization_id,
+ "models": () if team is not None else user_models(user),
+ "user_tpm_limit": user.tpm_limit,
+ "user_rpm_limit": user.rpm_limit,
+ "user_max_budget": user.max_budget,
+ "user_model_max_budget": _UserBudget.model_validate(user.model_dump()).model_max_budget,
+ "user_spend": user.spend,
+ "object_permission": permission,
+ "object_permission_id": user.object_permission_id,
+ }
+ )
+ identity.requires_fresh_policy = True
+ return identity
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(503, "Delegated user policy is unavailable") from exc
+
+
+async def issue_delegated_tokens(
+ principal: SessionPrincipal,
+ keys: SessionSigningKeys,
+ now: datetime,
+ previous_refresh_jti: str | None = None,
+) -> Response:
+ try:
+ if principal.delegation is None or not is_delegated_callback(principal.delegation.redirect_uri):
+ raise HTTPException(400, "The delegated callback is not approved")
+ await delegated_identity(principal)
+ access: Final = mint_session_token(principal, keys, now)
+ refresh: Final = mint_session_refresh_token(principal, keys, now)
+ if not isinstance(access, MintedSessionToken) or not isinstance(refresh, MintedSessionToken):
+ raise HTTPException(500, "Could not issue delegated tokens")
+ opened: Final = open_session_refresh_token(refresh.token.get_secret_value(), keys, now)
+ if not isinstance(opened, OpenedSessionToken):
+ raise HTTPException(500, "Could not issue delegated tokens")
+ if not await _family_transition(
+ principal, "create" if previous_refresh_jti is None else "rotate", previous_refresh_jti or "", opened.jti
+ ):
+ raise HTTPException(400, "The grant is expired, revoked, or reused; authorize again")
+ return JSONResponse(
+ {
+ "access_token": access.token.get_secret_value(),
+ "token_type": "Bearer",
+ "expires_in": int((access.expires_at - now).total_seconds()),
+ "refresh_token": refresh.token.get_secret_value(),
+ "scope": principal.delegation.scope,
+ },
+ headers=TOKEN_NO_CACHE_HEADERS,
+ )
+ except HTTPException as exc:
+ return JSONResponse(
+ {
+ "error": "temporarily_unavailable" if exc.status_code >= 500 else "invalid_grant",
+ "error_description": exc.detail,
+ },
+ status_code=503 if exc.status_code >= 500 else 400,
+ headers=TOKEN_NO_CACHE_HEADERS,
+ )
+
+
+async def authenticate_delegated_request(request: Request, token: str, route: str) -> UserAPIKeyAuth:
+ from litellm.proxy.proxy_server import master_key
+
+ keys: Final = active_session_signing_keys(master_key) if master_key else None
+ if keys is None or isinstance(keys, SessionSigningConfigError):
+ raise HTTPException(503, "The gateway token signing configuration is unavailable")
+ opened: Final = open_session_token(token, keys, datetime.now(timezone.utc))
+ if not isinstance(opened, OpenedSessionToken) or opened.principal.delegation is None:
+ raise HTTPException(401, "Invalid delegated access token")
+ if opened.principal.delegation.resource != canonical_resource_uri(get_request_base_url(request)):
+ raise HTTPException(401, "The delegated token belongs to a different gateway")
+ if not RouteChecks.is_delegated_admin_route(route, request):
+ raise HTTPException(403, "This route is outside the approved application scope")
+ if not await delegation_active(opened.principal):
+ raise HTTPException(401, "The application grant has expired or been revoked")
+ identity: Final = await delegated_identity(opened.principal)
+ identity.token = hash_token(token)
+ identity.key_alias = "oauth-admin"
+ return identity
diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py
index 6445f0d7b05..74d26aa1689 100644
--- a/litellm/proxy/auth/route_checks.py
+++ b/litellm/proxy/auth/route_checks.py
@@ -15,6 +15,11 @@ from litellm.proxy._types import (
)
from .auth_checks_organization import _user_is_org_admin
+from .auth_utils import (
+ get_request_route_template,
+ request_dispatched_to_pass_through_endpoint,
+ request_dispatched_to_provider_pass_through,
+)
# Management write routes denied to PROXY_ADMIN_VIEW_ONLY. Adding a new write
# endpoint to a management router REQUIRES adding it here too — the surrounding
@@ -68,6 +73,48 @@ _AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", "l
class RouteChecks:
+ @staticmethod
+ def is_delegated_admin_route(route: str, request: Request) -> bool:
+ template: Final = get_request_route_template(request) or route
+ excluded: Final = (
+ *LiteLLMRoutes.master_key_only_routes.value,
+ *LiteLLMRoutes.mcp_routes.value,
+ "/jwt/*",
+ "/config/*",
+ "/get/config/*",
+ "/sso/*",
+ "/session/*",
+ "/user/auth",
+ "/user/password/*",
+ "/callbacks/*",
+ "/team/{team_id:path}/callback",
+ "/team/{team_id:path}/callback/{callback_name}",
+ )
+ allowed: Final = (
+ *LiteLLMRoutes.management_routes.value,
+ *LiteLLMRoutes.self_managed_routes.value,
+ *LiteLLMRoutes.org_admin_only_routes.value,
+ *LiteLLMRoutes.openai_routes.value,
+ *LiteLLMRoutes.anthropic_routes.value,
+ *LiteLLMRoutes.google_routes.value,
+ *LiteLLMRoutes.admin_viewer_routes.value,
+ *LiteLLMRoutes.global_spend_tracking_routes.value,
+ "/budget/new",
+ "/budget/update",
+ "/budget/delete",
+ "/budget/info",
+ "/organization/new",
+ "/organization/update",
+ "/organization/list",
+ )
+ return (
+ route.isprintable()
+ and not request_dispatched_to_pass_through_endpoint(request)
+ and not request_dispatched_to_provider_pass_through(request)
+ and not RouteChecks.check_route_access(template, excluded)
+ and RouteChecks.check_route_access(template, allowed)
+ )
+
@staticmethod
def should_call_route(
route: str,
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index e82f3eed7cc..eb9a49b965e 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -662,7 +662,7 @@ async def user_api_key_auth_websocket_for_model(websocket: WebSocket, model: str
"path": ws_scope.get("path", ""),
"state": ws_scope.setdefault("state", {}),
}
- for key in ("root_path", "app_root_path"):
+ for key in ("root_path", "app_root_path", "endpoint", "path_params", "route"):
if key in ws_scope:
synthetic_scope[key] = ws_scope[key]
request: Final = Request(scope=synthetic_scope)
@@ -1667,7 +1667,15 @@ async def _user_api_key_auth_builder(
if general_settings.get("enable_oauth2_proxy_auth", False) is True:
return await handle_oauth2_proxy_request(request=request)
- if general_settings.get("enable_jwt_auth", False) is True:
+ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
+ is_session_bearer_shaped,
+ )
+ from litellm.proxy.auth.delegated_oauth import authenticate_delegated_request
+
+ if api_key is not None and is_session_bearer_shaped(api_key):
+ valid_token = await authenticate_delegated_request(request, api_key, route)
+
+ if valid_token is None and general_settings.get("enable_jwt_auth", False) is True:
is_jwt = jwt_handler.is_jwt(token=api_key)
verbose_proxy_logger.debug("is_jwt: %s", is_jwt)
if is_jwt:
@@ -1899,7 +1907,7 @@ async def _user_api_key_auth_builder(
#### ELSE ####
## CHECK PASS-THROUGH ENDPOINTS ##
- if not custom_auth_api_key:
+ if not custom_auth_api_key and not (valid_token is not None and valid_token.requires_fresh_policy):
response = await check_api_key_for_custom_headers_or_pass_through_endpoints(
request=request,
route=route,
@@ -2051,6 +2059,7 @@ async def _user_api_key_auth_builder(
valid_token is not None
and isinstance(valid_token, UserAPIKeyAuth)
and valid_token.user_role == LitellmUserRoles.PROXY_ADMIN
+ and not valid_token.requires_fresh_policy
):
if valid_token.expires is not None:
current_time = datetime.now(timezone.utc)
@@ -2086,6 +2095,7 @@ async def _user_api_key_auth_builder(
and isinstance(valid_token, UserAPIKeyAuth)
and valid_token.team_id is not None
and valid_token.team_id != UI_TEAM_ID
+ and not valid_token.requires_fresh_policy
):
## UPDATE TEAM VALUES BASED ON CACHED TEAM OBJECT - allows `/team/update` values to work for cached token
try:
@@ -2317,8 +2327,11 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
user_id_upsert=False,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
+ check_db_only=valid_token.requires_fresh_policy,
)
except Exception as e:
+ if valid_token.requires_fresh_policy:
+ raise
verbose_logger.debug(
"litellm.proxy.auth.user_api_key_auth.py::user_api_key_auth() - Unable to get user from db/cache. Setting user_obj to None. Exception received - %s",
e,
@@ -2363,11 +2376,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
if prisma_client is not None and _user_id is not None and _team_id is not None:
_cache_key: Final = team_membership_auth_cache_key(team_id=_team_id, user_id=_user_id)
- team_member_info = await user_api_key_cache.async_get_cache(
- key=_cache_key,
- model_type=LiteLLM_TeamMembership,
+ team_member_info = (
+ await get_team_membership(_user_id, _team_id, prisma_client, user_api_key_cache, check_db_only=True)
+ if valid_token.requires_fresh_policy
+ else await user_api_key_cache.async_get_cache(key=_cache_key, model_type=LiteLLM_TeamMembership)
)
- if team_member_info is None:
+ if team_member_info is None and not valid_token.requires_fresh_policy:
# read from DB
_db_member: Final = await TeamMembershipRepository(prisma_client).table.find_first(
where={
@@ -2551,8 +2565,11 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
+ check_db_only=valid_token.requires_fresh_policy,
)
except HTTPException:
+ if valid_token.requires_fresh_policy:
+ raise
token_team_models: Final = _token_team_models(valid_token)
_team_obj = LiteLLM_TeamTableCachedObj(
team_id=valid_token.team_id,
@@ -2660,11 +2677,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
route=route,
start_time=start_time,
)
- virtual_key_auth_obj.via_virtual_key = True
+ virtual_key_auth_obj.requires_fresh_policy = valid_token.requires_fresh_policy
+ virtual_key_auth_obj.via_virtual_key = not valid_token.requires_fresh_policy
return virtual_key_auth_obj
-async def _safe_fetch(label: str, awaitable):
+async def _safe_fetch(label: str, awaitable, *, fail_closed: bool = False):
"""Run an awaitable and return its result. Re-raises authentication /
authorization failures (HTTPException, ProxyException,
BudgetExceededError) so they propagate to the caller.
@@ -2684,6 +2702,8 @@ async def _safe_fetch(label: str, awaitable):
)
raise
except Exception as e:
+ if fail_closed:
+ raise
verbose_proxy_logger.debug(
"centralized auth: %s fetch swallowed (%s: %s)",
label,
@@ -2899,7 +2919,9 @@ async def _run_centralized_common_checks(
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
+ check_db_only=user_api_key_auth_obj.requires_fresh_policy,
),
+ fail_closed=user_api_key_auth_obj.requires_fresh_policy,
)
)
else:
@@ -2916,7 +2938,9 @@ async def _run_centralized_common_checks(
user_id_upsert=False,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
+ check_db_only=user_api_key_auth_obj.requires_fresh_policy,
),
+ fail_closed=user_api_key_auth_obj.requires_fresh_policy,
)
)
else:
@@ -2932,6 +2956,7 @@ async def _run_centralized_common_checks(
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
),
+ fail_closed=user_api_key_auth_obj.requires_fresh_policy,
)
)
else:
@@ -2951,6 +2976,7 @@ async def _run_centralized_common_checks(
token_end_user_max_budget=user_api_key_auth_obj.end_user_max_budget,
key_end_user_budget_id=key_end_user_budget_id,
),
+ fail_closed=user_api_key_auth_obj.requires_fresh_policy,
)
)
else:
@@ -2966,6 +2992,7 @@ async def _run_centralized_common_checks(
token=user_api_key_auth_obj.token or "",
proxy_logging_obj=proxy_logging_obj,
),
+ fail_closed=user_api_key_auth_obj.requires_fresh_policy,
)
)
@@ -2995,6 +3022,8 @@ async def _run_centralized_common_checks(
end_user_result,
global_spend_result,
):
+ if user_api_key_auth_obj.requires_fresh_policy and isinstance(r, BaseException):
+ raise r
if isinstance(r, (ProxyException, litellm.BudgetExceededError)):
raise r
@@ -3049,7 +3078,10 @@ async def _run_centralized_common_checks(
# caller. The token is the source of truth for these paths — force
# the admin user_object whenever the token says PROXY_ADMIN, even
# if a DB row was fetched.
- if user_api_key_auth_obj.user_role == LitellmUserRoles.PROXY_ADMIN:
+ if (
+ user_api_key_auth_obj.user_role == LitellmUserRoles.PROXY_ADMIN
+ and not user_api_key_auth_obj.requires_fresh_policy
+ ):
user_object = LiteLLM_UserTable(
user_id=user_api_key_auth_obj.user_id or litellm_proxy_admin_name,
user_role=LitellmUserRoles.PROXY_ADMIN,
diff --git a/litellm/proxy/common_utils/html_forms/native_client_consent.py b/litellm/proxy/common_utils/html_forms/native_client_consent.py
index dac92c4e787..fd9877fe70c 100644
--- a/litellm/proxy/common_utils/html_forms/native_client_consent.py
+++ b/litellm/proxy/common_utils/html_forms/native_client_consent.py
@@ -12,18 +12,28 @@ def render_native_client_consent_page(
teams: Sequence[tuple[str, str]],
flow_handle: str,
complete_url: str,
+ delegated: bool = False,
) -> str:
"""The consent page a native client's sign-in lands on: who is signed in, which
loopback client asked, which team the credential is attributed to, and an explicit
Approve or Deny that POSTs back to ``complete_url``. Every value is client- or
user-influenced and HTML-escaped; the flow handle travels only in the form body."""
+ title: Final = "Authorize application access" if delegated else "Authorize CLI access"
+ client: Final = "An application" if delegated else "A command-line client"
+ permission: Final = (
+ "Approving allows this application to manage keys, users, teams and budgets with your current admin "
+ "permissions until you disconnect or the authorization expires. Only approve an application you trust."
+ if delegated
+ else f"Approving issues it a personal credential that expires within {CLI_JWT_EXPIRATION_HOURS} hours. "
+ "lite logout stops it from being renewed. Only approve if you started this sign-in yourself."
+ )
return f"""