diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 11cec01fdee..3b090b3a44a 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -238,7 +238,7 @@ router_settings: | public_routes | List[str] | (Enterprise Feature) Control list of public routes | | alert_types | List[str] | Control list of alert types to send to slack (Doc on alert types)[./alerting.md] | | enforced_params | List[str] | (Enterprise Feature) List of params that must be included in all requests to the proxy | -| enable_oauth2_auth | boolean | (Enterprise Feature) If true, enables oauth2.0 authentication | +| enable_oauth2_auth | boolean | (Enterprise Feature) If true, enables oauth2.0 authentication on LLM + info routes | | use_x_forwarded_for | str | If true, uses the X-Forwarded-For header to get the client IP address | | service_account_settings | List[Dict[str, Any]] | Set `service_account_settings` if you want to create settings that only apply to service account keys (Doc on service accounts)[./service_accounts.md] | | image_generation_model | str | The default model to use for image generation - ignores model set in request | diff --git a/docs/my-website/docs/proxy/oauth2.md b/docs/my-website/docs/proxy/oauth2.md index c0597058cfd..9b94a017ca1 100644 --- a/docs/my-website/docs/proxy/oauth2.md +++ b/docs/my-website/docs/proxy/oauth2.md @@ -63,16 +63,19 @@ Start the LiteLLM Proxy with [`--detailed_debug` mode and you should see more ve ## Using OAuth2 + JWT Together -If both `enable_oauth2_auth` and `enable_jwt_auth` are enabled, LiteLLM can split auth paths: -- JWT validation for user tokens -- OAuth2 introspection for machine tokens +LiteLLM supports two OAuth2 + JWT modes: -For JWT-shaped machine tokens, configure `litellm_jwtauth.routing_overrides`: +1. **Global OAuth2 mode** (`enable_oauth2_auth: true`) + OAuth2 auth is enabled on LLM + info routes. +2. **Selective JWT override mode** (`enable_oauth2_auth: false`) + Only JWT-shaped tokens that match `litellm_jwtauth.routing_overrides` are routed to OAuth2 on LLM + info routes. + +For selective routing (OAuth2 only for specific JWTs), configure: ```yaml title="config.yaml" general_settings: enable_jwt_auth: true - enable_oauth2_auth: true + enable_oauth2_auth: false litellm_jwtauth: routing_overrides: - iss: "machine-issuer.example.com" diff --git a/docs/my-website/docs/proxy/token_auth.md b/docs/my-website/docs/proxy/token_auth.md index d37b05391b6..4d49a2445ef 100644 --- a/docs/my-website/docs/proxy/token_auth.md +++ b/docs/my-website/docs/proxy/token_auth.md @@ -792,16 +792,18 @@ litellm_jwtauth: ## Route JWT-Shaped Machine Tokens to OAuth2 -Use this when both are enabled: +Use this when: - `enable_jwt_auth: true` for standard JWT validation -- `enable_oauth2_auth: true` for OAuth2 introspection +- machine tokens are JWT-shaped and should be routed to OAuth2 based on claims -If some machine tokens are also JWT-shaped, configure `routing_overrides` to route matching tokens to OAuth2. +`routing_overrides` supports two operating modes: +- **Selective mode**: set `enable_oauth2_auth: false` to send only matching JWTs to OAuth2 on LLM + info routes +- **Global mode**: set `enable_oauth2_auth: true` to also enable OAuth2 on LLM + info routes ```yaml title="config.yaml" general_settings: enable_jwt_auth: true - enable_oauth2_auth: true + enable_oauth2_auth: false litellm_jwtauth: user_id_jwt_field: "sub" routing_overrides: @@ -822,7 +824,7 @@ general_settings: ```yaml title="config.yaml" general_settings: enable_jwt_auth: true - enable_oauth2_auth: true + enable_oauth2_auth: false litellm_jwtauth: routing_overrides: - iss: ["machine-issuer.example.com", "backup-issuer.example.com"] diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 046c39a9101..61c618eeb18 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -690,42 +690,39 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ########## End of Route Checks Before Reading DB / Cache for "token" ######## - if general_settings.get("enable_oauth2_auth", False) is True: - # Only apply OAuth2 M2M authentication to LLM API routes and info routes, not UI/management routes - # This allows UI SSO to work separately from API M2M authentication - # Note: Info routes are already scoped to the user - if RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route( - route=route - ): - # When both OAuth2 and JWT auth are enabled, use token format to decide: - # - JWT tokens (3 dot-separated parts) -> skip OAuth2, fall through to JWT handler - # - Opaque tokens -> use OAuth2 handler - # This allows JWT for users and OAuth2 for M2M on the same instance - is_jwt = ( - jwt_handler.is_jwt(token=api_key) - if general_settings.get("enable_jwt_auth", False) is True - else False - ) - # Routing uses unverified JWT claims only to choose auth path. - # Final authentication is enforced by the selected validator. - route_jwt_to_oauth2 = ( - is_jwt - and _should_route_jwt_to_oauth2_override( - token=api_key, jwt_handler=jwt_handler - ) - ) - if not is_jwt or route_jwt_to_oauth2: - # return UserAPIKeyAuth object - # helper to check if the api_key is a valid oauth2 token - from litellm.proxy.proxy_server import premium_user + enable_oauth2_auth = general_settings.get("enable_oauth2_auth", False) is True + enable_jwt_auth = general_settings.get("enable_jwt_auth", False) is True + is_jwt = jwt_handler.is_jwt(token=api_key) if enable_jwt_auth else False - if premium_user is not True: - raise ValueError( - "Oauth2 token validation is only available for premium users" - + CommonProxyErrors.not_premium_user.value - ) + # Routing uses unverified JWT claims only to choose auth path. + # Final authentication is enforced by the selected validator. + route_jwt_to_oauth2 = ( + is_jwt + and _should_route_jwt_to_oauth2_override( + token=api_key, jwt_handler=jwt_handler + ) + ) - return await Oauth2Handler.check_oauth2_token(token=api_key) + # OAuth2 applies for: + # 1) when global OAuth2 auth is enabled on LLM + info routes + # 2) JWT tokens that explicitly match routing_overrides on LLM + info routes + should_apply_override_oauth2 = route_jwt_to_oauth2 and ( + RouteChecks.is_llm_api_route(route=route) + or RouteChecks.is_info_route(route=route) + ) + should_apply_global_oauth2 = enable_oauth2_auth and ( + RouteChecks.is_llm_api_route(route=route) + or RouteChecks.is_info_route(route=route) + ) + if (should_apply_global_oauth2 and not is_jwt) or should_apply_override_oauth2: + from litellm.proxy.proxy_server import premium_user + if premium_user is not True: + raise ValueError( + "Oauth2 token validation is only available for premium users" + + CommonProxyErrors.not_premium_user.value + ) + + return await Oauth2Handler.check_oauth2_token(token=api_key) if general_settings.get("enable_oauth2_proxy_auth", False) is True: return await handle_oauth2_proxy_request(request=request) diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 6e1b245b3de..14912b67ab8 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -713,6 +713,51 @@ class TestJWTOAuth2Coexistence: mock_jwt_auth.assert_not_called() assert result.user_id == "machine-client-1" + @pytest.mark.asyncio + async def test_oauth2_path_requires_premium_user(self): + """ + OAuth2 token validation should fail when enterprise premium is disabled. + """ + opaque_token = "some-opaque-m2m-oauth2-token" + general_settings = { + "enable_oauth2_auth": True, + "enable_jwt_auth": True, + } + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.headers = {"authorization": f"Bearer {opaque_token}"} + mock_request.query_params = {} + + with patch( + "litellm.proxy.proxy_server.general_settings", general_settings + ), patch("litellm.proxy.proxy_server.premium_user", False), patch( + "litellm.proxy.proxy_server.master_key", "sk-master" + ), patch( + "litellm.proxy.proxy_server.prisma_client", None + ), patch( + "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", + new_callable=AsyncMock, + ) as mock_oauth2: + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {opaque_token}", + ) + + assert exc_info.value.type == ProxyErrorTypes.auth_error + assert ( + "Oauth2 token validation is only available for premium users" + in exc_info.value.message + ) + mock_oauth2.assert_not_called() + @pytest.mark.asyncio async def test_both_enabled_jwt_token_skips_oauth2(self): """ @@ -974,6 +1019,248 @@ class TestJWTOAuth2Coexistence: mock_jwt_auth.assert_not_called() assert result.user_id == "machine-client-aud-list" + @pytest.mark.asyncio + async def test_routing_override_routes_jwt_to_oauth2_when_oauth2_globally_disabled( + self, + ): + """ + If enable_oauth2_auth is false, JWT tokens matching routing_overrides + should still route to OAuth2 introspection. + """ + jwt_token = ( + "eyJhbGciOiJSUzI1NiJ9." + "eyJpc3MiOiJtYWNoaW5lLWlzc3Vlci5leGFtcGxlLmNvbSIsImNsaWVudF9pZCI6Ik1JRF9MSVRFTExNIn0." + "c2ln" + ) + general_settings = { + "enable_oauth2_auth": False, + "enable_jwt_auth": True, + } + mock_oauth2_response = UserAPIKeyAuth( + api_key=jwt_token, + user_id="machine-client-override-oauth2-off", + ) + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + + with patch( + "litellm.proxy.proxy_server.general_settings", general_settings + ), patch("litellm.proxy.proxy_server.premium_user", True), patch( + "litellm.proxy.proxy_server.master_key", "sk-master" + ), patch( + "litellm.proxy.proxy_server.prisma_client", None + ), patch( + "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", + new_callable=AsyncMock, + return_value=mock_oauth2_response, + ) as mock_oauth2, patch( + "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", + new_callable=AsyncMock, + ) as mock_jwt_auth: + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth( + routing_overrides=[ + JWTRoutingOverride( + iss="machine-issuer.example.com", + client_id="MID_LITELLM", + path="oauth2", + ) + ] + ), + ) + + result = await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {jwt_token}", + ) + + mock_oauth2.assert_called_once_with(token=jwt_token) + mock_jwt_auth.assert_not_called() + assert result.user_id == "machine-client-override-oauth2-off" + + @pytest.mark.asyncio + async def test_opaque_token_does_not_use_oauth2_when_oauth2_globally_disabled( + self, + ): + """ + With enable_oauth2_auth=false, opaque tokens must not be sent to OAuth2. + """ + opaque_token = "sk-ui-session-token" + general_settings = { + "enable_oauth2_auth": False, + "enable_jwt_auth": True, + } + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.headers = {"authorization": f"Bearer {opaque_token}"} + mock_request.query_params = {} + + with patch( + "litellm.proxy.proxy_server.general_settings", general_settings + ), patch("litellm.proxy.proxy_server.premium_user", True), patch( + "litellm.proxy.proxy_server.master_key", "sk-master" + ), patch( + "litellm.proxy.proxy_server.prisma_client", None + ), patch( + "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", + new_callable=AsyncMock, + ) as mock_oauth2: + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {opaque_token}", + ) + + assert exc_info.value.type in ( + ProxyErrorTypes.auth_error, + ProxyErrorTypes.no_db_connection, + ) + mock_oauth2.assert_not_called() + + @pytest.mark.asyncio + async def test_routing_override_on_info_route_uses_oauth2_when_oauth2_globally_disabled( + self, + ): + """ + With enable_oauth2_auth=false, a JWT matching routing_overrides should + still route to OAuth2 on info routes. + """ + jwt_token = ( + "eyJhbGciOiJSUzI1NiJ9." + "eyJpc3MiOiJtYWNoaW5lLWlzc3Vlci5leGFtcGxlLmNvbSIsImNsaWVudF9pZCI6Ik1JRF9MSVRFTExNIn0." + "c2ln" + ) + general_settings = { + "enable_oauth2_auth": False, + "enable_jwt_auth": True, + } + mock_oauth2_response = UserAPIKeyAuth( + api_key=jwt_token, + user_id="machine-client-info-override-oauth2-off", + ) + + mock_request = MagicMock() + mock_request.url.path = "/team/list" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + + with patch( + "litellm.proxy.proxy_server.general_settings", general_settings + ), patch("litellm.proxy.proxy_server.premium_user", True), patch( + "litellm.proxy.proxy_server.master_key", "sk-master" + ), patch( + "litellm.proxy.proxy_server.prisma_client", None + ), patch( + "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", + new_callable=AsyncMock, + return_value=mock_oauth2_response, + ) as mock_oauth2, patch( + "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", + new_callable=AsyncMock, + ) as mock_jwt_auth: + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth( + routing_overrides=[ + JWTRoutingOverride( + iss="machine-issuer.example.com", + client_id="MID_LITELLM", + path="oauth2", + ) + ] + ), + ) + + result = await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {jwt_token}", + ) + + mock_oauth2.assert_called_once_with(token=jwt_token) + mock_jwt_auth.assert_not_called() + assert result.user_id == "machine-client-info-override-oauth2-off" + + @pytest.mark.asyncio + async def test_routing_override_on_management_route_does_not_use_oauth2(self): + """ + JWT routing_overrides should not force OAuth2 on management routes. + """ + jwt_token = ( + "eyJhbGciOiJSUzI1NiJ9." + "eyJpc3MiOiJtYWNoaW5lLWlzc3Vlci5leGFtcGxlLmNvbSIsImNsaWVudF9pZCI6Ik1JRF9MSVRFTExNIn0." + "c2ln" + ) + general_settings = { + "enable_oauth2_auth": False, + "enable_jwt_auth": True, + } + mock_jwt_result = { + "is_proxy_admin": True, + "team_object": None, + "user_object": None, + "end_user_object": None, + "org_object": None, + "token": jwt_token, + "team_id": None, + "user_id": "jwt-admin-user", + "end_user_id": None, + "org_id": None, + "team_membership": None, + "jwt_claims": { + "iss": "machine-issuer.example.com", + "client_id": "MID_LITELLM", + }, + } + + mock_request = MagicMock() + mock_request.url.path = "/key/generate" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + + with patch( + "litellm.proxy.proxy_server.general_settings", general_settings + ), patch("litellm.proxy.proxy_server.premium_user", True), patch( + "litellm.proxy.proxy_server.master_key", "sk-master" + ), patch( + "litellm.proxy.proxy_server.prisma_client", None + ), patch( + "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", + new_callable=AsyncMock, + ) as mock_oauth2, patch( + "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", + new_callable=AsyncMock, + return_value=mock_jwt_result, + ) as mock_jwt_auth: + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth( + routing_overrides=[ + JWTRoutingOverride( + iss="machine-issuer.example.com", + client_id="MID_LITELLM", + path="oauth2", + ) + ] + ), + ) + + result = await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {jwt_token}", + ) + + mock_oauth2.assert_not_called() + mock_jwt_auth.assert_called_once() + assert result.user_id == "jwt-admin-user" + @pytest.mark.asyncio async def test_only_oauth2_enabled_handles_all_tokens(self): """