feat(identity): add LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT escape hatch for OAuth2 unknown roles

OAuth2 role mapping is fail-closed by default; unknown IdP roles raise
ValueError instead of silently granting INTERNAL_USER access. Operators
whose IdP returns custom role names not in LitellmUserRoles can set
LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT=<role> to map unknown roles to that
fallback. An invalid env value raises on first use so misconfiguration
is loud, not silently downgraded.

Resolves the reviewer split: Veria flagged the original INTERNAL_USER
default as fail-open; Greptile flagged the pure-raise as breaking
deployments with custom IdP role names. The opt-in flag preserves the
secure default and gives operators an explicit escape hatch.
This commit is contained in:
Yassin Kortam 2026-06-08 18:53:26 -07:00
parent 36e5ed2048
commit 368bfabedc
2 changed files with 44 additions and 1 deletions

View file

@ -6,10 +6,20 @@ an already-validated response payload into the carrier.
from __future__ import annotations
import os
from typing import Optional
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
UNKNOWN_ROLE_DEFAULT_ENV = "LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT"
def _unknown_role_fallback() -> Optional[LitellmUserRoles]:
raw = os.getenv(UNKNOWN_ROLE_DEFAULT_ENV)
if not raw:
return None
return LitellmUserRoles(raw)
def build_user_api_key_auth_from_oauth2_response(
*,
@ -29,6 +39,11 @@ def build_user_api_key_auth_from_oauth2_response(
Active-token validation, scope checks, and token-not-active rejection
happen upstream in ``Oauth2Handler``; this builder trusts its input.
Unknown IdP role values are rejected by default. Set
``LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT`` to a valid
``LitellmUserRoles`` value to map unknown roles to that fallback;
an invalid env value raises on first use so misconfiguration is loud.
"""
user_id: Optional[str] = response_data.get(user_id_field_name)
raw_role = response_data.get(user_role_field_name)
@ -41,7 +56,11 @@ def build_user_api_key_auth_from_oauth2_response(
try:
user_role = LitellmUserRoles(raw_role)
except ValueError as e:
raise ValueError(f"Invalid OAuth2 role: {raw_role!r}") from e
fallback = _unknown_role_fallback()
if fallback is not None:
user_role = fallback
else:
raise ValueError(f"Invalid OAuth2 role: {raw_role!r}") from e
return UserAPIKeyAuth(
api_key=token,

View file

@ -66,6 +66,30 @@ def test_missing_role_field_stays_none():
assert uak.user_role is None
def test_unknown_idp_role_uses_env_fallback_when_set(monkeypatch):
monkeypatch.setenv("LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT", "internal_user")
uak = build_user_api_key_auth_from_oauth2_response(
token="t", response_data={"sub": "u", "role": "custom-idp-role"}
)
assert uak.user_role == LitellmUserRoles.INTERNAL_USER
def test_unknown_idp_role_fallback_with_invalid_env_value_fails(monkeypatch):
monkeypatch.setenv("LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT", "not-a-real-role")
with pytest.raises(ValueError):
build_user_api_key_auth_from_oauth2_response(
token="t", response_data={"sub": "u", "role": "custom-idp-role"}
)
def test_known_idp_role_ignores_env_fallback(monkeypatch):
monkeypatch.setenv("LITELLM_OAUTH2_UNKNOWN_ROLE_DEFAULT", "internal_user")
uak = build_user_api_key_auth_from_oauth2_response(
token="t", response_data={"sub": "u", "role": "proxy_admin"}
)
assert uak.user_role == LitellmUserRoles.PROXY_ADMIN
def test_token_is_hashed_into_token_field():
"""The api_key is hashed by the UserAPIKeyAuth validator; the
OAuth2 builder must not bypass that path."""