From f115bb4ad05b55109b3ad67c1c089c28d9f5e4bb Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Sat, 3 Oct 2026 01:01:05 -0700 Subject: [PATCH] feat(auth): delegate hosted admin access through existing OAuth --- .../mcp_server/discoverable_endpoints.py | 24 +- .../mcp_server/gateway_dcr_flow.py | 115 ++++++-- .../session_credentials.py | 2 +- .../outbound_credentials/session_token.py | 62 ++++- litellm/proxy/_lazy_openapi_snapshot.json | 72 ++++- litellm/proxy/auth/auth_checks.py | 16 +- litellm/proxy/auth/auth_exception_handler.py | 1 + litellm/proxy/auth/auth_utils.py | 7 +- litellm/proxy/auth/delegated_oauth.py | 259 ++++++++++++++++++ litellm/proxy/auth/route_checks.py | 47 ++++ litellm/proxy/auth/user_api_key_auth.py | 52 +++- .../html_forms/native_client_consent.py | 18 +- .../pass_through_endpoints/common_utils.py | 11 +- .../llm_passthrough_endpoints.py | 5 +- .../openai_passthrough_endpoints.py | 4 + litellm/proxy/proxy_server.py | 3 + .../vertex_ai_endpoints/langfuse_endpoints.py | 4 + .../pass_through_endpoints.py | 1 + .../authorization/test_delegated_oauth.py | 211 ++++++++++++++ .../test_session_credentials.py | 8 + .../test_session_token.py | 51 ++++ .../mcp_server/test_discoverable_endpoints.py | 17 ++ .../mcp_server/test_gateway_dcr_flow.py | 110 ++++++++ .../proxy/auth/test_auth_exception_handler.py | 14 + tests/unit/proxy/auth/test_delegated_oauth.py | 78 ++++++ tests/unit/proxy/auth/test_route_checks.py | 46 ++++ .../unit/proxy/auth/test_user_api_key_auth.py | 46 +++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 41 ++- 28 files changed, 1263 insertions(+), 62 deletions(-) create mode 100644 litellm/proxy/auth/delegated_oauth.py create mode 100644 tests/integration/authorization/test_delegated_oauth.py create mode 100644 tests/unit/proxy/auth/test_delegated_oauth.py 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""" -Authorize CLI access - LiteLLM +{title} - LiteLLM