From 2bbf688613f4632b68cbc0eaa88cfdfa5c741941 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:22:20 -0700 Subject: [PATCH 01/51] build(auth_v2): add Authlib and scim2-models for the auth_v2 module Pull in the OSS libraries the standards-based auth module orchestrates: Authlib for the OIDC login flow and scim2-models for SCIM 2.0, and switch PyJWT to the [crypto] extra so JWKS-backed RS256 verification is explicit (cryptography was already a proxy dependency). scim2-models ships py.typed but its generic, alias-driven models trip mypy's call-arg check though they work at runtime, so treat the library as untyped at the boundary in both litellm/mypy.ini (used by CI) and the root pyproject mypy config. --- litellm/mypy.ini | 6 +++++- pyproject.toml | 14 +++++++++++++- uv.lock | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/litellm/mypy.ini b/litellm/mypy.ini index 4702b591124b..bdd60084f709 100644 --- a/litellm/mypy.ini +++ b/litellm/mypy.ini @@ -16,4 +16,8 @@ ignore_errors = True [mypy-fastuuid.*] ignore_missing_imports = True -ignore_errors = True \ No newline at end of file +ignore_errors = True + +[mypy-scim2_models.*] +follow_imports = skip +follow_imports_for_stubs = True \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b9d76379faf3..610305e1905a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,9 @@ proxy = [ "orjson>=3.11.6,<4.0", "apscheduler>=3.11.2,<4.0", "fastapi-sso>=0.19.0,<1.0", - "PyJWT>=2.13.0,<3.0", + "PyJWT[crypto]>=2.13.0,<3.0", + "Authlib>=1.6.0,<2.0", + "scim2-models>=0.6.0,<1.0", "python-multipart>=0.0.27,<1.0", "cryptography>=46.0.7,<47.0", "pynacl>=1.6.2,<2.0", @@ -277,6 +279,16 @@ version_files = [ [tool.mypy] plugins = "pydantic.mypy" +# scim2-models ships py.typed, but its generic, alias-driven SCIM models report +# phantom call-arg errors under mypy though they work at runtime. Treat the +# library as untyped at the boundary; litellm/auth_v2 is its only consumer. +# CI runs mypy from litellm/ against litellm/mypy.ini, which carries the same +# override; this block keeps root-level mypy runs consistent. +[[tool.mypy.overrides]] +module = "scim2_models.*" +follow_imports = "skip" +follow_imports_for_stubs = true + [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/uv.lock b/uv.lock index 1100db783d3b..e1b84f4e6297 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-05T23:18:37.734017Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [manifest] @@ -423,6 +423,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/1f/aa74b23b6eea4cf9b79ace914df59123c4c8e7e4bd32dd22d09c126422d9/aurelio_sdk-0.0.19-py3-none-any.whl", hash = "sha256:390c0212b59ce99116df8722d3badced88c5ef0bb742a6222d479ceed0ed3948", size = 17322, upload-time = "2025-03-24T14:37:31.305Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "azure-ai-contentsafety" version = "1.0.0" @@ -2735,6 +2748,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/2f590052b55cbdd0ace470ee7ee1f685f6882051be93a9374891005623e2/joserfc-1.7.0.tar.gz", hash = "sha256:4aced6ab0c47846f0a531402aec2419a874b91e918df9c4c9da8a82fb559d6c4", size = 232967, upload-time = "2026-06-02T09:59:34.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/83/b6b62a66a06ce872d9429a5eb5ee20b2002fd9c331b953c94381c1f7c9f9/joserfc-1.7.0-py3-none-any.whl", hash = "sha256:17e5d7a5a35e65442b05efc435a3d5d46696ffa2c8a2ed0eea6f63fc268e3224", size = 70387, upload-time = "2026-06-02T09:59:33.264Z" }, +] + [[package]] name = "jsonlines" version = "4.0.0" @@ -3323,6 +3348,7 @@ mlflow = [ ] proxy = [ { name = "apscheduler" }, + { name = "authlib" }, { name = "azure-identity" }, { name = "azure-storage-blob" }, { name = "backoff" }, @@ -3338,7 +3364,7 @@ proxy = [ { name = "orjson" }, { name = "polars" }, { name = "pydantic-settings" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pynacl" }, { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, { name = "python-multipart" }, @@ -3346,6 +3372,7 @@ proxy = [ { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, + { name = "scim2-models" }, { name = "soundfile" }, { name = "starlette" }, { name = "uvicorn" }, @@ -3477,6 +3504,7 @@ requires-dist = [ { name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" }, { name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" }, { name = "aurelio-sdk", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.0.19,<1.0" }, + { name = "authlib", marker = "extra == 'proxy'", specifier = ">=1.6.0,<2.0" }, { name = "azure-ai-contentsafety", marker = "extra == 'proxy-runtime'", specifier = ">=1.0.0,<2.0" }, { name = "azure-identity", marker = "extra == 'extra-proxy'", specifier = ">=1.25.2,<2.0" }, { name = "azure-identity", marker = "extra == 'proxy'", specifier = ">=1.25.2,<2.0" }, @@ -3527,7 +3555,7 @@ requires-dist = [ { name = "prometheus-client", marker = "extra == 'proxy-runtime'", specifier = ">=0.20.0,<1.0" }, { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", marker = "extra == 'proxy'", specifier = ">=2.14.1,<3.0" }, - { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=6.10.2,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, @@ -3542,6 +3570,7 @@ requires-dist = [ { name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.4,<14.0" }, { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, + { name = "scim2-models", marker = "extra == 'proxy'", specifier = ">=0.6.0,<1.0" }, { name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" }, { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = ">=2.21.0,<3.0" }, { name = "soundfile", marker = "extra == 'proxy'", specifier = ">=0.12.1,<1.0" }, @@ -6894,6 +6923,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, ] +[[package]] +name = "scim2-models" +version = "0.6.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", extra = ["email"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/67/121a60b8433839fb19a9986e8d9f5416941e42ea6827bd7e520783477cca/scim2_models-0.6.12.tar.gz", hash = "sha256:3a57c5ec10dc1007e5f4391d3bddeaca974be8d55e9ede4a1de20019ad3a920b", size = 48631, upload-time = "2026-04-13T12:37:11.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b0/04d4b4e10ce59e813a3000d3ccb97537e88ab0341aacebc51abee50ed637/scim2_models-0.6.12-py3-none-any.whl", hash = "sha256:8ccc8139c61f84a29a1c54d8eda3f8fd121534cdd292166be1e8289d6f05902a", size = 61012, upload-time = "2026-04-13T12:37:10.322Z" }, +] + [[package]] name = "scipy" version = "1.15.3" From a0a59a2197dd7e2efd194eaa37141c75b5169e5a Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:22:20 -0700 Subject: [PATCH 02/51] feat(auth_v2): add standards-based auth and identity module New additive litellm/auth_v2 package: a thin orchestration layer over PyJWT, Authlib and scim2-models behind FastAPI's native Security() primitives that normalizes every credential into one standards-shaped Principal carrying org/team/user and network identity. Authentication, identity resolution, authorization and enforcement are kept as separate layers. Five authenticators cover the OpenAPI scheme types (apiKey, http bearer-JWT/basic, oauth2 at+jwt + introspection, openIdConnect, mutualTLS); a shared JwtVerifier enforces signature, issuer, audience and exp on every JWT path via a cached PyJWKClient. RBAC is a flat Role enum plus scope/role checks wired through SecurityScopes. Missing or invalid credentials return 401 with an RFC 9110/6750 WWW-Authenticate challenge, scope failures return 403 insufficient_scope. SCIM 2.0 Users/Groups/PATCH/discovery and an Authlib OIDC login flow share one ProvisioningStore seam; the SAML SP is a documented thin adapter pending pysaml2. The module is unimported by the proxy app and depends on nothing in litellm/proxy/auth. --- litellm/auth_v2/__init__.py | 11 + litellm/auth_v2/authenticators.py | 347 ++++++++++++++++++++++++++++++ litellm/auth_v2/config.py | 64 ++++++ litellm/auth_v2/errors.py | 46 ++++ litellm/auth_v2/models.py | 108 ++++++++++ litellm/auth_v2/network.py | 57 +++++ litellm/auth_v2/oidc.py | 65 ++++++ litellm/auth_v2/rbac.py | 28 +++ litellm/auth_v2/resolver.py | 154 +++++++++++++ litellm/auth_v2/saml.py | 35 +++ litellm/auth_v2/scim.py | 216 +++++++++++++++++++ litellm/auth_v2/security.py | 89 ++++++++ 12 files changed, 1220 insertions(+) create mode 100644 litellm/auth_v2/__init__.py create mode 100644 litellm/auth_v2/authenticators.py create mode 100644 litellm/auth_v2/config.py create mode 100644 litellm/auth_v2/errors.py create mode 100644 litellm/auth_v2/models.py create mode 100644 litellm/auth_v2/network.py create mode 100644 litellm/auth_v2/oidc.py create mode 100644 litellm/auth_v2/rbac.py create mode 100644 litellm/auth_v2/resolver.py create mode 100644 litellm/auth_v2/saml.py create mode 100644 litellm/auth_v2/scim.py create mode 100644 litellm/auth_v2/security.py diff --git a/litellm/auth_v2/__init__.py b/litellm/auth_v2/__init__.py new file mode 100644 index 000000000000..7373bc860c46 --- /dev/null +++ b/litellm/auth_v2/__init__.py @@ -0,0 +1,11 @@ +from .config import AuthConfig +from .models import Principal +from .security import get_current_principal, install_auth, require_roles + +__all__ = [ + "Principal", + "AuthConfig", + "get_current_principal", + "require_roles", + "install_auth", +] diff --git a/litellm/auth_v2/authenticators.py b/litellm/auth_v2/authenticators.py new file mode 100644 index 000000000000..54bd9c15a6f8 --- /dev/null +++ b/litellm/auth_v2/authenticators.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import base64 +import binascii +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + +import httpx +import jwt +from fastapi import Request +from jwt import PyJWKClient +from jwt import decode as jwt_decode + +from . import errors +from .config import ( + ApiKeySchemeConfig, + AuthConfig, + HttpBasicConfig, + MutualTlsConfig, + OAuth2IntrospectionConfig, + OidcProviderConfig, +) +from .models import ( + AuthMethod, + ClientCertificate, + Credential, + CredentialRef, + SecuritySchemeType, +) + +AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} + + +@runtime_checkable +class Authenticator(Protocol): + scheme: SecuritySchemeType + + async def authenticate(self, request: Request) -> Optional[Credential]: ... + + def challenge(self) -> str: ... + + +def _extract_bearer(request: Request) -> Optional[str]: + header = request.headers.get("authorization") + if not header: + return None + scheme, _, value = header.partition(" ") + if scheme.lower() != "bearer" or not value: + return None + return value + + +def _looks_like_jwt(token: str) -> bool: + return token.count(".") == 2 + + +def _normalize_audience(value: Any) -> List[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(item) for item in value] + return [] + + +def _split_scope(value: Any) -> List[str]: + return value.split() if isinstance(value, str) else [] + + +def _credential_from_claims( + scheme: SecuritySchemeType, + method: AuthMethod, + token: str, + claims: Dict[str, Any], +) -> Credential: + header = jwt.get_unverified_header(token) + return Credential( + scheme=scheme, + method=method, + subject=str(claims.get("sub", "")), + issuer=claims.get("iss"), + audience=_normalize_audience(claims.get("aud")), + scopes=_split_scope(claims.get("scope")), + claims=claims, + credential_ref=CredentialRef( + key_id=header.get("kid"), token_id=claims.get("jti") + ), + ) + + +class JwtVerifier: + def __init__( + self, + provider: OidcProviderConfig, + jwks_client: Optional[PyJWKClient] = None, + ) -> None: + self.provider = provider + if jwks_client is not None: + self._jwks_client = jwks_client + return + jwks_uri = ( + str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() + ) + self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True) + + def _discover_jwks(self) -> str: + url = f"{self.provider.issuer.rstrip('/')}/.well-known/openid-configuration" + response = httpx.get(url, timeout=10.0) + response.raise_for_status() + jwks_uri = response.json().get("jwks_uri") + if not jwks_uri: + raise ValueError(f"discovery document missing jwks_uri: {url}") + return str(jwks_uri) + + def verify( + self, token: str, *, require_at_jwt: Optional[bool] = None + ) -> Dict[str, Any]: + enforce = ( + self.provider.require_at_jwt if require_at_jwt is None else require_at_jwt + ) + if enforce: + header = jwt.get_unverified_header(token) + if str(header.get("typ", "")).lower() not in AT_JWT_TYPES: + raise errors.invalid_token("token typ must be at+jwt") + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + return jwt_decode( + token, + signing_key.key, + algorithms=self.provider.algorithms, + audience=self.provider.audience, + issuer=self.provider.issuer, + options={"verify_exp": True, "require": ["exp", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + raise errors.invalid_token(str(exc)) from exc + + +def _select_verifier(token: str, verifiers: List[JwtVerifier]) -> Optional[JwtVerifier]: + if not verifiers: + return None + try: + issuer = jwt.decode(token, options={"verify_signature": False}).get("iss") + except jwt.PyJWTError: + return None + for verifier in verifiers: + if verifier.provider.issuer == issuer: + return verifier + return None + + +class ApiKeyAuthenticator: + scheme = SecuritySchemeType.API_KEY + + def __init__(self, config: ApiKeySchemeConfig) -> None: + self._header_name = config.header_name + + async def authenticate(self, request: Request) -> Optional[Credential]: + raw = request.headers.get(self._header_name) + if not raw: + return None + return Credential( + scheme=self.scheme, + method=AuthMethod.API_KEY, + subject=raw, + credential_ref=CredentialRef(key_id=raw[:10]), + claims={"_raw_api_key": raw}, + ) + + def challenge(self) -> str: + return "" + + +class HttpAuthenticator: + scheme = SecuritySchemeType.HTTP + + def __init__( + self, basic: HttpBasicConfig, jwt_verifiers: List[JwtVerifier] + ) -> None: + self._basic = basic + self._verifiers = jwt_verifiers + + async def authenticate(self, request: Request) -> Optional[Credential]: + header = request.headers.get("authorization") + if not header: + return None + scheme, _, value = header.partition(" ") + scheme_lower = scheme.lower() + if scheme_lower == "bearer" and value: + return self._verify_bearer(value) + if scheme_lower == "basic" and self._basic.enabled and value: + return self._verify_basic(value) + return None + + def _verify_bearer(self, token: str) -> Credential: + verifier = _select_verifier(token, self._verifiers) + if verifier is None: + raise errors.invalid_token("no issuer match") + claims = verifier.verify(token) + return _credential_from_claims( + self.scheme, AuthMethod.BEARER_JWT, token, claims + ) + + def _verify_basic(self, value: str) -> Credential: + try: + decoded = base64.b64decode(value).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise errors.unauthenticated( + errors.basic_challenge(self._basic.realm) + ) from exc + username, _, password = decoded.partition(":") + if not username: + raise errors.unauthenticated(errors.basic_challenge(self._basic.realm)) + return Credential( + scheme=self.scheme, + method=AuthMethod.HTTP_BASIC, + subject=username, + claims={"_basic_password": password}, + ) + + def challenge(self) -> str: + bearer = errors.bearer_challenge() + if self._basic.enabled: + return f"{bearer}, {errors.basic_challenge(self._basic.realm)}" + return bearer + + +class OAuth2Authenticator: + scheme = SecuritySchemeType.OAUTH2 + + def __init__( + self, + jwt_verifiers: List[JwtVerifier], + introspection: Optional[OAuth2IntrospectionConfig], + ) -> None: + self._verifiers = jwt_verifiers + self._introspection = introspection + + async def authenticate(self, request: Request) -> Optional[Credential]: + token = _extract_bearer(request) + if token is None: + return None + if _looks_like_jwt(token): + return self._verify_at_jwt(token) + if self._introspection is not None: + return await self._introspect(token) + raise errors.invalid_token() + + def _verify_at_jwt(self, token: str) -> Credential: + verifier = _select_verifier(token, self._verifiers) + if verifier is None: + raise errors.invalid_token("no issuer match") + claims = verifier.verify(token, require_at_jwt=True) + return _credential_from_claims( + self.scheme, AuthMethod.BEARER_JWT, token, claims + ) + + async def _introspect(self, token: str) -> Credential: + config = self._introspection + assert config is not None + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + str(config.introspection_endpoint), + data={"token": token}, + auth=(config.client_id, config.client_secret.get_secret_value()), + ) + if response.status_code != 200: + raise errors.invalid_token("introspection failed") + body = response.json() + if not body.get("active"): + raise errors.invalid_token("token inactive") + return Credential( + scheme=self.scheme, + method=AuthMethod.OAUTH2_INTROSPECTION, + subject=str(body.get(config.subject_field, "")), + issuer=body.get("iss"), + audience=_normalize_audience(body.get("aud")), + scopes=_split_scope(body.get("scope")), + claims=body, + ) + + def challenge(self) -> str: + return errors.bearer_challenge() + + +class OidcAuthenticator: + scheme = SecuritySchemeType.OPENID_CONNECT + + def __init__(self, jwt_verifiers: List[JwtVerifier]) -> None: + self._verifiers = jwt_verifiers + + async def authenticate(self, request: Request) -> Optional[Credential]: + token = _extract_bearer(request) + if token is None: + return None + verifier = _select_verifier(token, self._verifiers) + if verifier is None: + raise errors.invalid_token("no issuer match") + claims = verifier.verify(token) + return _credential_from_claims(self.scheme, AuthMethod.OIDC, token, claims) + + def challenge(self) -> str: + return errors.bearer_challenge() + + +class MutualTlsAuthenticator: + scheme = SecuritySchemeType.MUTUAL_TLS + + def __init__(self, config: MutualTlsConfig) -> None: + self._config = config + + async def authenticate(self, request: Request) -> Optional[Credential]: + cert = self._read_client_cert(request) + if cert is None: + return None + return Credential( + scheme=self.scheme, + method=AuthMethod.MUTUAL_TLS, + subject=cert.subject_dn, + client_certificate=cert, + ) + + def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: + if self._config.forwarded_subject_header: + dn = request.headers.get(self._config.forwarded_subject_header) + return ClientCertificate(subject_dn=dn) if dn else None + tls = request.scope.get("extensions", {}).get("tls", {}) + dn = tls.get("client_cert_name") + return ClientCertificate(subject_dn=dn) if dn else None + + def challenge(self) -> str: + return "" + + +def build_authenticators(config: AuthConfig) -> List[Authenticator]: + verifiers = [JwtVerifier(provider) for provider in config.oidc_providers] + by_scheme: Dict[SecuritySchemeType, Authenticator] = {} + if config.api_key is not None: + by_scheme[SecuritySchemeType.API_KEY] = ApiKeyAuthenticator(config.api_key) + by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(config.http_basic, verifiers) + by_scheme[SecuritySchemeType.OPENID_CONNECT] = OidcAuthenticator(verifiers) + by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator( + verifiers, config.oauth2_introspection + ) + if config.mutual_tls.enabled: + by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTlsAuthenticator( + config.mutual_tls + ) + return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py new file mode 100644 index 000000000000..c4a51afbabb5 --- /dev/null +++ b/litellm/auth_v2/config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import List, Optional + +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr + +from .models import SecuritySchemeType + + +class ApiKeySchemeConfig(BaseModel): + header_name: str = "x-litellm-api-key" + + +class HttpBasicConfig(BaseModel): + enabled: bool = False + realm: str = "litellm" + + +class OidcProviderConfig(BaseModel): + issuer: str + audience: List[str] + jwks_uri: Optional[AnyHttpUrl] = None + algorithms: List[str] = Field(default_factory=lambda: ["RS256"]) + require_at_jwt: bool = False + client_id: Optional[str] = None + client_secret: Optional[SecretStr] = None + login_scopes: List[str] = Field( + default_factory=lambda: ["openid", "email", "profile"] + ) + + +class OAuth2IntrospectionConfig(BaseModel): + introspection_endpoint: AnyHttpUrl + client_id: str + client_secret: SecretStr + subject_field: str = "sub" + + +class MutualTlsConfig(BaseModel): + enabled: bool = False + forwarded_subject_header: Optional[str] = None + + +class TrustedProxyConfig(BaseModel): + use_forwarded_for: bool = False + trusted_proxy_cidrs: List[str] = Field(default_factory=list) + + +class AuthConfig(BaseModel): + scheme_order: List[SecuritySchemeType] = Field( + default_factory=lambda: [ + SecuritySchemeType.API_KEY, + SecuritySchemeType.HTTP, + SecuritySchemeType.OPENID_CONNECT, + SecuritySchemeType.OAUTH2, + SecuritySchemeType.MUTUAL_TLS, + ] + ) + api_key: Optional[ApiKeySchemeConfig] = Field(default_factory=ApiKeySchemeConfig) + http_basic: HttpBasicConfig = Field(default_factory=HttpBasicConfig) + oidc_providers: List[OidcProviderConfig] = Field(default_factory=list) + oauth2_introspection: Optional[OAuth2IntrospectionConfig] = None + mutual_tls: MutualTlsConfig = Field(default_factory=MutualTlsConfig) + network: TrustedProxyConfig = Field(default_factory=TrustedProxyConfig) diff --git a/litellm/auth_v2/errors.py b/litellm/auth_v2/errors.py new file mode 100644 index 000000000000..8c0dbbdd1bfc --- /dev/null +++ b/litellm/auth_v2/errors.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import HTTPException + + +class AuthError(HTTPException): + def __init__( + self, status_code: int, detail: str, challenge: Optional[str] = None + ) -> None: + headers = {"WWW-Authenticate": challenge} if challenge else None + super().__init__(status_code=status_code, detail=detail, headers=headers) + + +def bearer_challenge( + error: Optional[str] = None, description: Optional[str] = None +) -> str: + parts = ['Bearer realm="litellm"'] + if error: + parts.append(f'error="{error}"') + if description: + parts.append(f'error_description="{description}"') + return ", ".join(parts) + + +def basic_challenge(realm: str = "litellm") -> str: + return f'Basic realm="{realm}"' + + +def unauthenticated(challenge: str) -> AuthError: + return AuthError(401, "Not authenticated", challenge) + + +def invalid_token(description: Optional[str] = None) -> AuthError: + return AuthError( + 401, "Invalid token", bearer_challenge("invalid_token", description) + ) + + +def insufficient_scope() -> AuthError: + return AuthError(403, "Insufficient scope", bearer_challenge("insufficient_scope")) + + +def forbidden_role() -> AuthError: + return AuthError(403, "Insufficient role") diff --git a/litellm/auth_v2/models.py b/litellm/auth_v2/models.py new file mode 100644 index 000000000000..d23306abf833 --- /dev/null +++ b/litellm/auth_v2/models.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .rbac import Role + + +class SecuritySchemeType(str, Enum): + API_KEY = "apiKey" + HTTP = "http" + OAUTH2 = "oauth2" + OPENID_CONNECT = "openIdConnect" + MUTUAL_TLS = "mutualTLS" + + +class AuthMethod(str, Enum): + API_KEY = "api_key" + HTTP_BASIC = "http_basic" + BEARER_JWT = "bearer_jwt" + OAUTH2_INTROSPECTION = "oauth2_introspection" + OIDC = "oidc" + MUTUAL_TLS = "mutual_tls" + + +class PrincipalType(str, Enum): + HUMAN = "human" + SERVICE_ACCOUNT = "service_account" + + +class TeamRole(str, Enum): + ADMIN = "admin" + MEMBER = "member" + + +class UserIdentity(BaseModel): + id: str + external_id: Optional[str] = None + user_name: Optional[str] = None + email: Optional[str] = None + display_name: Optional[str] = None + + +class OrganizationIdentity(BaseModel): + id: str + name: Optional[str] = None + + +class TeamIdentity(BaseModel): + id: str + name: Optional[str] = None + role: TeamRole = TeamRole.MEMBER + + +class CredentialRef(BaseModel): + key_id: Optional[str] = None + token_id: Optional[str] = None + + +class NetworkContext(BaseModel): + client_ip: Optional[str] = None + host: Optional[str] = None + via_trusted_proxy: bool = False + + +class ClientCertificate(BaseModel): + subject_dn: str + issuer_dn: Optional[str] = None + serial_number: Optional[str] = None + + +class Credential(BaseModel): + """A verified credential, before identity resolution.""" + + model_config = ConfigDict(frozen=True) + + scheme: SecuritySchemeType + method: AuthMethod + subject: str + issuer: Optional[str] = None + audience: List[str] = Field(default_factory=list) + scopes: List[str] = Field(default_factory=list) + claims: Dict[str, Any] = Field(default_factory=dict) + credential_ref: CredentialRef = Field(default_factory=CredentialRef) + client_certificate: Optional[ClientCertificate] = None + + +class Principal(BaseModel): + """Normalized caller identity. Identity only, no policy/budget state.""" + + principal_type: PrincipalType + subject: str + issuer: Optional[str] = None + audience: List[str] = Field(default_factory=list) + + user: Optional[UserIdentity] = None + organization: Optional[OrganizationIdentity] = None + teams: List[TeamIdentity] = Field(default_factory=list) + + roles: List[Role] = Field(default_factory=list) + scopes: List[str] = Field(default_factory=list) + + auth_method: AuthMethod + credential_ref: CredentialRef = Field(default_factory=CredentialRef) + network: NetworkContext = Field(default_factory=NetworkContext) + claims: Dict[str, Any] = Field(default_factory=dict) diff --git a/litellm/auth_v2/network.py b/litellm/auth_v2/network.py new file mode 100644 index 000000000000..c8aee43fffb1 --- /dev/null +++ b/litellm/auth_v2/network.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import ipaddress +from typing import List, Optional, Tuple + +from fastapi import Request + +from .config import TrustedProxyConfig +from .models import NetworkContext + + +def _is_valid_ip(value: str) -> bool: + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False + + +def _ip_in_cidrs(ip: Optional[str], cidrs: List[str]) -> bool: + if not ip or not _is_valid_ip(ip): + return False + address = ipaddress.ip_address(ip) + for cidr in cidrs: + try: + if address in ipaddress.ip_network(cidr, strict=False): + return True + except ValueError: + continue + return False + + +def resolve_client_ip( + request: Request, config: TrustedProxyConfig +) -> Tuple[Optional[str], bool]: + peer = request.client.host if request.client else None + if not config.use_forwarded_for or not _ip_in_cidrs( + peer, config.trusted_proxy_cidrs + ): + return peer, False + forwarded = request.headers.get("x-forwarded-for", "") + hops = [h.strip() for h in forwarded.split(",") if h.strip()] + for hop in reversed(hops): + if not _ip_in_cidrs(hop, config.trusted_proxy_cidrs) and _is_valid_ip(hop): + return hop, True + return peer, True + + +def resolve_network_context( + request: Request, config: TrustedProxyConfig +) -> NetworkContext: + ip, via_proxy = resolve_client_ip(request, config) + return NetworkContext( + client_ip=ip, + host=request.headers.get("host"), + via_trusted_proxy=via_proxy, + ) diff --git a/litellm/auth_v2/oidc.py b/litellm/auth_v2/oidc.py new file mode 100644 index 000000000000..e463981f6e6e --- /dev/null +++ b/litellm/auth_v2/oidc.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +from typing import Any, Dict + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from scim2_models import User as ScimUser + +from .config import AuthConfig, OidcProviderConfig +from .resolver import ProvisioningStore + + +def _provider_key(provider: OidcProviderConfig) -> str: + return re.sub(r"[^a-z0-9]+", "-", provider.issuer.lower()).strip("-") + + +def _user_from_userinfo(userinfo: Dict[str, Any]) -> ScimUser: + return ScimUser( + external_id=userinfo.get("sub"), + user_name=userinfo.get("preferred_username") or userinfo.get("email"), + display_name=userinfo.get("name"), + ) + + +def build_oidc_router(config: AuthConfig) -> APIRouter: + oauth = OAuth() + for provider in config.oidc_providers: + oauth.register( + name=_provider_key(provider), + server_metadata_url=f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration", + client_id=provider.client_id, + client_secret=( + provider.client_secret.get_secret_value() + if provider.client_secret + else None + ), + client_kwargs={"scope": " ".join(provider.login_scopes)}, + ) + + router = APIRouter(prefix="/auth/oidc", tags=["oidc"]) + + @router.get("/{provider}/login") + async def login(provider: str, request: Request) -> Any: + client = oauth.create_client(provider) + if client is None: + raise HTTPException(status_code=404, detail="unknown provider") + redirect_uri = request.url_for("oidc_callback", provider=provider) + return await client.authorize_redirect(request, str(redirect_uri)) + + @router.get("/{provider}/callback", name="oidc_callback") + async def callback(provider: str, request: Request) -> JSONResponse: + client = oauth.create_client(provider) + if client is None: + raise HTTPException(status_code=404, detail="unknown provider") + token = await client.authorize_access_token(request) + userinfo = token.get("userinfo") + if userinfo is None: + userinfo = await client.userinfo(token=token) + store: ProvisioningStore = request.app.state.auth_v2.resolver + stored = await store.upsert_user(_user_from_userinfo(dict(userinfo))) + return JSONResponse(content=stored.model_dump()) + + return router diff --git a/litellm/auth_v2/rbac.py b/litellm/auth_v2/rbac.py new file mode 100644 index 000000000000..ee094cac5003 --- /dev/null +++ b/litellm/auth_v2/rbac.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Tuple + +from fastapi.security import SecurityScopes + +if TYPE_CHECKING: + from .models import Principal + + +class Role(str, Enum): + PLATFORM_ADMIN = "platform_admin" + PLATFORM_VIEWER = "platform_viewer" + ORG_ADMIN = "org_admin" + ORG_VIEWER = "org_viewer" + TEAM_ADMIN = "team_admin" + TEAM_MEMBER = "team_member" + + +def has_required_scopes( + security_scopes: SecurityScopes, principal: "Principal" +) -> bool: + return set(security_scopes.scopes).issubset(set(principal.scopes)) + + +def has_any_role(principal: "Principal", allowed: Tuple[Role, ...]) -> bool: + return any(role in allowed for role in principal.roles) diff --git a/litellm/auth_v2/resolver.py b/litellm/auth_v2/resolver.py new file mode 100644 index 000000000000..55a5883689f9 --- /dev/null +++ b/litellm/auth_v2/resolver.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import hashlib +import uuid +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + +from scim2_models import Group as ScimGroup +from scim2_models import User as ScimUser + +from . import errors +from .models import ( + AuthMethod, + Credential, + Principal, + PrincipalType, + TeamIdentity, + UserIdentity, +) +from .rbac import Role + + +@runtime_checkable +class IdentityResolver(Protocol): + async def resolve(self, credential: Credential) -> Principal: ... + + +@runtime_checkable +class ProvisioningStore(Protocol): + async def upsert_user(self, user: ScimUser) -> ScimUser: ... + async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... + async def deactivate_user(self, resource_id: str) -> None: ... + async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: ... + async def upsert_group(self, group: ScimGroup) -> ScimGroup: ... + async def get_group(self, resource_id: str) -> Optional[ScimGroup]: ... + async def delete_group(self, resource_id: str) -> None: ... + async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... + + +def _hash_api_key(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _roles_from_claims(claims: Dict[str, Any]) -> List[Role]: + raw = claims.get("roles", []) + if not isinstance(raw, list): + return [] + valid = {role.value for role in Role} + return [Role(value) for value in raw if value in valid] + + +def _teams_from_claims(claims: Dict[str, Any]) -> List[TeamIdentity]: + groups = claims.get("groups", []) + if not isinstance(groups, list): + return [] + return [TeamIdentity(id=str(group), name=str(group)) for group in groups] + + +class InMemoryIdentityStore(IdentityResolver, ProvisioningStore): + def __init__( + self, + api_keys: Optional[Dict[str, Principal]] = None, + subjects: Optional[Dict[str, Principal]] = None, + users: Optional[Dict[str, ScimUser]] = None, + groups: Optional[Dict[str, ScimGroup]] = None, + ) -> None: + self._api_keys = api_keys or {} + self._subjects = subjects or {} + self._users = users or {} + self._groups = groups or {} + + async def resolve(self, credential: Credential) -> Principal: + if credential.method == AuthMethod.API_KEY: + return self._resolve_api_key(credential) + return self._resolve_subject(credential) + + def _resolve_api_key(self, credential: Credential) -> Principal: + raw = credential.claims.get("_raw_api_key") + if not isinstance(raw, str): + raise errors.invalid_token() + principal = self._api_keys.get(_hash_api_key(raw)) + if principal is None: + raise errors.invalid_token() + return principal + + def _resolve_subject(self, credential: Credential) -> Principal: + stored = self._subjects.get(f"{credential.issuer}|{credential.subject}") + if stored is not None: + return stored + return self._principal_from_claims(credential) + + def _principal_from_claims(self, credential: Credential) -> Principal: + claims = credential.claims + if credential.method == AuthMethod.MUTUAL_TLS: + return Principal( + principal_type=PrincipalType.SERVICE_ACCOUNT, + subject=credential.subject, + issuer=credential.issuer, + audience=list(credential.audience), + scopes=list(credential.scopes), + auth_method=credential.method, + credential_ref=credential.credential_ref, + claims=dict(claims), + ) + return Principal( + principal_type=PrincipalType.HUMAN, + subject=credential.subject, + issuer=credential.issuer, + audience=list(credential.audience), + user=UserIdentity( + id=credential.subject, + external_id=credential.subject, + email=claims.get("email"), + user_name=claims.get("preferred_username"), + display_name=claims.get("name"), + ), + teams=_teams_from_claims(claims), + roles=_roles_from_claims(claims), + scopes=list(credential.scopes), + auth_method=credential.method, + credential_ref=credential.credential_ref, + claims=dict(claims), + ) + + async def upsert_user(self, user: ScimUser) -> ScimUser: + if not user.id: + user.id = str(uuid.uuid4()) + self._users[user.id] = user + return user + + async def get_user(self, resource_id: str) -> Optional[ScimUser]: + return self._users.get(resource_id) + + async def deactivate_user(self, resource_id: str) -> None: + user = self._users.get(resource_id) + if user is not None: + user.active = False + + async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: + return list(self._users.values()) + + async def upsert_group(self, group: ScimGroup) -> ScimGroup: + if not group.id: + group.id = str(uuid.uuid4()) + self._groups[group.id] = group + return group + + async def get_group(self, resource_id: str) -> Optional[ScimGroup]: + return self._groups.get(resource_id) + + async def delete_group(self, resource_id: str) -> None: + self._groups.pop(resource_id, None) + + async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: + return list(self._groups.values()) diff --git a/litellm/auth_v2/saml.py b/litellm/auth_v2/saml.py new file mode 100644 index 000000000000..706880b7d2bb --- /dev/null +++ b/litellm/auth_v2/saml.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Request + +from .models import Credential, SecuritySchemeType + + +class SamlAuthenticator: + """Thin SAML SP seam. Full pysaml2 wiring (system libxmlsec1, pinned + xmlsec/lxml, multi-IdP metadata) is deferred; the ACS maps a SAML assertion's + NameID and attribute statements into the same scim2_models.User upsert as + OIDC and SCIM.""" + + scheme = SecuritySchemeType.HTTP + + async def authenticate(self, request: Request) -> Optional[Credential]: + raise NotImplementedError("SAML SP deferred; see 03-design.md cut list") + + +def build_saml_router() -> APIRouter: + router = APIRouter(prefix="/auth/saml", tags=["saml"]) + + @router.get("/metadata") + async def metadata() -> None: + raise NotImplementedError("requires pysaml2 + system libxmlsec1") + + @router.post("/acs") + async def assertion_consumer_service(request: Request) -> None: + raise NotImplementedError( + "parse assertion -> scim2_models.User -> store.upsert_user" + ) + + return router diff --git a/litellm/auth_v2/scim.py b/litellm/auth_v2/scim.py new file mode 100644 index 000000000000..d9a2739bbfa8 --- /dev/null +++ b/litellm/auth_v2/scim.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional, Type, TypeVar + +from fastapi import APIRouter, Request, Response, status +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from scim2_models import ( + Bulk, + ChangePassword, + Context, + Error, + Filter, + Group, + ListResponse, + Patch, + PatchOp, + Resource, + ResourceType, + ServiceProviderConfig, + Sort, + User, +) + +from .resolver import ProvisioningStore + +R = TypeVar("R", bound=Resource) + + +def _store(request: Request) -> ProvisioningStore: + return request.app.state.auth_v2.resolver + + +def _error(status_code: int, detail: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content=Error(status=str(status_code), detail=detail).model_dump(), + ) + + +async def _parse(request: Request, model: Type[R]) -> R: + body = await request.json() + return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + + +def _apply_patch(resource: R, patch: PatchOp) -> R: + data: Dict[str, Any] = resource.model_dump() + for op in patch.operations: + action = op.op.value if hasattr(op.op, "value") else str(op.op) + if action == "remove": + if op.path: + data.pop(op.path, None) + continue + if op.path is None and isinstance(op.value, dict): + data.update(op.value) + elif op.path is not None: + data[op.path] = op.value + return type(resource).model_validate(data) + + +def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: + return resource.model_dump(scim_ctx=ctx) + + +def build_scim_router() -> APIRouter: + router = APIRouter(prefix="/scim/v2", tags=["scim"]) + + @router.post("/Users", status_code=status.HTTP_201_CREATED) + async def create_user(request: Request) -> Response: + try: + user = await _parse(request, User) + except ValidationError as exc: + return _error(status.HTTP_400_BAD_REQUEST, str(exc)) + stored = await _store(request).upsert_user(user) + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), + ) + + @router.get("/Users/{resource_id}") + async def get_user(resource_id: str, request: Request) -> Response: + user = await _store(request).get_user(resource_id) + if user is None: + return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + return JSONResponse(content=_dump(user, Context.RESOURCE_QUERY_RESPONSE)) + + @router.patch("/Users/{resource_id}") + async def patch_user(resource_id: str, request: Request) -> Response: + store = _store(request) + user = await store.get_user(resource_id) + if user is None: + return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + try: + patch = PatchOp[User].model_validate(await request.json()) + except ValidationError as exc: + return _error(status.HTTP_400_BAD_REQUEST, str(exc)) + updated = await store.upsert_user(_apply_patch(user, patch)) + return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) + + @router.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) + async def deactivate_user(resource_id: str, request: Request) -> Response: + await _store(request).deactivate_user(resource_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.get("/Users") + async def list_users(request: Request, filter: Optional[str] = None) -> Response: + users = await _store(request).list_users(filter) + listing: ListResponse[User] = ListResponse[User]( + total_results=len(users), + start_index=1, + items_per_page=len(users), + resources=users or None, + ) + return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) + + @router.post("/Groups", status_code=status.HTTP_201_CREATED) + async def create_group(request: Request) -> Response: + try: + group = await _parse(request, Group) + except ValidationError as exc: + return _error(status.HTTP_400_BAD_REQUEST, str(exc)) + stored = await _store(request).upsert_group(group) + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), + ) + + @router.get("/Groups/{resource_id}") + async def get_group(resource_id: str, request: Request) -> Response: + group = await _store(request).get_group(resource_id) + if group is None: + return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + return JSONResponse(content=_dump(group, Context.RESOURCE_QUERY_RESPONSE)) + + @router.patch("/Groups/{resource_id}") + async def patch_group(resource_id: str, request: Request) -> Response: + store = _store(request) + group = await store.get_group(resource_id) + if group is None: + return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + try: + patch = PatchOp[Group].model_validate(await request.json()) + except ValidationError as exc: + return _error(status.HTTP_400_BAD_REQUEST, str(exc)) + updated = await store.upsert_group(_apply_patch(group, patch)) + return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) + + @router.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) + async def delete_group(resource_id: str, request: Request) -> Response: + await _store(request).delete_group(resource_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.get("/Groups") + async def list_groups(request: Request, filter: Optional[str] = None) -> Response: + groups = await _store(request).list_groups(filter) + listing: ListResponse[Group] = ListResponse[Group]( + total_results=len(groups), + start_index=1, + items_per_page=len(groups), + resources=groups or None, + ) + return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) + + @router.get("/ServiceProviderConfig") + async def service_provider_config() -> Response: + config = ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=False, max_results=0), + change_password=ChangePassword(supported=False), + sort=Sort(supported=False), + etag=None, + authentication_schemes=[], + ) + return JSONResponse(content=config.model_dump()) + + @router.get("/ResourceTypes") + async def resource_types() -> Response: + types = [ + ResourceType( + id="User", + name="User", + endpoint="/Users", + schema="urn:ietf:params:scim:schemas:core:2.0:User", + ), + ResourceType( + id="Group", + name="Group", + endpoint="/Groups", + schema="urn:ietf:params:scim:schemas:core:2.0:Group", + ), + ] + listing: ListResponse[ResourceType] = ListResponse[ResourceType]( + total_results=len(types), + start_index=1, + items_per_page=len(types), + resources=types, + ) + return JSONResponse(content=listing.model_dump()) + + @router.get("/Schemas") + async def schemas() -> Response: + return JSONResponse( + content={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": [ + User.to_schema().model_dump(), + Group.to_schema().model_dump(), + ], + } + ) + + return router diff --git a/litellm/auth_v2/security.py b/litellm/auth_v2/security.py new file mode 100644 index 000000000000..0e016f3a5c2d --- /dev/null +++ b/litellm/auth_v2/security.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated, Callable, List + +from fastapi import FastAPI, Request, Security +from fastapi.security import SecurityScopes + +from . import errors +from .authenticators import Authenticator, build_authenticators +from .config import AuthConfig +from .models import Principal +from .network import resolve_network_context +from .rbac import Role, has_any_role, has_required_scopes +from .resolver import IdentityResolver + + +@dataclass +class AuthContext: + config: AuthConfig + authenticators: List[Authenticator] + resolver: IdentityResolver + + +def install_auth( + app: FastAPI, + config: AuthConfig, + resolver: IdentityResolver, + *, + mount_scim: bool = True, + mount_oidc: bool = True, +) -> AuthContext: + ctx = AuthContext(config, build_authenticators(config), resolver) + app.state.auth_v2 = ctx + if mount_scim: + from .scim import build_scim_router + + app.include_router(build_scim_router()) + if mount_oidc and config.oidc_providers: + from .oidc import build_oidc_router + + app.include_router(build_oidc_router(config)) + return ctx + + +def _ctx(request: Request) -> AuthContext: + return request.app.state.auth_v2 + + +def _combined_challenge(authenticators: List[Authenticator]) -> str: + seen: List[str] = [] + for authenticator in authenticators: + challenge = authenticator.challenge() + if challenge and challenge not in seen: + seen.append(challenge) + return ", ".join(seen) + + +async def get_current_principal( + security_scopes: SecurityScopes, request: Request +) -> Principal: + ctx = _ctx(request) + credential = None + for authenticator in ctx.authenticators: + credential = await authenticator.authenticate(request) + if credential is not None: + break + if credential is None: + raise errors.unauthenticated(_combined_challenge(ctx.authenticators)) + + resolved = await ctx.resolver.resolve(credential) + principal = resolved.model_copy( + update={"network": resolve_network_context(request, ctx.config.network)} + ) + + if not has_required_scopes(security_scopes, principal): + raise errors.insufficient_scope() + return principal + + +def require_roles(*allowed: Role) -> Callable[..., object]: + async def dependency( + principal: Annotated[Principal, Security(get_current_principal)], + ) -> Principal: + if not has_any_role(principal, allowed): + raise errors.forbidden_role() + return principal + + return dependency From 0b74ffa9c6766e74333868d5e57a2e5b546d9e5f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:33:02 -0700 Subject: [PATCH 03/51] feat(auth_v2): implement full SAML 2.0 SP via pysaml2 Replace the deferred SAML thin-adapter stub with a working Service Provider built on pysaml2: an SP metadata endpoint, an SP-initiated /login that redirects to the IdP, and an ACS handling the HTTP-POST binding that verifies the signed assertion, maps NameID and attribute statements into a scim2_models.User, and upserts it through the same ProvisioningStore seam SCIM and OIDC use. A SamlAuthenticator reads the post-ACS session cookie and resolves to the one normalized Principal like every other scheme; AuthMethod gains a SAML member. IdP metadata loads from a file path or inline XML via SamlConfig, and install_auth mounts the router and authenticator when SAML is enabled. pysaml2 pulls pyOpenSSL transitively without pinning it, and older pyOpenSSL caps cryptography below 46 and breaks at import against the version this proxy already requires; pin pyOpenSSL>=26 so the resolver stays on a cryptography-46-compatible release. pysaml2 also needs the system xmlsec1 binary at runtime (brew install libxmlsec1 on macOS, apt-get install xmlsec1 libxmlsec1-dev on Debian); SamlConfig.xmlsec_binary can point at it when it is not on PATH. --- litellm/auth_v2/config.py | 41 ++++++++- litellm/auth_v2/models.py | 1 + litellm/auth_v2/saml.py | 179 +++++++++++++++++++++++++++++++++--- litellm/auth_v2/security.py | 7 ++ pyproject.toml | 5 + uv.lock | 65 +++++++++++++ 6 files changed, 281 insertions(+), 17 deletions(-) diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index c4a51afbabb5..8b2fdc98ec5a 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -1,11 +1,22 @@ from __future__ import annotations -from typing import List, Optional +from typing import Dict, List, Optional -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, model_validator from .models import SecuritySchemeType +DEFAULT_SAML_ATTRIBUTE_MAP = { + "email": "email", + "mail": "email", + "emailAddress": "email", + "displayName": "display_name", + "cn": "display_name", + "userName": "user_name", + "uid": "user_name", + "sAMAccountName": "user_name", +} + class ApiKeySchemeConfig(BaseModel): header_name: str = "x-litellm-api-key" @@ -46,6 +57,31 @@ class TrustedProxyConfig(BaseModel): trusted_proxy_cidrs: List[str] = Field(default_factory=list) +class SamlConfig(BaseModel): + enabled: bool = False + sp_entity_id: str + acs_url: str + idp_metadata_path: Optional[str] = None + idp_metadata_inline: Optional[str] = None + sp_key_file: Optional[str] = None + sp_cert_file: Optional[str] = None + want_assertions_signed: bool = True + allow_unsolicited: bool = True + session_cookie: str = "saml_session" + xmlsec_binary: Optional[str] = None + attribute_map: Dict[str, str] = Field( + default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) + ) + + @model_validator(mode="after") + def _require_idp_metadata(self) -> "SamlConfig": + if self.enabled and not (self.idp_metadata_path or self.idp_metadata_inline): + raise ValueError( + "SAML enabled but no IdP metadata: set idp_metadata_path or idp_metadata_inline" + ) + return self + + class AuthConfig(BaseModel): scheme_order: List[SecuritySchemeType] = Field( default_factory=lambda: [ @@ -62,3 +98,4 @@ class AuthConfig(BaseModel): oauth2_introspection: Optional[OAuth2IntrospectionConfig] = None mutual_tls: MutualTlsConfig = Field(default_factory=MutualTlsConfig) network: TrustedProxyConfig = Field(default_factory=TrustedProxyConfig) + saml: Optional[SamlConfig] = None diff --git a/litellm/auth_v2/models.py b/litellm/auth_v2/models.py index d23306abf833..3c6ba79de130 100644 --- a/litellm/auth_v2/models.py +++ b/litellm/auth_v2/models.py @@ -22,6 +22,7 @@ class AuthMethod(str, Enum): BEARER_JWT = "bearer_jwt" OAUTH2_INTROSPECTION = "oauth2_introspection" OIDC = "oidc" + SAML = "saml" MUTUAL_TLS = "mutual_tls" diff --git a/litellm/auth_v2/saml.py b/litellm/auth_v2/saml.py index 706880b7d2bb..a36d6cc4103e 100644 --- a/litellm/auth_v2/saml.py +++ b/litellm/auth_v2/saml.py @@ -1,35 +1,184 @@ from __future__ import annotations -from typing import Optional +import secrets +from typing import Any, Dict, Optional -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse, Response +from saml2 import BINDING_HTTP_POST +from saml2.client import Saml2Client +from saml2.config import SPConfig +from saml2.metadata import entity_descriptor +from scim2_models import User as ScimUser -from .models import Credential, SecuritySchemeType +from .config import SamlConfig +from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +from .resolver import ProvisioningStore +_SINGLE_VALUE_CLAIM = { + "email": "email", + "user_name": "preferred_username", + "display_name": "name", +} +_MULTI_VALUE_ATTRS = ("groups", "roles") -class SamlAuthenticator: - """Thin SAML SP seam. Full pysaml2 wiring (system libxmlsec1, pinned - xmlsec/lxml, multi-IdP metadata) is deferred; the ACS maps a SAML assertion's - NameID and attribute statements into the same scim2_models.User upsert as - OIDC and SCIM.""" +def _normalize_attributes( + ava: Dict[str, Any], attribute_map: Dict[str, str] +) -> Dict[str, Any]: + claims: Dict[str, Any] = {} + for saml_attr, target in attribute_map.items(): + if saml_attr not in ava or target not in _SINGLE_VALUE_CLAIM: + continue + value = ava[saml_attr] + scalar = value[0] if isinstance(value, list) and value else value + claims.setdefault(_SINGLE_VALUE_CLAIM[target], scalar) + for attr in _MULTI_VALUE_ATTRS: + value = ava.get(attr) + if isinstance(value, list): + claims[attr] = value + return claims + + +def _user_from_claims(name_id: str, claims: Dict[str, Any]) -> ScimUser: + return ScimUser( + external_id=name_id, + user_name=claims.get("preferred_username") or claims.get("email") or name_id, + display_name=claims.get("name"), + ) + + +def _sp_config_dict(config: SamlConfig) -> Dict[str, Any]: + cfg: Dict[str, Any] = { + "entityid": config.sp_entity_id, + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [(config.acs_url, BINDING_HTTP_POST)] + }, + "allow_unsolicited": config.allow_unsolicited, + "authn_requests_signed": False, + "want_assertions_signed": config.want_assertions_signed, + "want_response_signed": False, + } + }, + "allow_unknown_attributes": True, + } + if config.idp_metadata_path: + cfg["metadata"] = {"local": [config.idp_metadata_path]} + elif config.idp_metadata_inline: + cfg["metadata"] = {"inline": [config.idp_metadata_inline]} + if config.sp_key_file: + cfg["key_file"] = config.sp_key_file + if config.sp_cert_file: + cfg["cert_file"] = config.sp_cert_file + if config.xmlsec_binary: + cfg["xmlsec_binary"] = config.xmlsec_binary + return cfg + + +def build_sp_client(config: SamlConfig) -> Saml2Client: + conf = SPConfig() + conf.load(_sp_config_dict(config)) + return Saml2Client(config=conf) + + +class SamlSessionStore: + def __init__(self) -> None: + self._sessions: Dict[str, Dict[str, Any]] = {} + self.outstanding: Dict[str, str] = {} + + def remember_request(self, request_id: str, relay_state: str = "/") -> None: + self.outstanding[request_id] = relay_state + + def create_session(self, identity: Dict[str, Any]) -> str: + session_id = secrets.token_urlsafe(32) + self._sessions[session_id] = identity + return session_id + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + return self._sessions.get(session_id) + + +class SamlAuthenticator: scheme = SecuritySchemeType.HTTP + def __init__(self, config: SamlConfig, session_store: SamlSessionStore) -> None: + self._config = config + self._store = session_store + async def authenticate(self, request: Request) -> Optional[Credential]: - raise NotImplementedError("SAML SP deferred; see 03-design.md cut list") + session_id = request.cookies.get(self._config.session_cookie) + if not session_id: + return None + identity = self._store.get(session_id) + if identity is None: + return None + return Credential( + scheme=self.scheme, + method=AuthMethod.SAML, + subject=identity["name_id"], + issuer=identity.get("issuer"), + claims=identity.get("claims", {}), + credential_ref=CredentialRef(token_id=session_id), + ) + + def challenge(self) -> str: + return "" -def build_saml_router() -> APIRouter: +def build_saml_router(config: SamlConfig, session_store: SamlSessionStore) -> APIRouter: + client = build_sp_client(config) router = APIRouter(prefix="/auth/saml", tags=["saml"]) @router.get("/metadata") - async def metadata() -> None: - raise NotImplementedError("requires pysaml2 + system libxmlsec1") + async def metadata() -> Response: + return Response( + content=str(entity_descriptor(client.config)), + media_type="application/samlmetadata+xml", + ) + + @router.get("/login") + async def login() -> RedirectResponse: + request_id, info = client.prepare_for_authenticate() + session_store.remember_request(request_id) + location = dict(info["headers"]).get("Location") + if not location: + raise HTTPException(status_code=500, detail="no SAML redirect produced") + return RedirectResponse(location, status_code=303) @router.post("/acs") - async def assertion_consumer_service(request: Request) -> None: - raise NotImplementedError( - "parse assertion -> scim2_models.User -> store.upsert_user" + async def assertion_consumer_service(request: Request) -> Response: + form = await request.form() + saml_response = form.get("SAMLResponse") + if not isinstance(saml_response, str): + raise HTTPException(status_code=400, detail="missing SAMLResponse") + authn_response = client.parse_authn_request_response( + saml_response, + BINDING_HTTP_POST, + outstanding=session_store.outstanding or None, + ) + if authn_response is None: + raise HTTPException(status_code=401, detail="invalid SAML response") + + name_id = authn_response.get_subject().text + ava = authn_response.get_identity() or {} + claims = _normalize_attributes(ava, config.attribute_map) + user = _user_from_claims(name_id, claims) + + store: ProvisioningStore = request.app.state.auth_v2.resolver + await store.upsert_user(user) + + in_response_to = getattr(authn_response, "in_response_to", None) + if in_response_to: + session_store.outstanding.pop(in_response_to, None) + session_id = session_store.create_session( + {"name_id": name_id, "issuer": authn_response.issuer(), "claims": claims} + ) + response = JSONResponse(content=user.model_dump()) + response.set_cookie( + config.session_cookie, session_id, httponly=True, samesite="lax" ) + return response return router diff --git a/litellm/auth_v2/security.py b/litellm/auth_v2/security.py index 0e016f3a5c2d..5330509f617a 100644 --- a/litellm/auth_v2/security.py +++ b/litellm/auth_v2/security.py @@ -29,6 +29,7 @@ def install_auth( *, mount_scim: bool = True, mount_oidc: bool = True, + mount_saml: bool = True, ) -> AuthContext: ctx = AuthContext(config, build_authenticators(config), resolver) app.state.auth_v2 = ctx @@ -40,6 +41,12 @@ def install_auth( from .oidc import build_oidc_router app.include_router(build_oidc_router(config)) + if mount_saml and config.saml is not None and config.saml.enabled: + from .saml import SamlAuthenticator, SamlSessionStore, build_saml_router + + session_store = SamlSessionStore() + ctx.authenticators.append(SamlAuthenticator(config.saml, session_store)) + app.include_router(build_saml_router(config.saml, session_store)) return ctx diff --git a/pyproject.toml b/pyproject.toml index 610305e1905a..d388c29ab335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,11 @@ proxy = [ "PyJWT[crypto]>=2.13.0,<3.0", "Authlib>=1.6.0,<2.0", "scim2-models>=0.6.0,<1.0", + "pysaml2>=7.5.0,<8.0", + # pysaml2 pulls pyOpenSSL transitively without pinning it; force a floor that + # supports cryptography 46 (older pyOpenSSL caps cryptography below 46 and + # breaks at import against the version this proxy already requires). + "pyOpenSSL>=26.0.0,<27.0", "python-multipart>=0.0.27,<1.0", "cryptography>=46.0.7,<47.0", "pynacl>=1.6.2,<2.0", diff --git a/uv.lock b/uv.lock index e1b84f4e6297..4f3e18d573b3 100644 --- a/uv.lock +++ b/uv.lock @@ -1297,6 +1297,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/8c/e1a7043e562b5b29fb5d0930630a18078fecb1c30ca6776221ce0dab6f95/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d36a16e8746cb38a143faa6e1cd10927bf4a482c29f4010afde4bd0f4bb89db4", size = 7390107, upload-time = "2025-01-16T17:17:54.826Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1422,6 +1431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "elementpath" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/41/afdd82534c80e9675d1c51dc21d0889b72d023bfe395a2f5a44d751d3a73/elementpath-4.8.0.tar.gz", hash = "sha256:5822a2560d99e2633d95f78694c7ff9646adaa187db520da200a8e9479dc46ae", size = 358528, upload-time = "2025-03-03T20:51:08.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/95/615af832e7f507fe5ce4562b4be1bd2fec080c4ff6da88dcd0c2dbfca582/elementpath-4.8.0-py3-none-any.whl", hash = "sha256:5393191f84969bcf8033b05ec4593ef940e58622ea13cefe60ecefbbf09d58d9", size = 243271, upload-time = "2025-03-03T20:51:03.027Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -3366,7 +3384,9 @@ proxy = [ { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pynacl" }, + { name = "pyopenssl" }, { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, + { name = "pysaml2" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "restrictedpython" }, @@ -3557,8 +3577,10 @@ requires-dist = [ { name = "pydantic-settings", marker = "extra == 'proxy'", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, + { name = "pyopenssl", marker = "extra == 'proxy'", specifier = ">=26.0.0,<27.0" }, { name = "pypdf", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=6.10.2,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, + { name = "pysaml2", marker = "extra == 'proxy'", specifier = ">=7.5.0,<8.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2.0" }, { name = "python-multipart", marker = "extra == 'proxy'", specifier = ">=0.0.27,<1.0" }, { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0.3,<7.0" }, @@ -6077,6 +6099,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -6134,6 +6169,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/8f/88d792e9cacd6ff3bd9a50100586ddc665e02a917662c17d30931f778542/pyroscope_io-0.8.16-py2.py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b91ce5b240f8de756c16a17022ca8e25ef8a4eed461c7d074b8a0841cf7b445", size = 3485288, upload-time = "2026-01-22T06:23:32Z" }, ] +[[package]] +name = "pysaml2" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "defusedxml" }, + { name = "pyopenssl" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "xmlschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/02/e8ecb5d1574a2add1431c8ec16dff137610f30580a7c1d6205929b3db3ee/pysaml2-7.5.0.tar.gz", hash = "sha256:f36871d4e5ee857c6b85532e942550d2cf90ea4ee943d75eb681044bbc4f54f7", size = 340338, upload-time = "2024-01-30T11:49:08.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/92d84ae0e80e829e84785c6e4e425ff6d447116289f0ecf2af068f771a73/pysaml2-7.5.0-py3-none-any.whl", hash = "sha256:bc6627cc344476a83c757f440a73fda1369f13b6fda1b4e16bca63ffbabb5318", size = 419304, upload-time = "2024-01-30T11:49:04.5Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -8153,6 +8206,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] +[[package]] +name = "xmlschema" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elementpath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/af/42e9e773eaa6bc8e8c322f93c75454b0d370979048b250bfef7786ff26ec/xmlschema-2.5.1.tar.gz", hash = "sha256:4f7497de6c8b6dc2c28ad7b9ed6e21d186f4afe248a5bea4f54eedab4da44083", size = 539267, upload-time = "2023-12-19T15:51:57.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2a/c2bc97fd20efe65cfcfc21666d1b0213969133d37ea093761d264d9ed9f8/xmlschema-2.5.1-py3-none-any.whl", hash = "sha256:ec2b2a15c8896c1fcd14dcee34ca30032b99456c3c43ce793fdb9dca2fb4b869", size = 395065, upload-time = "2023-12-19T15:51:53.136Z" }, +] + [[package]] name = "xmltodict" version = "1.0.4" From 677762bf6071c24e1b5a98f5d8d37ce72fa12c32 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:37:50 -0700 Subject: [PATCH 04/51] refactor(auth_v2): align SamlConfig with the revised design doc Match the updated 03-design.md SAML spec: rename sp_entity_id to entity_id, collapse the split idp_metadata_path/idp_metadata_inline into one idp_metadata field accepting inline XML, a local path, or a remote URL, and default the attribute_map to the common Okta/Entra claims (email, givenName, surname, groups). Make assertion signing mandatory by hardcoding want_assertions_signed rather than exposing it as a togglable field. Map givenName/surname into the SCIM User's Name (given/family/formatted) and email into emails, and fail the ACS closed with 401 on any parse or signature-verification error. --- litellm/auth_v2/config.py | 18 +++--- litellm/auth_v2/saml.py | 121 +++++++++++++++++++++++++++----------- 2 files changed, 95 insertions(+), 44 deletions(-) diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index 8b2fdc98ec5a..33ea5dacafed 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -9,12 +9,14 @@ DEFAULT_SAML_ATTRIBUTE_MAP = { "email": "email", "mail": "email", - "emailAddress": "email", + "givenName": "given_name", + "surname": "family_name", + "sn": "family_name", "displayName": "display_name", - "cn": "display_name", "userName": "user_name", "uid": "user_name", - "sAMAccountName": "user_name", + "groups": "groups", + "roles": "roles", } @@ -59,13 +61,11 @@ class TrustedProxyConfig(BaseModel): class SamlConfig(BaseModel): enabled: bool = False - sp_entity_id: str + entity_id: str acs_url: str - idp_metadata_path: Optional[str] = None - idp_metadata_inline: Optional[str] = None + idp_metadata: str = "" sp_key_file: Optional[str] = None sp_cert_file: Optional[str] = None - want_assertions_signed: bool = True allow_unsolicited: bool = True session_cookie: str = "saml_session" xmlsec_binary: Optional[str] = None @@ -75,9 +75,9 @@ class SamlConfig(BaseModel): @model_validator(mode="after") def _require_idp_metadata(self) -> "SamlConfig": - if self.enabled and not (self.idp_metadata_path or self.idp_metadata_inline): + if self.enabled and not self.idp_metadata.strip(): raise ValueError( - "SAML enabled but no IdP metadata: set idp_metadata_path or idp_metadata_inline" + "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" ) return self diff --git a/litellm/auth_v2/saml.py b/litellm/auth_v2/saml.py index a36d6cc4103e..d41840c9924e 100644 --- a/litellm/auth_v2/saml.py +++ b/litellm/auth_v2/saml.py @@ -1,7 +1,7 @@ from __future__ import annotations import secrets -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse, Response @@ -9,48 +9,93 @@ from saml2.client import Saml2Client from saml2.config import SPConfig from saml2.metadata import entity_descriptor +from scim2_models import Email, Name from scim2_models import User as ScimUser from .config import SamlConfig from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType from .resolver import ProvisioningStore -_SINGLE_VALUE_CLAIM = { - "email": "email", - "user_name": "preferred_username", - "display_name": "name", +_SINGLE_VALUE_TARGETS = { + "email", + "given_name", + "family_name", + "user_name", + "display_name", } -_MULTI_VALUE_ATTRS = ("groups", "roles") +_MULTI_VALUE_TARGETS = ("groups", "roles") -def _normalize_attributes( +def _map_attributes( ava: Dict[str, Any], attribute_map: Dict[str, str] ) -> Dict[str, Any]: - claims: Dict[str, Any] = {} + mapped: Dict[str, Any] = {} for saml_attr, target in attribute_map.items(): - if saml_attr not in ava or target not in _SINGLE_VALUE_CLAIM: + if saml_attr not in ava: continue value = ava[saml_attr] - scalar = value[0] if isinstance(value, list) and value else value - claims.setdefault(_SINGLE_VALUE_CLAIM[target], scalar) - for attr in _MULTI_VALUE_ATTRS: - value = ava.get(attr) - if isinstance(value, list): - claims[attr] = value + if target in _SINGLE_VALUE_TARGETS: + scalar = value[0] if isinstance(value, list) and value else value + mapped.setdefault(target, scalar) + elif target in _MULTI_VALUE_TARGETS and isinstance(value, list): + mapped[target] = value + return mapped + + +def _formatted_name(mapped: Dict[str, Any]) -> Optional[str]: + if mapped.get("display_name"): + return mapped["display_name"] + parts: List[str] = [ + part for part in (mapped.get("given_name"), mapped.get("family_name")) if part + ] + return " ".join(parts) if parts else None + + +def _user_from_mapped(name_id: str, mapped: Dict[str, Any]) -> ScimUser: + display = _formatted_name(mapped) + user = ScimUser( + external_id=name_id, + user_name=mapped.get("user_name") or mapped.get("email") or name_id, + display_name=display, + ) + if mapped.get("given_name") or mapped.get("family_name"): + user.name = Name( + given_name=mapped.get("given_name"), + family_name=mapped.get("family_name"), + formatted=display, + ) + if mapped.get("email"): + user.emails = [Email(value=mapped["email"], primary=True)] + return user + + +def _claims_from_mapped(mapped: Dict[str, Any]) -> Dict[str, Any]: + claims: Dict[str, Any] = {} + if mapped.get("email"): + claims["email"] = mapped["email"] + if mapped.get("user_name"): + claims["preferred_username"] = mapped["user_name"] + display = _formatted_name(mapped) + if display: + claims["name"] = display + for target in _MULTI_VALUE_TARGETS: + if mapped.get(target): + claims[target] = mapped[target] return claims -def _user_from_claims(name_id: str, claims: Dict[str, Any]) -> ScimUser: - return ScimUser( - external_id=name_id, - user_name=claims.get("preferred_username") or claims.get("email") or name_id, - display_name=claims.get("name"), - ) +def _metadata_source(idp_metadata: str) -> Dict[str, Any]: + stripped = idp_metadata.strip() + if stripped.startswith("<"): + return {"inline": [idp_metadata]} + if stripped.startswith("http://") or stripped.startswith("https://"): + return {"remote": [{"url": idp_metadata}]} + return {"local": [idp_metadata]} def _sp_config_dict(config: SamlConfig) -> Dict[str, Any]: cfg: Dict[str, Any] = { - "entityid": config.sp_entity_id, + "entityid": config.entity_id, "service": { "sp": { "endpoints": { @@ -58,16 +103,13 @@ def _sp_config_dict(config: SamlConfig) -> Dict[str, Any]: }, "allow_unsolicited": config.allow_unsolicited, "authn_requests_signed": False, - "want_assertions_signed": config.want_assertions_signed, + "want_assertions_signed": True, "want_response_signed": False, } }, + "metadata": _metadata_source(config.idp_metadata), "allow_unknown_attributes": True, } - if config.idp_metadata_path: - cfg["metadata"] = {"local": [config.idp_metadata_path]} - elif config.idp_metadata_inline: - cfg["metadata"] = {"inline": [config.idp_metadata_inline]} if config.sp_key_file: cfg["key_file"] = config.sp_key_file if config.sp_cert_file: @@ -153,18 +195,23 @@ async def assertion_consumer_service(request: Request) -> Response: saml_response = form.get("SAMLResponse") if not isinstance(saml_response, str): raise HTTPException(status_code=400, detail="missing SAMLResponse") - authn_response = client.parse_authn_request_response( - saml_response, - BINDING_HTTP_POST, - outstanding=session_store.outstanding or None, - ) + try: + authn_response = client.parse_authn_request_response( + saml_response, + BINDING_HTTP_POST, + outstanding=session_store.outstanding or None, + ) + except Exception as exc: + raise HTTPException( + status_code=401, detail="invalid SAML response" + ) from exc if authn_response is None: raise HTTPException(status_code=401, detail="invalid SAML response") name_id = authn_response.get_subject().text ava = authn_response.get_identity() or {} - claims = _normalize_attributes(ava, config.attribute_map) - user = _user_from_claims(name_id, claims) + mapped = _map_attributes(ava, config.attribute_map) + user = _user_from_mapped(name_id, mapped) store: ProvisioningStore = request.app.state.auth_v2.resolver await store.upsert_user(user) @@ -173,7 +220,11 @@ async def assertion_consumer_service(request: Request) -> Response: if in_response_to: session_store.outstanding.pop(in_response_to, None) session_id = session_store.create_session( - {"name_id": name_id, "issuer": authn_response.issuer(), "claims": claims} + { + "name_id": name_id, + "issuer": authn_response.issuer(), + "claims": _claims_from_mapped(mapped), + } ) response = JSONResponse(content=user.model_dump()) response.set_cookie( From da1a088a4f430cfc0c323a609ed8c6e648cc2b54 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:40:05 -0700 Subject: [PATCH 05/51] feat(auth_v2): redirect after SAML ACS with validated RelayState Replace the test-convenience JSON body from /acs with the standard SP flow: set the session cookie, then 303 redirect to the RelayState the IdP echoes back, or to SamlConfig.default_redirect_path (default "/") when it is absent. RelayState is validated to block open redirects - only relative paths are honored (must start with a single "/", reject "//", any scheme, and backslashes), and anything else falls back to the default. GET /login threads a ?next= query param through as RelayState with the same validation so the post-login landing page survives the round trip. --- litellm/auth_v2/config.py | 1 + litellm/auth_v2/saml.py | 30 +++++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index 33ea5dacafed..16169e758ad4 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -68,6 +68,7 @@ class SamlConfig(BaseModel): sp_cert_file: Optional[str] = None allow_unsolicited: bool = True session_cookie: str = "saml_session" + default_redirect_path: str = "/" xmlsec_binary: Optional[str] = None attribute_map: Dict[str, str] = Field( default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) diff --git a/litellm/auth_v2/saml.py b/litellm/auth_v2/saml.py index d41840c9924e..9f3d1773fed4 100644 --- a/litellm/auth_v2/saml.py +++ b/litellm/auth_v2/saml.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi.responses import RedirectResponse, Response from saml2 import BINDING_HTTP_POST from saml2.client import Saml2Client from saml2.config import SPConfig @@ -84,6 +84,18 @@ def _claims_from_mapped(mapped: Dict[str, Any]) -> Dict[str, Any]: return claims +def _safe_relay_state(target: Optional[str], default: str) -> str: + if ( + target + and target.startswith("/") + and not target.startswith("//") + and "://" not in target + and "\\" not in target + ): + return target + return default + + def _metadata_source(idp_metadata: str) -> Dict[str, Any]: stripped = idp_metadata.strip() if stripped.startswith("<"): @@ -181,9 +193,12 @@ async def metadata() -> Response: ) @router.get("/login") - async def login() -> RedirectResponse: - request_id, info = client.prepare_for_authenticate() - session_store.remember_request(request_id) + async def login(request: Request) -> RedirectResponse: + relay_state = _safe_relay_state( + request.query_params.get("next"), config.default_redirect_path + ) + request_id, info = client.prepare_for_authenticate(relay_state=relay_state) + session_store.remember_request(request_id, relay_state) location = dict(info["headers"]).get("Location") if not location: raise HTTPException(status_code=500, detail="no SAML redirect produced") @@ -226,7 +241,12 @@ async def assertion_consumer_service(request: Request) -> Response: "claims": _claims_from_mapped(mapped), } ) - response = JSONResponse(content=user.model_dump()) + relay_state = form.get("RelayState") + target = _safe_relay_state( + relay_state if isinstance(relay_state, str) else None, + config.default_redirect_path, + ) + response = RedirectResponse(target, status_code=303) response.set_cookie( config.session_cookie, session_id, httponly=True, samesite="lax" ) From 03faccea172a3c0e5b17b76e5e18c6ad68de83db Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 17:59:29 -0700 Subject: [PATCH 06/51] test(auth_v2): add test suite for the standards-based auth module Cover every layer of litellm/auth_v2 with tests that fail when the behavior regresses, not just for coverage. Highlights: - authenticators: JwtVerifier enforces signature, aud, iss, exp, required claims, and at+jwt typ via an injected jwks_client (real RS256 against an in-test RSA keypair, no monkeypatching); per-scheme apiKey/http-bearer/ http-basic/oauth2/oidc/mTLS extraction and fail-fast on present-but-invalid - security: OR precedence first-match-wins, a present-but-invalid api key does not fall through to a valid bearer, scope -> 403 insufficient_scope, role -> 403, missing credential -> 401 with WWW-Authenticate, network wired onto the principal - resolver: sha256 api-key lookup (wrong key never resolves), claims-driven principal build (groups -> teams, roles filtered to the Role enum), mTLS -> service account - network: trusted-proxy XFF honored only from a trusted peer, right-to-left parse skips chained proxies, spoofed XFF from an untrusted peer ignored - scim: Users/Groups create/get/patch/list/delete round-trip plus malformed body -> SCIM 400 Error and discovery endpoints - oidc: userinfo -> scim2_models.User mapping and the upsert seam - saml: a real pysaml2 IdP mints a signed assertion; ACS provisions the user, sets a session cookie, and authenticates with method=saml, while tampered and unsigned assertions are rejected (skipped when xmlsec1 is absent) - models/rbac/config: frozen Credential, Role validation, scope/role helpers, SamlConfig metadata validation A mutation spot-check confirmed the suite fails when JWT verification or the api-key hash lookup is broken. --- tests/test_litellm/auth_v2/auth_v2_helpers.py | 90 +++++ tests/test_litellm/auth_v2/conftest.py | 51 +++ .../auth_v2/test_authenticators.py | 338 +++++++++++++++++ tests/test_litellm/auth_v2/test_config.py | 52 +++ tests/test_litellm/auth_v2/test_models.py | 93 +++++ tests/test_litellm/auth_v2/test_network.py | 76 ++++ tests/test_litellm/auth_v2/test_oidc.py | 50 +++ tests/test_litellm/auth_v2/test_rbac.py | 45 +++ tests/test_litellm/auth_v2/test_resolver.py | 119 ++++++ tests/test_litellm/auth_v2/test_saml.py | 356 ++++++++++++++++++ tests/test_litellm/auth_v2/test_scim.py | 146 +++++++ tests/test_litellm/auth_v2/test_security.py | 203 ++++++++++ 12 files changed, 1619 insertions(+) create mode 100644 tests/test_litellm/auth_v2/auth_v2_helpers.py create mode 100644 tests/test_litellm/auth_v2/conftest.py create mode 100644 tests/test_litellm/auth_v2/test_authenticators.py create mode 100644 tests/test_litellm/auth_v2/test_config.py create mode 100644 tests/test_litellm/auth_v2/test_models.py create mode 100644 tests/test_litellm/auth_v2/test_network.py create mode 100644 tests/test_litellm/auth_v2/test_oidc.py create mode 100644 tests/test_litellm/auth_v2/test_rbac.py create mode 100644 tests/test_litellm/auth_v2/test_resolver.py create mode 100644 tests/test_litellm/auth_v2/test_saml.py create mode 100644 tests/test_litellm/auth_v2/test_scim.py create mode 100644 tests/test_litellm/auth_v2/test_security.py diff --git a/tests/test_litellm/auth_v2/auth_v2_helpers.py b/tests/test_litellm/auth_v2/auth_v2_helpers.py new file mode 100644 index 000000000000..d2e503ea93c3 --- /dev/null +++ b/tests/test_litellm/auth_v2/auth_v2_helpers.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import time +from typing import Any, Dict, List, Optional, Tuple + +import jwt +from fastapi import Request + +TEST_ISSUER = "https://idp.test.litellm.ai" +TEST_AUDIENCE = "litellm-proxy" + + +class _StaticSigningKey: + def __init__(self, key: Any) -> None: + self.key = key + + +class FakeJwksClient: + """Stands in for PyJWKClient. Returns one fixed key for every token so + JwtVerifier performs a real RS256 signature check against it via PyJWT.""" + + def __init__(self, public_key: Any) -> None: + self._public_key = public_key + self.calls = 0 + + def get_signing_key_from_jwt(self, token: str) -> _StaticSigningKey: + self.calls += 1 + return _StaticSigningKey(self._public_key) + + +class TokenFactory: + def __init__(self, private_pem: bytes) -> None: + self._private_pem = private_pem + + def mint( + self, + *, + issuer: str = TEST_ISSUER, + audience: Any = TEST_AUDIENCE, + subject: str = "user-1", + expires_in: int = 3600, + headers: Optional[Dict[str, Any]] = None, + private_pem: Optional[bytes] = None, + **extra_claims: Any, + ) -> str: + now = int(time.time()) + claims: Dict[str, Any] = { + "iss": issuer, + "aud": audience, + "sub": subject, + "iat": now, + "exp": now + expires_in, + } + claims.update(extra_claims) + return jwt.encode( + claims, + private_pem or self._private_pem, + algorithm="RS256", + headers=headers or {}, + ) + + +def make_request( + *, + headers: Optional[Dict[str, str]] = None, + cookies: Optional[Dict[str, str]] = None, + client: Optional[Tuple[str, int]] = ("203.0.113.7", 5555), + scope_extra: Optional[Dict[str, Any]] = None, +) -> Request: + raw_headers: List[Tuple[bytes, bytes]] = [] + for key, value in (headers or {}).items(): + raw_headers.append((key.lower().encode(), value.encode())) + if cookies: + cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items()) + raw_headers.append((b"cookie", cookie_header.encode())) + scope: Dict[str, Any] = { + "type": "http", + "http_version": "1.1", + "method": "GET", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "headers": raw_headers, + "client": client, + "server": ("testserver", 80), + "scheme": "http", + } + if scope_extra: + scope.update(scope_extra) + return Request(scope) diff --git a/tests/test_litellm/auth_v2/conftest.py b/tests/test_litellm/auth_v2/conftest.py new file mode 100644 index 000000000000..3a1b8af831ce --- /dev/null +++ b/tests/test_litellm/auth_v2/conftest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, Tuple + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from litellm.auth_v2.authenticators import JwtVerifier +from litellm.auth_v2.config import OidcProviderConfig + +from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient, TokenFactory + + +def _generate_keypair() -> Tuple[bytes, Any]: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + return private_pem, private_key.public_key() + + +@pytest.fixture(scope="session") +def rsa_keypair() -> Tuple[bytes, Any]: + return _generate_keypair() + + +@pytest.fixture(scope="session") +def other_rsa_keypair() -> Tuple[bytes, Any]: + return _generate_keypair() + + +@pytest.fixture +def token_factory(rsa_keypair: Tuple[bytes, Any]) -> TokenFactory: + private_pem, _ = rsa_keypair + return TokenFactory(private_pem) + + +@pytest.fixture +def oidc_provider() -> OidcProviderConfig: + return OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]) + + +@pytest.fixture +def jwt_verifier( + rsa_keypair: Tuple[bytes, Any], oidc_provider: OidcProviderConfig +) -> JwtVerifier: + _, public_key = rsa_keypair + return JwtVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) diff --git a/tests/test_litellm/auth_v2/test_authenticators.py b/tests/test_litellm/auth_v2/test_authenticators.py new file mode 100644 index 000000000000..ab05e50a896c --- /dev/null +++ b/tests/test_litellm/auth_v2/test_authenticators.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import base64 +from typing import Any + +import pytest + +from litellm.auth_v2.authenticators import ( + ApiKeyAuthenticator, + HttpAuthenticator, + JwtVerifier, + MutualTlsAuthenticator, + OAuth2Authenticator, + OidcAuthenticator, + build_authenticators, +) +from litellm.auth_v2.config import ( + ApiKeySchemeConfig, + AuthConfig, + HttpBasicConfig, + MutualTlsConfig, + OidcProviderConfig, +) +from litellm.auth_v2.errors import AuthError +from litellm.auth_v2.models import AuthMethod, SecuritySchemeType + +from auth_v2_helpers import ( + TEST_AUDIENCE, + TEST_ISSUER, + FakeJwksClient, + make_request, +) + +# --------------------------------------------------------------------------- # +# JwtVerifier: every RFC 7519 check must be enforced. +# --------------------------------------------------------------------------- # + + +def test_jwt_verifier_accepts_valid_token(jwt_verifier, token_factory): + claims = jwt_verifier.verify(token_factory.mint(subject="alice", scope="a b")) + assert claims["sub"] == "alice" + assert claims["aud"] == TEST_AUDIENCE + + +def test_jwt_verifier_rejects_bad_signature( + rsa_keypair, other_rsa_keypair, oidc_provider, token_factory +): + _, public_key = rsa_keypair + verifier = JwtVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) + other_pem, _ = other_rsa_keypair + forged = token_factory.mint(private_pem=other_pem) + with pytest.raises(AuthError) as exc: + verifier.verify(forged) + assert exc.value.status_code == 401 + + +def test_jwt_verifier_rejects_wrong_audience(jwt_verifier, token_factory): + with pytest.raises(AuthError) as exc: + jwt_verifier.verify(token_factory.mint(audience="some-other-app")) + assert exc.value.status_code == 401 + + +def test_jwt_verifier_rejects_wrong_issuer(jwt_verifier, token_factory): + with pytest.raises(AuthError) as exc: + jwt_verifier.verify(token_factory.mint(issuer="https://evil.example.com")) + assert exc.value.status_code == 401 + + +def test_jwt_verifier_rejects_expired(jwt_verifier, token_factory): + with pytest.raises(AuthError) as exc: + jwt_verifier.verify(token_factory.mint(expires_in=-30)) + assert exc.value.status_code == 401 + + +def test_jwt_verifier_requires_exp_iss_aud(jwt_verifier, rsa_keypair): + import jwt as pyjwt + + private_pem, _ = rsa_keypair + # token deliberately missing exp/iss/aud + token = pyjwt.encode({"sub": "x"}, private_pem, algorithm="RS256") + with pytest.raises(AuthError) as exc: + jwt_verifier.verify(token) + assert exc.value.status_code == 401 + + +def test_jwt_verifier_enforces_at_jwt_typ(jwt_verifier, token_factory): + without_typ = token_factory.mint() + with pytest.raises(AuthError): + jwt_verifier.verify(without_typ, require_at_jwt=True) + + with_typ = token_factory.mint(headers={"typ": "at+jwt"}) + claims = jwt_verifier.verify(with_typ, require_at_jwt=True) + assert claims["sub"] == "user-1" + + +# --------------------------------------------------------------------------- # +# ApiKeyAuthenticator +# --------------------------------------------------------------------------- # + + +async def test_api_key_authenticator_extracts_header(): + auth = ApiKeyAuthenticator(ApiKeySchemeConfig(header_name="x-litellm-api-key")) + request = make_request(headers={"x-litellm-api-key": "sk-secret-value"}) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.method == AuthMethod.API_KEY + assert credential.subject == "sk-secret-value" + assert credential.claims["_raw_api_key"] == "sk-secret-value" + assert credential.credential_ref.key_id == "sk-secret-" + + +async def test_api_key_authenticator_returns_none_when_absent(): + auth = ApiKeyAuthenticator(ApiKeySchemeConfig()) + assert await auth.authenticate(make_request()) is None + + +# --------------------------------------------------------------------------- # +# HttpAuthenticator (bearer-JWT + basic) +# --------------------------------------------------------------------------- # + + +def _http_auth(public_key: Any, *, basic: HttpBasicConfig = None) -> HttpAuthenticator: + verifier = JwtVerifier( + OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + jwks_client=FakeJwksClient(public_key), + ) + return HttpAuthenticator(basic or HttpBasicConfig(), [verifier]) + + +async def test_http_bearer_valid_token_resolves_credential(rsa_keypair, token_factory): + _, public_key = rsa_keypair + auth = _http_auth(public_key) + token = token_factory.mint(subject="bob", scope="models:read chat:write") + request = make_request(headers={"authorization": f"Bearer {token}"}) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.method == AuthMethod.BEARER_JWT + assert credential.subject == "bob" + assert credential.issuer == TEST_ISSUER + assert credential.audience == [TEST_AUDIENCE] + assert credential.scopes == ["models:read", "chat:write"] + + +async def test_http_bearer_present_but_invalid_fails_fast(rsa_keypair, token_factory): + _, public_key = rsa_keypair + auth = _http_auth(public_key) + # issuer with no configured verifier -> must raise, not return None + token = token_factory.mint(issuer="https://unconfigured.example.com") + request = make_request(headers={"authorization": f"Bearer {token}"}) + with pytest.raises(AuthError) as exc: + await auth.authenticate(request) + assert exc.value.status_code == 401 + + +async def test_http_no_authorization_header_returns_none(rsa_keypair): + _, public_key = rsa_keypair + assert await _http_auth(public_key).authenticate(make_request()) is None + + +async def test_http_basic_disabled_ignores_basic_scheme(rsa_keypair): + _, public_key = rsa_keypair + auth = _http_auth(public_key, basic=HttpBasicConfig(enabled=False)) + creds = base64.b64encode(b"alice:pw").decode() + request = make_request(headers={"authorization": f"Basic {creds}"}) + assert await auth.authenticate(request) is None + + +async def test_http_basic_enabled_decodes_username(rsa_keypair): + _, public_key = rsa_keypair + auth = _http_auth(public_key, basic=HttpBasicConfig(enabled=True)) + creds = base64.b64encode(b"alice:supersecret").decode() + request = make_request(headers={"authorization": f"Basic {creds}"}) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.method == AuthMethod.HTTP_BASIC + assert credential.subject == "alice" + + +async def test_http_basic_malformed_payload_raises(rsa_keypair): + _, public_key = rsa_keypair + auth = _http_auth(public_key, basic=HttpBasicConfig(enabled=True)) + request = make_request(headers={"authorization": "Basic !!!not-base64!!!"}) + with pytest.raises(AuthError) as exc: + await auth.authenticate(request) + assert exc.value.status_code == 401 + + +def test_http_challenge_advertises_basic_only_when_enabled(rsa_keypair): + _, public_key = rsa_keypair + assert "Basic" not in _http_auth(public_key).challenge() + enabled = _http_auth(public_key, basic=HttpBasicConfig(enabled=True)) + assert "Basic" in enabled.challenge() + assert "Bearer" in enabled.challenge() + + +# --------------------------------------------------------------------------- # +# OAuth2Authenticator (at+jwt enforcement + opaque token path) +# --------------------------------------------------------------------------- # + + +def _oauth2(public_key: Any) -> OAuth2Authenticator: + verifier = JwtVerifier( + OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + jwks_client=FakeJwksClient(public_key), + ) + return OAuth2Authenticator([verifier], introspection=None) + + +async def test_oauth2_rejects_jwt_without_at_jwt_typ(rsa_keypair, token_factory): + _, public_key = rsa_keypair + token = token_factory.mint() # no typ header + request = make_request(headers={"authorization": f"Bearer {token}"}) + with pytest.raises(AuthError) as exc: + await _oauth2(public_key).authenticate(request) + assert exc.value.status_code == 401 + + +async def test_oauth2_accepts_at_jwt(rsa_keypair, token_factory): + _, public_key = rsa_keypair + token = token_factory.mint(headers={"typ": "at+jwt"}, subject="svc-1") + request = make_request(headers={"authorization": f"Bearer {token}"}) + credential = await _oauth2(public_key).authenticate(request) + assert credential is not None + assert credential.subject == "svc-1" + + +async def test_oauth2_opaque_token_without_introspection_raises(rsa_keypair): + _, public_key = rsa_keypair + request = make_request(headers={"authorization": "Bearer opaque-not-a-jwt"}) + with pytest.raises(AuthError) as exc: + await _oauth2(public_key).authenticate(request) + assert exc.value.status_code == 401 + + +async def test_oauth2_no_bearer_returns_none(rsa_keypair): + _, public_key = rsa_keypair + assert await _oauth2(public_key).authenticate(make_request()) is None + + +# --------------------------------------------------------------------------- # +# OidcAuthenticator +# --------------------------------------------------------------------------- # + + +def _oidc(public_key: Any) -> OidcAuthenticator: + verifier = JwtVerifier( + OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + jwks_client=FakeJwksClient(public_key), + ) + return OidcAuthenticator([verifier]) + + +async def test_oidc_valid_token_sets_oidc_method(rsa_keypair, token_factory): + _, public_key = rsa_keypair + token = token_factory.mint(subject="carol", email="carol@example.com") + request = make_request(headers={"authorization": f"Bearer {token}"}) + credential = await _oidc(public_key).authenticate(request) + assert credential is not None + assert credential.method == AuthMethod.OIDC + assert credential.subject == "carol" + assert credential.claims["email"] == "carol@example.com" + + +async def test_oidc_unknown_issuer_raises(rsa_keypair, token_factory): + _, public_key = rsa_keypair + token = token_factory.mint(issuer="https://other.example.com") + request = make_request(headers={"authorization": f"Bearer {token}"}) + with pytest.raises(AuthError): + await _oidc(public_key).authenticate(request) + + +# --------------------------------------------------------------------------- # +# MutualTlsAuthenticator +# --------------------------------------------------------------------------- # + + +async def test_mtls_reads_forwarded_subject_header(): + auth = MutualTlsAuthenticator( + MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn") + ) + request = make_request(headers={"x-client-dn": "CN=svc-a,O=Co,C=US"}) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.method == AuthMethod.MUTUAL_TLS + assert credential.subject == "CN=svc-a,O=Co,C=US" + assert credential.client_certificate.subject_dn == "CN=svc-a,O=Co,C=US" + + +async def test_mtls_forwarded_header_absent_returns_none(): + auth = MutualTlsAuthenticator( + MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn") + ) + assert await auth.authenticate(make_request()) is None + + +async def test_mtls_reads_asgi_tls_extension(): + auth = MutualTlsAuthenticator(MutualTlsConfig(enabled=True)) + request = make_request( + scope_extra={"extensions": {"tls": {"client_cert_name": "CN=from-asgi"}}} + ) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.subject == "CN=from-asgi" + + +async def test_mtls_no_cert_returns_none(): + auth = MutualTlsAuthenticator(MutualTlsConfig(enabled=True)) + assert await auth.authenticate(make_request()) is None + + +# --------------------------------------------------------------------------- # +# build_authenticators: ordering and inclusion follow config +# --------------------------------------------------------------------------- # + + +def test_build_authenticators_follows_scheme_order(): + config = AuthConfig() + authenticators = build_authenticators(config) + schemes = [a.scheme for a in authenticators] + # mutual_tls disabled by default -> excluded + assert schemes == [ + SecuritySchemeType.API_KEY, + SecuritySchemeType.HTTP, + SecuritySchemeType.OPENID_CONNECT, + SecuritySchemeType.OAUTH2, + ] + + +def test_build_authenticators_omits_api_key_when_unconfigured(): + config = AuthConfig(api_key=None) + schemes = [a.scheme for a in build_authenticators(config)] + assert SecuritySchemeType.API_KEY not in schemes + + +def test_build_authenticators_includes_mtls_when_enabled(): + config = AuthConfig(mutual_tls=MutualTlsConfig(enabled=True)) + schemes = [a.scheme for a in build_authenticators(config)] + assert SecuritySchemeType.MUTUAL_TLS == schemes[-1] diff --git a/tests/test_litellm/auth_v2/test_config.py b/tests/test_litellm/auth_v2/test_config.py new file mode 100644 index 000000000000..8050ef3eba0e --- /dev/null +++ b/tests/test_litellm/auth_v2/test_config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from litellm.auth_v2.config import ( + OAuth2IntrospectionConfig, + OidcProviderConfig, + SamlConfig, +) + + +def test_saml_config_requires_idp_metadata_when_enabled(): + with pytest.raises(ValidationError): + SamlConfig(enabled=True, entity_id="sp", acs_url="https://sp/acs") + + +def test_saml_config_allows_empty_metadata_when_disabled(): + config = SamlConfig(enabled=False, entity_id="sp", acs_url="https://sp/acs") + assert config.idp_metadata == "" + + +def test_saml_config_accepts_inline_metadata(): + config = SamlConfig( + enabled=True, + entity_id="sp", + acs_url="https://sp/acs", + idp_metadata="", + ) + assert config.idp_metadata == "" + + +def test_oidc_provider_requires_audience(): + with pytest.raises(ValidationError): + OidcProviderConfig(issuer="https://idp.example.com") + + +def test_oidc_provider_defaults_to_rs256(): + provider = OidcProviderConfig(issuer="https://idp.example.com", audience=["x"]) + assert provider.algorithms == ["RS256"] + assert provider.require_at_jwt is False + + +def test_introspection_client_secret_is_secret(): + config = OAuth2IntrospectionConfig( + introspection_endpoint="https://idp.example.com/introspect", + client_id="rp", + client_secret="hunter2", + ) + # SecretStr never leaks the value in its repr + assert "hunter2" not in repr(config) + assert config.client_secret.get_secret_value() == "hunter2" diff --git a/tests/test_litellm/auth_v2/test_models.py b/tests/test_litellm/auth_v2/test_models.py new file mode 100644 index 000000000000..f77bf0f3cbcf --- /dev/null +++ b/tests/test_litellm/auth_v2/test_models.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from litellm.auth_v2.models import ( + AuthMethod, + Credential, + Principal, + PrincipalType, + SecuritySchemeType, + TeamIdentity, + TeamRole, + UserIdentity, +) +from litellm.auth_v2.rbac import Role + + +def _credential() -> Credential: + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod.API_KEY, + subject="sk-test", + ) + + +def test_credential_is_frozen(): + credential = _credential() + with pytest.raises(ValidationError): + credential.subject = "mutated" + + +def test_credential_defaults_are_independent_instances(): + a = _credential() + b = _credential() + assert a.audience == [] and a.scopes == [] and a.claims == {} + assert a.audience is not b.audience + assert a.claims is not b.claims + + +def test_principal_requires_identity_core_fields(): + with pytest.raises(ValidationError): + Principal(subject="u1") # missing principal_type + auth_method + + +def test_principal_roles_are_validated_against_role_enum(): + principal = Principal( + principal_type=PrincipalType.HUMAN, + subject="u1", + auth_method=AuthMethod.OIDC, + roles=["org_admin"], + ) + assert principal.roles == [Role.ORG_ADMIN] + assert isinstance(principal.roles[0], Role) + + with pytest.raises(ValidationError): + Principal( + principal_type=PrincipalType.HUMAN, + subject="u1", + auth_method=AuthMethod.OIDC, + roles=["not_a_real_role"], + ) + + +def test_principal_default_network_and_collections(): + principal = Principal( + principal_type=PrincipalType.SERVICE_ACCOUNT, + subject="svc", + auth_method=AuthMethod.MUTUAL_TLS, + ) + assert principal.teams == [] + assert principal.scopes == [] + assert principal.network.client_ip is None + assert principal.network.via_trusted_proxy is False + + +def test_team_identity_defaults_to_member_role(): + team = TeamIdentity(id="g1") + assert team.role == TeamRole.MEMBER + + +def test_security_scheme_values_match_openapi_spec(): + assert SecuritySchemeType.API_KEY.value == "apiKey" + assert SecuritySchemeType.HTTP.value == "http" + assert SecuritySchemeType.OAUTH2.value == "oauth2" + assert SecuritySchemeType.OPENID_CONNECT.value == "openIdConnect" + assert SecuritySchemeType.MUTUAL_TLS.value == "mutualTLS" + + +def test_user_identity_optional_fields_default_none(): + user = UserIdentity(id="u1") + assert user.email is None + assert user.external_id is None diff --git a/tests/test_litellm/auth_v2/test_network.py b/tests/test_litellm/auth_v2/test_network.py new file mode 100644 index 000000000000..968130d2d6ad --- /dev/null +++ b/tests/test_litellm/auth_v2/test_network.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from litellm.auth_v2.config import TrustedProxyConfig +from litellm.auth_v2.network import resolve_client_ip, resolve_network_context + +from auth_v2_helpers import make_request + +TRUSTED = TrustedProxyConfig(use_forwarded_for=True, trusted_proxy_cidrs=["10.0.0.0/8"]) + + +def test_xff_ignored_when_forwarding_disabled(): + config = TrustedProxyConfig( + use_forwarded_for=False, trusted_proxy_cidrs=["10.0.0.0/8"] + ) + request = make_request( + headers={"x-forwarded-for": "203.0.113.9"}, client=("10.0.0.1", 1) + ) + ip, via_proxy = resolve_client_ip(request, config) + assert ip == "10.0.0.1" + assert via_proxy is False + + +def test_xff_honored_from_trusted_peer(): + request = make_request( + headers={"x-forwarded-for": "203.0.113.9, 10.0.0.5"}, client=("10.0.0.1", 1) + ) + ip, via_proxy = resolve_client_ip(request, TRUSTED) + assert ip == "203.0.113.9" + assert via_proxy is True + + +def test_spoofed_xff_from_untrusted_peer_is_ignored(): + request = make_request( + headers={"x-forwarded-for": "203.0.113.9"}, client=("8.8.8.8", 1) + ) + ip, via_proxy = resolve_client_ip(request, TRUSTED) + assert ip == "8.8.8.8" + assert via_proxy is False + + +def test_right_to_left_parse_skips_chained_trusted_proxies(): + request = make_request( + headers={"x-forwarded-for": "198.51.100.4, 10.1.1.1, 10.0.0.9"}, + client=("10.0.0.1", 1), + ) + ip, via_proxy = resolve_client_ip(request, TRUSTED) + assert ip == "198.51.100.4" + assert via_proxy is True + + +def test_all_trusted_hops_fall_back_to_peer(): + request = make_request( + headers={"x-forwarded-for": "10.1.1.1, 10.0.0.9"}, client=("10.0.0.1", 1) + ) + ip, via_proxy = resolve_client_ip(request, TRUSTED) + assert ip == "10.0.0.1" + assert via_proxy is True + + +def test_invalid_xff_token_is_skipped(): + request = make_request( + headers={"x-forwarded-for": "not-an-ip, 203.0.113.50"}, client=("10.0.0.1", 1) + ) + ip, _ = resolve_client_ip(request, TRUSTED) + assert ip == "203.0.113.50" + + +def test_network_context_captures_host_and_proxy_flag(): + request = make_request( + headers={"x-forwarded-for": "203.0.113.9", "host": "proxy.litellm.ai"}, + client=("10.0.0.1", 1), + ) + ctx = resolve_network_context(request, TRUSTED) + assert ctx.client_ip == "203.0.113.9" + assert ctx.host == "proxy.litellm.ai" + assert ctx.via_trusted_proxy is True diff --git a/tests/test_litellm/auth_v2/test_oidc.py b/tests/test_litellm/auth_v2/test_oidc.py new file mode 100644 index 000000000000..c2b04c571f22 --- /dev/null +++ b/tests/test_litellm/auth_v2/test_oidc.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from litellm.auth_v2.config import OidcProviderConfig +from litellm.auth_v2.oidc import _provider_key, _user_from_userinfo +from litellm.auth_v2.resolver import InMemoryIdentityStore + + +def test_userinfo_maps_standard_claims_to_scim_user(): + user = _user_from_userinfo( + { + "sub": "idp-subject-123", + "preferred_username": "dana", + "email": "dana@example.com", + "name": "Dana D", + } + ) + assert user.external_id == "idp-subject-123" + assert user.user_name == "dana" + assert user.display_name == "Dana D" + + +def test_userinfo_falls_back_to_email_when_no_preferred_username(): + user = _user_from_userinfo({"sub": "s1", "email": "eve@example.com"}) + assert user.user_name == "eve@example.com" + + +def test_provider_key_sanitizes_issuer_url(): + key = _provider_key( + OidcProviderConfig(issuer="https://Login.Example.com/realm", audience=["x"]) + ) + assert key == "https-login-example-com-realm" + assert " " not in key + + +async def test_callback_seam_upserts_userinfo_into_store(): + store = InMemoryIdentityStore() + userinfo = { + "sub": "idp-subject-123", + "preferred_username": "dana", + "email": "dana@example.com", + "name": "Dana D", + } + # this is exactly what the OIDC callback does: map userinfo -> SCIM user -> upsert + stored = await store.upsert_user(_user_from_userinfo(userinfo)) + + assert stored.id # store assigned an id + fetched = await store.get_user(stored.id) + assert fetched is not None + assert fetched.external_id == "idp-subject-123" + assert fetched.user_name == "dana" diff --git a/tests/test_litellm/auth_v2/test_rbac.py b/tests/test_litellm/auth_v2/test_rbac.py new file mode 100644 index 000000000000..98232fbdc5c6 --- /dev/null +++ b/tests/test_litellm/auth_v2/test_rbac.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from fastapi.security import SecurityScopes + +from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.auth_v2.rbac import Role, has_any_role, has_required_scopes + + +def _principal(*, scopes=None, roles=None) -> Principal: + return Principal( + principal_type=PrincipalType.HUMAN, + subject="u1", + auth_method=AuthMethod.OIDC, + scopes=scopes or [], + roles=roles or [], + ) + + +def test_required_scopes_is_subset_check(): + principal = _principal(scopes=["models:read", "chat:write", "scim:write"]) + assert has_required_scopes(SecurityScopes(["models:read"]), principal) + assert has_required_scopes(SecurityScopes(["models:read", "chat:write"]), principal) + + +def test_missing_required_scope_fails(): + principal = _principal(scopes=["models:read"]) + assert not has_required_scopes(SecurityScopes(["chat:write"]), principal) + + +def test_empty_required_scopes_always_passes(): + assert has_required_scopes(SecurityScopes([]), _principal()) + + +def test_has_any_role_matches_one_of_allowed(): + principal = _principal(roles=[Role.TEAM_MEMBER, Role.ORG_VIEWER]) + assert has_any_role(principal, (Role.ORG_VIEWER, Role.PLATFORM_ADMIN)) + + +def test_has_any_role_rejects_when_no_overlap(): + principal = _principal(roles=[Role.TEAM_MEMBER]) + assert not has_any_role(principal, (Role.PLATFORM_ADMIN, Role.ORG_ADMIN)) + + +def test_has_any_role_false_when_principal_has_no_roles(): + assert not has_any_role(_principal(), (Role.PLATFORM_ADMIN,)) diff --git a/tests/test_litellm/auth_v2/test_resolver.py b/tests/test_litellm/auth_v2/test_resolver.py new file mode 100644 index 000000000000..6526f314819b --- /dev/null +++ b/tests/test_litellm/auth_v2/test_resolver.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import pytest + +from litellm.auth_v2.errors import AuthError +from litellm.auth_v2.models import ( + AuthMethod, + ClientCertificate, + Credential, + Principal, + PrincipalType, + SecuritySchemeType, +) +from litellm.auth_v2.rbac import Role +from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key + + +def _api_key_credential(raw: str) -> Credential: + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod.API_KEY, + subject=raw, + claims={"_raw_api_key": raw}, + ) + + +def _principal(subject: str = "user-1") -> Principal: + return Principal( + principal_type=PrincipalType.HUMAN, + subject=subject, + auth_method=AuthMethod.API_KEY, + ) + + +async def test_api_key_resolves_only_for_exact_key(): + raw = "sk-live-correct-horse" + store = InMemoryIdentityStore(api_keys={_hash_api_key(raw): _principal("svc-a")}) + + resolved = await store.resolve(_api_key_credential(raw)) + assert resolved.subject == "svc-a" + + +async def test_wrong_api_key_never_resolves(): + raw = "sk-live-correct-horse" + store = InMemoryIdentityStore(api_keys={_hash_api_key(raw): _principal()}) + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential("sk-live-wrong-key")) + assert exc.value.status_code == 401 + + +async def test_api_key_lookup_is_keyed_on_sha256_not_raw(): + raw = "sk-live-correct-horse" + # store keyed by the raw value (not its hash) must NOT resolve: resolver hashes first + store = InMemoryIdentityStore(api_keys={raw: _principal()}) + with pytest.raises(AuthError): + await store.resolve(_api_key_credential(raw)) + + +async def test_missing_raw_api_key_claim_is_rejected(): + store = InMemoryIdentityStore(api_keys={}) + credential = Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod.API_KEY, + subject="sk-x", + ) + with pytest.raises(AuthError): + await store.resolve(credential) + + +async def test_subject_lookup_prefers_stored_principal(): + stored = _principal("from-store") + store = InMemoryIdentityStore(subjects={"https://idp|sub-9": stored}) + credential = Credential( + scheme=SecuritySchemeType.OPENID_CONNECT, + method=AuthMethod.OIDC, + subject="sub-9", + issuer="https://idp", + ) + resolved = await store.resolve(credential) + assert resolved.subject == "from-store" + + +async def test_self_describing_token_builds_principal_from_claims(): + store = InMemoryIdentityStore() + credential = Credential( + scheme=SecuritySchemeType.OPENID_CONNECT, + method=AuthMethod.OIDC, + subject="sub-42", + issuer="https://idp", + scopes=["models:read"], + claims={ + "email": "dana@example.com", + "preferred_username": "dana", + "name": "Dana D", + "groups": ["eng", "oncall"], + "roles": ["org_admin", "bogus_role"], + }, + ) + principal = await store.resolve(credential) + assert principal.user.email == "dana@example.com" + assert principal.user.user_name == "dana" + assert [team.id for team in principal.teams] == ["eng", "oncall"] + # invalid role strings are filtered out, valid ones become Role enums + assert principal.roles == [Role.ORG_ADMIN] + assert principal.scopes == ["models:read"] + + +async def test_mtls_credential_resolves_to_service_account(): + store = InMemoryIdentityStore() + credential = Credential( + scheme=SecuritySchemeType.MUTUAL_TLS, + method=AuthMethod.MUTUAL_TLS, + subject="CN=svc-a,O=Co", + client_certificate=ClientCertificate(subject_dn="CN=svc-a,O=Co"), + ) + principal = await store.resolve(credential) + assert principal.principal_type == PrincipalType.SERVICE_ACCOUNT + assert principal.user is None + assert principal.subject == "CN=svc-a,O=Co" diff --git a/tests/test_litellm/auth_v2/test_saml.py b/tests/test_litellm/auth_v2/test_saml.py new file mode 100644 index 000000000000..0c05c2301bda --- /dev/null +++ b/tests/test_litellm/auth_v2/test_saml.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import base64 +import datetime +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + +import pytest +from fastapi import FastAPI, Security +from fastapi.testclient import TestClient + +xmlsec1 = shutil.which("xmlsec1") +pytestmark = pytest.mark.skipif( + xmlsec1 is None, reason="SAML SP requires the xmlsec1 binary on PATH" +) + +SP_ENTITY_ID = "https://sp.test.litellm.ai/auth/saml/metadata" +ACS_URL = "https://sp.test.litellm.ai/auth/saml/acs" +IDP_ENTITY_ID = "https://idp.test.litellm.ai/idp" +IDP_SSO_URL = "https://idp.test.litellm.ai/sso" + + +def _gen_cert(directory: Path, prefix: str) -> tuple[str, str]: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, prefix)]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime(2020, 1, 1)) + .not_valid_after(datetime.datetime(2035, 1, 1)) + .sign(key, hashes.SHA256()) + ) + key_path = directory / f"{prefix}.key" + cert_path = directory / f"{prefix}.crt" + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + return str(key_path), str(cert_path) + + +@dataclass +class SamlEnv: + config: Any + idp: Any # saml2.server.Server + name_id_value: str = "alice@example.com" + + def mint_response( + self, + *, + identity: Optional[Dict[str, Any]] = None, + sign_assertion: bool = True, + ) -> str: + from saml2.authn_context import PASSWORD + from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NameID + + name_id = NameID(format=NAMEID_FORMAT_EMAILADDRESS, text=self.name_id_value) + response = self.idp.create_authn_response( + identity=identity + or { + "email": ["alice@example.com"], + "displayName": ["Alice Anderson"], + "groups": ["eng", "admins"], + }, + in_response_to=None, + destination=ACS_URL, + sp_entity_id=SP_ENTITY_ID, + name_id=name_id, + sign_assertion=sign_assertion, + authn={"class_ref": PASSWORD, "authn_auth": IDP_ENTITY_ID}, + ) + return base64.b64encode(str(response).encode()).decode() + + +@pytest.fixture +def saml_env(tmp_path: Path) -> SamlEnv: + from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT + from saml2.config import IdPConfig, SPConfig + from saml2.metadata import entity_descriptor + from saml2.saml import NAMEID_FORMAT_EMAILADDRESS + from saml2.server import Server + + from litellm.auth_v2.config import SamlConfig + + idp_key, idp_cert = _gen_cert(tmp_path, "idp") + sp_key, sp_cert = _gen_cert(tmp_path, "sp") + + sp_conf = SPConfig() + sp_conf.load( + { + "entityid": SP_ENTITY_ID, + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [(ACS_URL, BINDING_HTTP_POST)] + }, + "allow_unsolicited": True, + "authn_requests_signed": False, + "want_assertions_signed": True, + "want_response_signed": False, + } + }, + "allow_unknown_attributes": True, + "xmlsec_binary": xmlsec1, + } + ) + sp_metadata_path = tmp_path / "sp_metadata.xml" + sp_metadata_path.write_text(str(entity_descriptor(sp_conf))) + + idp_conf = IdPConfig() + idp_conf.load( + { + "entityid": IDP_ENTITY_ID, + "service": { + "idp": { + "endpoints": { + "single_sign_on_service": [(IDP_SSO_URL, BINDING_HTTP_REDIRECT)] + }, + "name_id_format": [NAMEID_FORMAT_EMAILADDRESS], + } + }, + "metadata": {"local": [str(sp_metadata_path)]}, + "key_file": idp_key, + "cert_file": idp_cert, + "xmlsec_binary": xmlsec1, + } + ) + idp = Server(config=idp_conf) + idp_metadata = str(entity_descriptor(idp.config)) + + config = SamlConfig( + enabled=True, + entity_id=SP_ENTITY_ID, + acs_url=ACS_URL, + idp_metadata=idp_metadata, + sp_key_file=sp_key, + sp_cert_file=sp_cert, + xmlsec_binary=xmlsec1, + ) + return SamlEnv(config=config, idp=idp) + + +def _build_app(saml_env: SamlEnv): + from litellm.auth_v2.config import AuthConfig + from litellm.auth_v2.models import Principal + from litellm.auth_v2.resolver import InMemoryIdentityStore + from litellm.auth_v2.security import get_current_principal, install_auth + + app = FastAPI() + store = InMemoryIdentityStore() + install_auth( + app, + AuthConfig(saml=saml_env.config), + store, + mount_scim=False, + mount_oidc=False, + mount_saml=True, + ) + + @app.get("/whoami") + async def whoami( + principal: "Principal" = Security(get_current_principal), + ): + return { + "subject": principal.subject, + "auth_method": principal.auth_method.value, + "email": principal.user.email if principal.user else None, + } + + return app, store + + +# --------------------------------------------------------------------------- # +# Metadata + login redirect +# --------------------------------------------------------------------------- # + + +def test_metadata_endpoint_serves_sp_descriptor(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + response = client.get("/auth/saml/metadata") + assert response.status_code == 200 + assert "EntityDescriptor" in response.text + assert SP_ENTITY_ID in response.text + assert ACS_URL in response.text + + +def test_login_redirects_to_idp_sso(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + response = client.get("/auth/saml/login", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"].startswith(IDP_SSO_URL) + + +# --------------------------------------------------------------------------- # +# ACS: signed assertion provisions + authenticates; tampering is rejected +# --------------------------------------------------------------------------- # + + +def test_acs_accepts_signed_assertion_and_provisions_user(saml_env): + app, store = _build_app(saml_env) + client = TestClient(app) + saml_response = saml_env.mint_response() + + acs = client.post( + "/auth/saml/acs", + data={"SAMLResponse": saml_response}, + follow_redirects=False, + ) + assert acs.status_code == 303 + assert "saml_session" in acs.cookies + + # user was provisioned into the ProvisioningStore via the shared upsert seam + users = list(store._users.values()) + assert len(users) == 1 + assert users[0].external_id == "alice@example.com" + assert users[0].emails[0].value == "alice@example.com" + + +def test_session_cookie_authenticates_with_saml_method(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={"SAMLResponse": saml_env.mint_response()}, + follow_redirects=False, + ) + client.cookies.set("saml_session", acs.cookies["saml_session"]) + + whoami = client.get("/whoami") + assert whoami.status_code == 200 + body = whoami.json() + assert body["auth_method"] == "saml" + assert body["subject"] == "alice@example.com" + assert body["email"] == "alice@example.com" + + +def test_acs_rejects_tampered_assertion(saml_env): + app, store = _build_app(saml_env) + client = TestClient(app) + valid = saml_env.mint_response() + decoded = base64.b64decode(valid).decode() + tampered = decoded.replace("alice@example.com", "attacker@evil.com") + tampered_b64 = base64.b64encode(tampered.encode()).decode() + + response = client.post( + "/auth/saml/acs", + data={"SAMLResponse": tampered_b64}, + follow_redirects=False, + ) + assert response.status_code == 401 + assert store._users == {} + + +def test_acs_rejects_unsigned_assertion(saml_env): + app, store = _build_app(saml_env) + client = TestClient(app) + unsigned = saml_env.mint_response(sign_assertion=False) + response = client.post( + "/auth/saml/acs", + data={"SAMLResponse": unsigned}, + follow_redirects=False, + ) + assert response.status_code == 401 + assert store._users == {} + + +def test_acs_missing_response_is_rejected(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + response = client.post("/auth/saml/acs", data={}, follow_redirects=False) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- # +# Pure helpers (no xmlsec1 required) - attribute mapping + open-redirect guard +# --------------------------------------------------------------------------- # + + +def test_map_attributes_applies_attribute_map(): + from litellm.auth_v2.config import DEFAULT_SAML_ATTRIBUTE_MAP + from litellm.auth_v2.saml import _map_attributes + + ava = { + "email": ["alice@example.com"], + "givenName": ["Alice"], + "surname": ["Anderson"], + "groups": ["eng", "admins"], + } + mapped = _map_attributes(ava, dict(DEFAULT_SAML_ATTRIBUTE_MAP)) + assert mapped["email"] == "alice@example.com" + assert mapped["given_name"] == "Alice" + assert mapped["family_name"] == "Anderson" + assert mapped["groups"] == ["eng", "admins"] + + +def test_user_from_mapped_builds_name_and_email(): + from litellm.auth_v2.saml import _user_from_mapped + + user = _user_from_mapped( + "alice@example.com", + { + "given_name": "Alice", + "family_name": "Anderson", + "email": "alice@example.com", + }, + ) + assert user.external_id == "alice@example.com" + assert user.display_name == "Alice Anderson" + assert user.emails[0].value == "alice@example.com" + assert user.name.given_name == "Alice" + + +@pytest.mark.parametrize( + "candidate,expected", + [ + ("/dashboard", "/dashboard"), + ("//evil.com", "/"), + ("https://evil.com", "/"), + ("/path\\with-backslash", "/"), + (None, "/"), + ], +) +def test_safe_relay_state_blocks_open_redirects(candidate, expected): + from litellm.auth_v2.saml import _safe_relay_state + + assert _safe_relay_state(candidate, "/") == expected + + +@pytest.mark.parametrize( + "metadata,expected_key", + [ + ("", "inline"), + ("https://idp.example.com/metadata", "remote"), + ("/etc/saml/idp.xml", "local"), + ], +) +def test_metadata_source_classifies_input(metadata, expected_key): + from litellm.auth_v2.saml import _metadata_source + + assert expected_key in _metadata_source(metadata) diff --git a/tests/test_litellm/auth_v2/test_scim.py b/tests/test_litellm/auth_v2/test_scim.py new file mode 100644 index 000000000000..d00df3364573 --- /dev/null +++ b/tests/test_litellm/auth_v2/test_scim.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from litellm.auth_v2.config import AuthConfig +from litellm.auth_v2.resolver import InMemoryIdentityStore +from litellm.auth_v2.security import install_auth + +USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + install_auth( + app, + AuthConfig(), + InMemoryIdentityStore(), + mount_scim=True, + mount_oidc=False, + mount_saml=False, + ) + return TestClient(app) + + +def _create_user(client: TestClient, user_name="alice@example.com", display="Alice"): + return client.post( + "/scim/v2/Users", + json={"schemas": [USER_SCHEMA], "userName": user_name, "displayName": display}, + ) + + +def test_create_user_returns_201_with_id(client): + response = _create_user(client) + assert response.status_code == 201 + body = response.json() + assert body["id"] + assert body["userName"] == "alice@example.com" + assert USER_SCHEMA in body["schemas"] + + +def test_get_user_round_trips(client): + user_id = _create_user(client).json()["id"] + response = client.get(f"/scim/v2/Users/{user_id}") + assert response.status_code == 200 + assert response.json()["userName"] == "alice@example.com" + + +def test_get_unknown_user_returns_scim_404(client): + response = client.get("/scim/v2/Users/does-not-exist") + assert response.status_code == 404 + assert ERROR_SCHEMA in response.json()["schemas"] + + +def test_patch_replace_display_name(client): + user_id = _create_user(client).json()["id"] + response = client.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "replace", "path": "displayName", "value": "Alice B"} + ], + }, + ) + assert response.status_code == 200 + assert response.json()["displayName"] == "Alice B" + # persisted + assert client.get(f"/scim/v2/Users/{user_id}").json()["displayName"] == "Alice B" + + +def test_list_users_returns_list_response(client): + _create_user(client, user_name="a@example.com") + _create_user(client, user_name="b@example.com") + response = client.get("/scim/v2/Users") + assert response.status_code == 200 + body = response.json() + assert body["totalResults"] == 2 + user_names = {r["userName"] for r in body["Resources"]} + assert user_names == {"a@example.com", "b@example.com"} + + +def test_deactivate_user_sets_active_false(client): + user_id = _create_user(client).json()["id"] + assert client.delete(f"/scim/v2/Users/{user_id}").status_code == 204 + assert client.get(f"/scim/v2/Users/{user_id}").json()["active"] is False + + +def test_malformed_user_returns_scim_400_error(client): + # userName is required for a SCIM User creation request + response = client.post( + "/scim/v2/Users", json={"schemas": [USER_SCHEMA], "displayName": "No Username"} + ) + assert response.status_code == 400 + body = response.json() + assert ERROR_SCHEMA in body["schemas"] + assert body["status"] == "400" + + +def test_group_membership_round_trips(client): + response = client.post( + "/scim/v2/Groups", + json={ + "schemas": [GROUP_SCHEMA], + "displayName": "Engineering", + "members": [{"value": "user-1", "display": "Alice"}], + }, + ) + assert response.status_code == 201 + group_id = response.json()["id"] + + fetched = client.get(f"/scim/v2/Groups/{group_id}").json() + assert fetched["displayName"] == "Engineering" + assert fetched["members"][0]["value"] == "user-1" + + +def test_delete_group_removes_it(client): + group_id = client.post( + "/scim/v2/Groups", + json={"schemas": [GROUP_SCHEMA], "displayName": "Temp"}, + ).json()["id"] + assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 204 + assert client.get(f"/scim/v2/Groups/{group_id}").status_code == 404 + + +def test_service_provider_config_advertises_patch(client): + response = client.get("/scim/v2/ServiceProviderConfig") + assert response.status_code == 200 + assert response.json()["patch"]["supported"] is True + + +def test_resource_types_lists_user_and_group(client): + response = client.get("/scim/v2/ResourceTypes") + assert response.status_code == 200 + names = {r["name"] for r in response.json()["Resources"]} + assert names == {"User", "Group"} + + +def test_schemas_endpoint_returns_user_and_group(client): + response = client.get("/scim/v2/Schemas") + assert response.status_code == 200 + assert response.json()["totalResults"] == 2 diff --git a/tests/test_litellm/auth_v2/test_security.py b/tests/test_litellm/auth_v2/test_security.py new file mode 100644 index 000000000000..f47466f2d296 --- /dev/null +++ b/tests/test_litellm/auth_v2/test_security.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import Annotated, Any, Tuple + +import pytest +from fastapi import FastAPI, Security +from fastapi.testclient import TestClient + +from litellm.auth_v2.authenticators import ( + ApiKeyAuthenticator, + HttpAuthenticator, + JwtVerifier, +) +from litellm.auth_v2.config import ( + ApiKeySchemeConfig, + AuthConfig, + HttpBasicConfig, + OidcProviderConfig, +) +from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.auth_v2.rbac import Role +from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.auth_v2.security import ( + AuthContext, + get_current_principal, + require_roles, +) + +from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient + +ADMIN_KEY = "sk-admin-key" +READER_KEY = "sk-reader-key" +NOSCOPE_KEY = "sk-noscope-key" + + +def _principal(subject: str, *, scopes=None, roles=None) -> Principal: + return Principal( + principal_type=PrincipalType.HUMAN, + subject=subject, + auth_method=AuthMethod.API_KEY, + scopes=scopes or [], + roles=roles or [], + ) + + +def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]: + verifier = JwtVerifier( + OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + jwks_client=FakeJwksClient(public_key), + ) + authenticators = [ + ApiKeyAuthenticator(ApiKeySchemeConfig()), + HttpAuthenticator(HttpBasicConfig(), [verifier]), + ] + resolver = InMemoryIdentityStore( + api_keys={ + _hash_api_key(ADMIN_KEY): _principal( + "admin-principal", scopes=["models:read"], roles=[Role.ORG_ADMIN] + ), + _hash_api_key(READER_KEY): _principal( + "reader-principal", scopes=["models:read"] + ), + _hash_api_key(NOSCOPE_KEY): _principal("noscope-principal"), + } + ) + ctx = AuthContext(AuthConfig(), authenticators, resolver) + + app = FastAPI() + app.state.auth_v2 = ctx + + @app.get("/open") + async def open_route( + principal: Annotated[Principal, Security(get_current_principal)], + ): + return { + "subject": principal.subject, + "auth_method": principal.auth_method.value, + "network_host": principal.network.host, + } + + @app.get("/scoped") + async def scoped_route( + principal: Annotated[ + Principal, Security(get_current_principal, scopes=["models:read"]) + ], + ): + return {"subject": principal.subject} + + @app.get("/admin") + async def admin_route( + principal: Annotated[Principal, Security(require_roles(Role.ORG_ADMIN))], + ): + return {"subject": principal.subject} + + return app, resolver + + +@pytest.fixture +def client(rsa_keypair) -> TestClient: + _, public_key = rsa_keypair + app, _ = _build_app(public_key) + return TestClient(app) + + +def _bearer(token_factory, **claims) -> dict: + return {"Authorization": f"Bearer {token_factory.mint(**claims)}"} + + +# --------------------------------------------------------------------------- # +# Missing credential +# --------------------------------------------------------------------------- # + + +def test_no_credential_returns_401_with_challenge(client): + response = client.get("/open") + assert response.status_code == 401 + assert "WWW-Authenticate" in response.headers + assert "Bearer" in response.headers["WWW-Authenticate"] + + +# --------------------------------------------------------------------------- # +# Single-scheme success + network wiring +# --------------------------------------------------------------------------- # + + +def test_valid_api_key_authenticates(client): + response = client.get("/open", headers={"x-litellm-api-key": READER_KEY}) + assert response.status_code == 200 + body = response.json() + assert body["subject"] == "reader-principal" + assert body["network_host"] # resolve_network_context wired into principal + + +def test_valid_bearer_authenticates_from_claims(client, token_factory): + response = client.get( + "/open", headers=_bearer(token_factory, subject="jwt-user", scope="models:read") + ) + assert response.status_code == 200 + assert response.json()["subject"] == "jwt-user" + + +# --------------------------------------------------------------------------- # +# OR precedence: first match wins, present-but-invalid fails fast +# --------------------------------------------------------------------------- # + + +def test_first_match_wins_api_key_before_bearer(client, token_factory): + response = client.get( + "/open", + headers={ + "x-litellm-api-key": READER_KEY, + "Authorization": f"Bearer {token_factory.mint(subject='jwt-user')}", + }, + ) + assert response.status_code == 200 + # api key is earlier in scheme_order, so it resolves; bearer is never consulted + assert response.json()["subject"] == "reader-principal" + + +def test_present_but_invalid_api_key_does_not_fall_through_to_bearer( + client, token_factory +): + response = client.get( + "/open", + headers={ + "x-litellm-api-key": "sk-totally-unknown", + "Authorization": f"Bearer {token_factory.mint(subject='jwt-user', scope='models:read')}", + }, + ) + assert response.status_code == 401 + assert "jwt-user" not in response.text + + +# --------------------------------------------------------------------------- # +# Scope enforcement (RFC 6750: 403 insufficient_scope) +# --------------------------------------------------------------------------- # + + +def test_scope_satisfied_returns_200(client): + response = client.get("/scoped", headers={"x-litellm-api-key": READER_KEY}) + assert response.status_code == 200 + + +def test_missing_scope_returns_403_insufficient_scope(client): + response = client.get("/scoped", headers={"x-litellm-api-key": NOSCOPE_KEY}) + assert response.status_code == 403 + assert "insufficient_scope" in response.headers.get("WWW-Authenticate", "") + + +# --------------------------------------------------------------------------- # +# Role enforcement +# --------------------------------------------------------------------------- # + + +def test_required_role_present_returns_200(client): + response = client.get("/admin", headers={"x-litellm-api-key": ADMIN_KEY}) + assert response.status_code == 200 + assert response.json()["subject"] == "admin-principal" + + +def test_required_role_missing_returns_403(client): + response = client.get("/admin", headers={"x-litellm-api-key": READER_KEY}) + assert response.status_code == 403 From d3878ee9a931fb67f70598d52ea8bcba3f973b5a Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:03:08 -0700 Subject: [PATCH 07/51] fix(auth_v2): require scim:write auth on all SCIM routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SCIM router mounted /scim/v2/* with no security dependency, so any caller could create or delete users and groups unauthenticated. Guard the whole router with Security(get_current_principal, scopes=["scim:write"]) per design 03 §11, so provisioning callers authenticate with the same bearer token or API key as every other route and the scope gates them: unauthenticated requests now 401, an authenticated principal without scim:write gets 403 insufficient_scope. Also document two deployment facts uncovered alongside this: uvicorn's --proxy-headers rewrites request.client from X-Forwarded-For before this module's trusted_proxy_cidrs check runs and silently bypasses it (install_auth docstring), and the scheme_order precedence where HTTP precedes openIdConnect so a bearer JWT is labeled bearer_jwt rather than oidc (both verify identically). --- litellm/auth_v2/config.py | 4 ++++ litellm/auth_v2/scim.py | 9 +++++++-- litellm/auth_v2/security.py | 9 +++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index 16169e758ad4..3e2acc7a78bf 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -84,6 +84,10 @@ def _require_idp_metadata(self) -> "SamlConfig": class AuthConfig(BaseModel): + # First-match-wins precedence. HTTP precedes OPENID_CONNECT, so a bearer JWT + # is claimed by HttpAuthenticator (auth_method=bearer_jwt) and OidcAuthenticator + # never runs; both share the same JwtVerifiers and verify identically, so this + # only changes the auth_method label. Reorder if openIdConnect labeling matters. scheme_order: List[SecuritySchemeType] = Field( default_factory=lambda: [ SecuritySchemeType.API_KEY, diff --git a/litellm/auth_v2/scim.py b/litellm/auth_v2/scim.py index d9a2739bbfa8..0f9b13f1e333 100644 --- a/litellm/auth_v2/scim.py +++ b/litellm/auth_v2/scim.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional, Type, TypeVar -from fastapi import APIRouter, Request, Response, status +from fastapi import APIRouter, Request, Response, Security, status from fastapi.responses import JSONResponse from pydantic import ValidationError from scim2_models import ( @@ -23,6 +23,7 @@ ) from .resolver import ProvisioningStore +from .security import get_current_principal R = TypeVar("R", bound=Resource) @@ -63,7 +64,11 @@ def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: def build_scim_router() -> APIRouter: - router = APIRouter(prefix="/scim/v2", tags=["scim"]) + router = APIRouter( + prefix="/scim/v2", + tags=["scim"], + dependencies=[Security(get_current_principal, scopes=["scim:write"])], + ) @router.post("/Users", status_code=status.HTTP_201_CREATED) async def create_user(request: Request) -> Response: diff --git a/litellm/auth_v2/security.py b/litellm/auth_v2/security.py index 5330509f617a..7dd2721a3fb0 100644 --- a/litellm/auth_v2/security.py +++ b/litellm/auth_v2/security.py @@ -31,6 +31,15 @@ def install_auth( mount_oidc: bool = True, mount_saml: bool = True, ) -> AuthContext: + """Wire the authenticators, resolver and optional routers onto the app. + + Deployment requirement for trusted-proxy IP resolution: uvicorn's + ``--proxy-headers`` (enabled by default) overwrites ``request.client`` from + ``X-Forwarded-For`` before this module's ``trusted_proxy_cidrs`` check runs, + which silently bypasses it. Run uvicorn with ``--no-proxy-headers`` and let + this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and + rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both. + """ ctx = AuthContext(config, build_authenticators(config), resolver) app.state.auth_v2 = ctx if mount_scim: From 7cf35ccc0ae5a8fd3eecb9f5b07f086807716aa0 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:06:06 -0700 Subject: [PATCH 08/51] test(auth_v2): cover SCIM scim:write guard and SAML RelayState redirect Follow the auth module updates: SCIM routes now require the scim:write scope, and the SAML ACS/login flow redirects to a validated RelayState instead of returning JSON. - scim: authenticate every request with a scoped key, and pin the guard directly: no credential -> 401 with WWW-Authenticate, an authenticated principal without scim:write -> 403 insufficient_scope - saml: assert ACS returns 303 to a safe RelayState ("/dashboard") and falls back to default_redirect_path for an absolute/"//host" RelayState; assert GET /login threads ?next= through as a validated RelayState; add a garbage SAMLResponse -> 401 case A mutation spot-check confirmed the open-redirect tests fail when the _safe_relay_state guard is bypassed. --- tests/test_litellm/auth_v2/test_saml.py | 61 +++++++++++++++++++++++++ tests/test_litellm/auth_v2/test_scim.py | 55 ++++++++++++++++++++-- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/auth_v2/test_saml.py b/tests/test_litellm/auth_v2/test_saml.py index 0c05c2301bda..a4f9f15b68d7 100644 --- a/tests/test_litellm/auth_v2/test_saml.py +++ b/tests/test_litellm/auth_v2/test_saml.py @@ -287,6 +287,67 @@ def test_acs_missing_response_is_rejected(saml_env): assert response.status_code == 400 +def test_acs_rejects_garbage_response(saml_env): + app, store = _build_app(saml_env) + client = TestClient(app) + response = client.post( + "/auth/saml/acs", + data={"SAMLResponse": "this-is-not-a-saml-response"}, + follow_redirects=False, + ) + assert response.status_code == 401 + assert store._users == {} + + +def test_acs_redirects_to_safe_relay_state(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={"SAMLResponse": saml_env.mint_response(), "RelayState": "/dashboard"}, + follow_redirects=False, + ) + assert acs.status_code == 303 + assert acs.headers["location"] == "/dashboard" + + +def test_acs_rejects_open_redirect_relay_state(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={ + "SAMLResponse": saml_env.mint_response(), + "RelayState": "https://evil.example.com/phish", + }, + follow_redirects=False, + ) + assert acs.status_code == 303 + # unsafe RelayState falls back to default_redirect_path, never the attacker URL + assert acs.headers["location"] == "/" + + +def test_login_threads_safe_next_as_relay_state(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + response = client.get("/auth/saml/login?next=/dashboard", follow_redirects=False) + assert response.status_code == 303 + assert "RelayState=%2Fdashboard" in response.headers["location"] + + +def test_login_rejects_open_redirect_next(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + response = client.get( + "/auth/saml/login?next=https://evil.example.com", follow_redirects=False + ) + assert response.status_code == 303 + location = response.headers["location"] + assert "evil.example.com" not in location + # falls back to default_redirect_path ("/") as the RelayState + assert "RelayState=%2F&" in location or location.endswith("RelayState=%2F") + + # --------------------------------------------------------------------------- # # Pure helpers (no xmlsec1 required) - attribute mapping + open-redirect guard # --------------------------------------------------------------------------- # diff --git a/tests/test_litellm/auth_v2/test_scim.py b/tests/test_litellm/auth_v2/test_scim.py index d00df3364573..b58529161bd9 100644 --- a/tests/test_litellm/auth_v2/test_scim.py +++ b/tests/test_litellm/auth_v2/test_scim.py @@ -5,26 +5,49 @@ from fastapi.testclient import TestClient from litellm.auth_v2.config import AuthConfig -from litellm.auth_v2.resolver import InMemoryIdentityStore +from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key from litellm.auth_v2.security import install_auth USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_KEY = "sk-scim-writer" +NOSCOPE_KEY = "sk-no-scim-scope" -@pytest.fixture -def client() -> TestClient: + +def _principal(subject: str, scopes: list) -> Principal: + return Principal( + principal_type=PrincipalType.HUMAN, + subject=subject, + auth_method=AuthMethod.API_KEY, + scopes=scopes, + ) + + +def _app() -> FastAPI: app = FastAPI() install_auth( app, AuthConfig(), - InMemoryIdentityStore(), + InMemoryIdentityStore( + api_keys={ + _hash_api_key(SCIM_KEY): _principal("scim-writer", ["scim:write"]), + _hash_api_key(NOSCOPE_KEY): _principal("no-scope", []), + } + ), mount_scim=True, mount_oidc=False, mount_saml=False, ) - return TestClient(app) + return app + + +@pytest.fixture +def client() -> TestClient: + # SCIM routes require scim:write; authenticate every request with a scoped key + return TestClient(_app(), headers={"x-litellm-api-key": SCIM_KEY}) def _create_user(client: TestClient, user_name="alice@example.com", display="Alice"): @@ -144,3 +167,25 @@ def test_schemas_endpoint_returns_user_and_group(client): response = client.get("/scim/v2/Schemas") assert response.status_code == 200 assert response.json()["totalResults"] == 2 + + +# --------------------------------------------------------------------------- # +# SCIM routes are gated by scim:write (design section 11) +# --------------------------------------------------------------------------- # + + +def test_scim_requires_authentication(): + unauth = TestClient(_app()) + response = unauth.post( + "/scim/v2/Users", + json={"schemas": [USER_SCHEMA], "userName": "x@example.com"}, + ) + assert response.status_code == 401 + assert "WWW-Authenticate" in response.headers + + +def test_scim_requires_scim_write_scope(): + underscoped = TestClient(_app(), headers={"x-litellm-api-key": NOSCOPE_KEY}) + response = underscoped.get("/scim/v2/Users") + assert response.status_code == 403 + assert "insufficient_scope" in response.headers.get("WWW-Authenticate", "") From e309003c842a3a7373f08d9c0dce5211d6442a8b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:21:14 -0700 Subject: [PATCH 09/51] feat(auth_v2): back RBAC with Casbin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled has_any_role set check with a Casbin-backed RbacEngine (per design 03 §4). The engine wraps casbin.Enforcer over an embedded RBAC model (request sub/obj/act, g role hierarchy, keyMatch2 on obj, regexMatch on act) and a default in-code policy: platform_admin inherits org_admin/team_admin/platform_viewer, org_admin inherits org_viewer, team_admin inherits team_member; grants platform_admin /* .*, platform_admin /scim/v2/* .*, platform_viewer /* GET. Operators can replace the whole policy with a CSV via AuthConfig.casbin_policy_path (FileAdapter); no DB adapter yet. require_roles now honors the hierarchy through the enforcer's grouping (get_implicit_roles_for_user) instead of exact-match membership, so a platform_admin passes a require_roles(ORG_ADMIN) gate; signature and 403 semantics are unchanged. New require_permission(obj, act) dependency runs get_current_principal then RbacEngine.enforce and 403s on deny. The engine is built in install_auth and injectable for tests via a new rbac kwarg. Scope checks stay plain SecurityScopes (a token property, not policy). Adds casbin to the proxy extra (pure python, no native deps). --- litellm/auth_v2/__init__.py | 8 +++- litellm/auth_v2/authenticators.py | 19 ++++++--- litellm/auth_v2/config.py | 1 + litellm/auth_v2/errors.py | 4 ++ litellm/auth_v2/rbac.py | 64 +++++++++++++++++++++++++++++-- litellm/auth_v2/security.py | 26 ++++++++++--- pyproject.toml | 1 + uv.lock | 23 +++++++++++ 8 files changed, 131 insertions(+), 15 deletions(-) diff --git a/litellm/auth_v2/__init__.py b/litellm/auth_v2/__init__.py index 7373bc860c46..0e76c806a6b8 100644 --- a/litellm/auth_v2/__init__.py +++ b/litellm/auth_v2/__init__.py @@ -1,11 +1,17 @@ from .config import AuthConfig from .models import Principal -from .security import get_current_principal, install_auth, require_roles +from .security import ( + get_current_principal, + install_auth, + require_permission, + require_roles, +) __all__ = [ "Principal", "AuthConfig", "get_current_principal", "require_roles", + "require_permission", "install_auth", ] diff --git a/litellm/auth_v2/authenticators.py b/litellm/auth_v2/authenticators.py index 54bd9c15a6f8..6f22bf639910 100644 --- a/litellm/auth_v2/authenticators.py +++ b/litellm/auth_v2/authenticators.py @@ -256,12 +256,19 @@ def _verify_at_jwt(self, token: str) -> Credential: async def _introspect(self, token: str) -> Credential: config = self._introspection assert config is not None - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - str(config.introspection_endpoint), - data={"token": token}, - auth=(config.client_id, config.client_secret.get_secret_value()), - ) + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + from litellm.types.llms.custom_http import httpxSpecialProvider + + basic = base64.b64encode( + f"{config.client_id}:{config.client_secret.get_secret_value()}".encode() + ).decode() + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + response = await client.post( + str(config.introspection_endpoint), + data={"token": token}, + headers={"Authorization": f"Basic {basic}"}, + timeout=10.0, + ) if response.status_code != 200: raise errors.invalid_token("introspection failed") body = response.json() diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index 3e2acc7a78bf..4da9447b9a36 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -104,3 +104,4 @@ class AuthConfig(BaseModel): mutual_tls: MutualTlsConfig = Field(default_factory=MutualTlsConfig) network: TrustedProxyConfig = Field(default_factory=TrustedProxyConfig) saml: Optional[SamlConfig] = None + casbin_policy_path: Optional[str] = None diff --git a/litellm/auth_v2/errors.py b/litellm/auth_v2/errors.py index 8c0dbbdd1bfc..82953124496f 100644 --- a/litellm/auth_v2/errors.py +++ b/litellm/auth_v2/errors.py @@ -44,3 +44,7 @@ def insufficient_scope() -> AuthError: def forbidden_role() -> AuthError: return AuthError(403, "Insufficient role") + + +def forbidden_permission() -> AuthError: + return AuthError(403, "Forbidden") diff --git a/litellm/auth_v2/rbac.py b/litellm/auth_v2/rbac.py index ee094cac5003..2290fd671dd5 100644 --- a/litellm/auth_v2/rbac.py +++ b/litellm/auth_v2/rbac.py @@ -1,8 +1,9 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple +import casbin from fastapi.security import SecurityScopes if TYPE_CHECKING: @@ -24,5 +25,62 @@ def has_required_scopes( return set(security_scopes.scopes).issubset(set(principal.scopes)) -def has_any_role(principal: "Principal", allowed: Tuple[Role, ...]) -> bool: - return any(role in allowed for role in principal.roles) +_MODEL_TEXT = """ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act) +""" + +_DEFAULT_GROUPING: List[Tuple[str, str]] = [ + (Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value), + (Role.PLATFORM_ADMIN.value, Role.ORG_ADMIN.value), + (Role.PLATFORM_ADMIN.value, Role.TEAM_ADMIN.value), + (Role.ORG_ADMIN.value, Role.ORG_VIEWER.value), + (Role.TEAM_ADMIN.value, Role.TEAM_MEMBER.value), +] + +_DEFAULT_POLICY: List[Tuple[str, str, str]] = [ + (Role.PLATFORM_ADMIN.value, "/*", ".*"), + (Role.PLATFORM_ADMIN.value, "/scim/v2/*", ".*"), + (Role.PLATFORM_VIEWER.value, "/*", "GET"), +] + + +class RbacEngine: + def __init__(self, policy_path: Optional[str] = None) -> None: + model = casbin.Model() + model.load_model_from_text(_MODEL_TEXT) + if policy_path: + self._enforcer = casbin.Enforcer(model, casbin.FileAdapter(policy_path)) + return + self._enforcer = casbin.Enforcer(model) + for sub, inherits in _DEFAULT_GROUPING: + self._enforcer.add_grouping_policy(sub, inherits) + for rule in _DEFAULT_POLICY: + self._enforcer.add_policy(*rule) + + def enforce(self, principal: "Principal", obj: str, act: str) -> bool: + return any( + self._enforcer.enforce(role.value, obj, act) for role in principal.roles + ) + + def has_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: + allowed_values = {role.value for role in allowed} + for role in principal.roles: + if role.value in allowed_values: + return True + implicit = set(self._enforcer.get_implicit_roles_for_user(role.value)) + if allowed_values & implicit: + return True + return False diff --git a/litellm/auth_v2/security.py b/litellm/auth_v2/security.py index 7dd2721a3fb0..7d7099553814 100644 --- a/litellm/auth_v2/security.py +++ b/litellm/auth_v2/security.py @@ -1,7 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Annotated, Callable, List +from dataclasses import dataclass, field +from typing import Annotated, Callable, List, Optional from fastapi import FastAPI, Request, Security from fastapi.security import SecurityScopes @@ -11,7 +11,7 @@ from .config import AuthConfig from .models import Principal from .network import resolve_network_context -from .rbac import Role, has_any_role, has_required_scopes +from .rbac import RbacEngine, Role, has_required_scopes from .resolver import IdentityResolver @@ -20,6 +20,7 @@ class AuthContext: config: AuthConfig authenticators: List[Authenticator] resolver: IdentityResolver + rbac: RbacEngine = field(default_factory=RbacEngine) def install_auth( @@ -27,6 +28,7 @@ def install_auth( config: AuthConfig, resolver: IdentityResolver, *, + rbac: Optional[RbacEngine] = None, mount_scim: bool = True, mount_oidc: bool = True, mount_saml: bool = True, @@ -40,7 +42,8 @@ def install_auth( this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both. """ - ctx = AuthContext(config, build_authenticators(config), resolver) + engine = rbac if rbac is not None else RbacEngine(config.casbin_policy_path) + ctx = AuthContext(config, build_authenticators(config), resolver, engine) app.state.auth_v2 = ctx if mount_scim: from .scim import build_scim_router @@ -96,10 +99,23 @@ async def get_current_principal( def require_roles(*allowed: Role) -> Callable[..., object]: async def dependency( + request: Request, principal: Annotated[Principal, Security(get_current_principal)], ) -> Principal: - if not has_any_role(principal, allowed): + if not _ctx(request).rbac.has_role(principal, allowed): raise errors.forbidden_role() return principal return dependency + + +def require_permission(obj: str, act: str) -> Callable[..., object]: + async def dependency( + request: Request, + principal: Annotated[Principal, Security(get_current_principal)], + ) -> Principal: + if not _ctx(request).rbac.enforce(principal, obj, act): + raise errors.forbidden_permission() + return principal + + return dependency diff --git a/pyproject.toml b/pyproject.toml index d388c29ab335..478390dee9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ proxy = [ "PyJWT[crypto]>=2.13.0,<3.0", "Authlib>=1.6.0,<2.0", "scim2-models>=0.6.0,<1.0", + "casbin>=1.36.0,<2.0", "pysaml2>=7.5.0,<8.0", # pysaml2 pulls pyOpenSSL transitively without pinning it; force a floor that # supports cryptography 46 (older pyOpenSSL caps cryptography below 46 and diff --git a/uv.lock b/uv.lock index 4f3e18d573b3..040b3d65b4a4 100644 --- a/uv.lock +++ b/uv.lock @@ -668,6 +668,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, ] +[[package]] +name = "casbin" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simpleeval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/df/ff2aa55cf0d7c14622ce4f9252cdc34c828c81d4213965d73207ac5434ae/casbin-1.43.0.tar.gz", hash = "sha256:d2e90ce8e72f912877851e94d37999f32c558c6ba7aba0437d483275262e86e0", size = 425727, upload-time = "2025-05-10T06:57:18.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/07/facef6abd81378e6153b757dc1848621675d971fbc88ebb5d182ebc1c37f/casbin-1.43.0-py3-none-any.whl", hash = "sha256:63a3d1228870250e859ccd94133fe478821093f71dd37f05e0baa0c6fea26623", size = 475059, upload-time = "2025-05-10T06:57:16.89Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -3371,6 +3383,7 @@ proxy = [ { name = "azure-storage-blob" }, { name = "backoff" }, { name = "boto3" }, + { name = "casbin" }, { name = "cryptography" }, { name = "fastapi" }, { name = "fastapi-sso" }, @@ -3533,6 +3546,7 @@ requires-dist = [ { name = "azure-storage-file-datalake", marker = "extra == 'proxy-runtime'", specifier = ">=12.20.0,<13.0" }, { name = "backoff", marker = "extra == 'proxy'", specifier = ">=2.2.1,<3.0" }, { name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" }, + { name = "casbin", marker = "extra == 'proxy'", specifier = ">=1.36.0,<2.0" }, { name = "click", specifier = ">=8.0.0,<9.0" }, { name = "cryptography", marker = "extra == 'proxy'", specifier = ">=46.0.7,<47.0" }, { name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=2.19.0,<3.0" }, @@ -7160,6 +7174,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simpleeval" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/9d/e7c9309940794dd3073cba2e5101df5874d84243595ce63b1e1c8f9b9c76/simpleeval-1.0.7.tar.gz", hash = "sha256:1e10e5f9fec597814444e20c0892ed15162fa214c8a88f434b5b077cf2fef85b", size = 30250, upload-time = "2026-03-16T10:53:03.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2f/f32aa85591882378bb43caa09363f3ed97df399369a5144c7f19f2275bc0/simpleeval-1.0.7-py3-none-any.whl", hash = "sha256:97ac271bfd8f2af9e7b9a36ceea67617f26fa873f9d5ae1922f64d4c1442534b", size = 18792, upload-time = "2026-03-16T10:53:02.103Z" }, +] + [[package]] name = "six" version = "1.17.0" From 3dc660a1353e7ef286f4a050e9c46d0127730d2b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:25:48 -0700 Subject: [PATCH 10/51] test(auth_v2): cover OAuth2 token introspection over the cached async client --- .../auth_v2/test_authenticators.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_litellm/auth_v2/test_authenticators.py b/tests/test_litellm/auth_v2/test_authenticators.py index ab05e50a896c..791c3f07f749 100644 --- a/tests/test_litellm/auth_v2/test_authenticators.py +++ b/tests/test_litellm/auth_v2/test_authenticators.py @@ -2,6 +2,7 @@ import base64 from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -19,6 +20,7 @@ AuthConfig, HttpBasicConfig, MutualTlsConfig, + OAuth2IntrospectionConfig, OidcProviderConfig, ) from litellm.auth_v2.errors import AuthError @@ -237,6 +239,75 @@ async def test_oauth2_no_bearer_returns_none(rsa_keypair): assert await _oauth2(public_key).authenticate(make_request()) is None +def _introspecting_oauth2() -> OAuth2Authenticator: + return OAuth2Authenticator( + [], + introspection=OAuth2IntrospectionConfig( + introspection_endpoint="https://idp.example.com/introspect", + client_id="rs-client", + client_secret="rs-secret", + subject_field="sub", + ), + ) + + +def _mock_introspection_post(status_code: int, body: dict) -> AsyncMock: + response = MagicMock() + response.status_code = status_code + response.json.return_value = body + handler = MagicMock() + handler.post = AsyncMock(return_value=response) + factory = MagicMock(return_value=handler) + return factory + + +async def test_oauth2_opaque_token_introspects_active_to_credential(): + factory = _mock_introspection_post( + 200, + {"active": True, "sub": "svc-9", "scope": "models:read tools:run", "aud": "rs"}, + ) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + credential = await _introspecting_oauth2().authenticate(request) + + assert credential is not None + assert credential.method == AuthMethod.OAUTH2_INTROSPECTION + assert credential.subject == "svc-9" + assert credential.scopes == ["models:read", "tools:run"] + assert credential.audience == ["rs"] + + handler = factory.return_value + _, kwargs = handler.post.call_args + assert kwargs["data"] == {"token": "opaque-xyz"} + expected_basic = base64.b64encode(b"rs-client:rs-secret").decode() + assert kwargs["headers"]["Authorization"] == f"Basic {expected_basic}" + assert handler.post.call_args.args[0] == "https://idp.example.com/introspect" + + +async def test_oauth2_introspection_inactive_token_raises(): + factory = _mock_introspection_post(200, {"active": False}) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2().authenticate(request) + assert exc.value.status_code == 401 + + +async def test_oauth2_introspection_non_200_raises(): + factory = _mock_introspection_post(500, {}) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2().authenticate(request) + assert exc.value.status_code == 401 + + # --------------------------------------------------------------------------- # # OidcAuthenticator # --------------------------------------------------------------------------- # From 55a332bb914ee97d25c9c02151e074bc84783a6d Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:26:19 -0700 Subject: [PATCH 11/51] test(auth_v2): cover Casbin-backed RBAC hierarchy and permissions RBAC moved to an embedded Casbin enforcer: require_roles now honors the role hierarchy and require_permission gates object/action against the policy. - rbac: RbacEngine.has_role inherits down the g-rules (platform_admin satisfies an org_admin/team_member gate, org_admin satisfies org_viewer, team_admin satisfies team_member) and never climbs (team_member fails an org_admin gate); enforce honors the default policy (platform_admin any obj/act incl keyMatch2 on /scim/v2/*, platform_viewer read-only, org_viewer no write) and an operator CSV fully replaces the in-code defaults - security: require_roles passes a higher role through a lower-role gate via the hierarchy; require_permission allows platform_admin, denies a viewer on write with detail "Forbidden", and 401s when unauthenticated; an RbacEngine injected onto the AuthContext overrides the default policy (operator CSV path) Replaces the removed has_any_role coverage. Mutation-checked: dropping the hierarchy lookup or short-circuiting enforce fails these. --- tests/test_litellm/auth_v2/test_rbac.py | 101 ++++++++++++++++++-- tests/test_litellm/auth_v2/test_security.py | 80 +++++++++++++++- 2 files changed, 171 insertions(+), 10 deletions(-) diff --git a/tests/test_litellm/auth_v2/test_rbac.py b/tests/test_litellm/auth_v2/test_rbac.py index 98232fbdc5c6..2a83a40ba069 100644 --- a/tests/test_litellm/auth_v2/test_rbac.py +++ b/tests/test_litellm/auth_v2/test_rbac.py @@ -1,9 +1,10 @@ from __future__ import annotations +import pytest from fastapi.security import SecurityScopes from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.auth_v2.rbac import Role, has_any_role, has_required_scopes +from litellm.auth_v2.rbac import RbacEngine, Role, has_required_scopes def _principal(*, scopes=None, roles=None) -> Principal: @@ -16,6 +17,11 @@ def _principal(*, scopes=None, roles=None) -> Principal: ) +# --------------------------------------------------------------------------- # +# Scopes stay a plain SecurityScopes subset check (not Casbin) +# --------------------------------------------------------------------------- # + + def test_required_scopes_is_subset_check(): principal = _principal(scopes=["models:read", "chat:write", "scim:write"]) assert has_required_scopes(SecurityScopes(["models:read"]), principal) @@ -31,15 +37,92 @@ def test_empty_required_scopes_always_passes(): assert has_required_scopes(SecurityScopes([]), _principal()) -def test_has_any_role_matches_one_of_allowed(): - principal = _principal(roles=[Role.TEAM_MEMBER, Role.ORG_VIEWER]) - assert has_any_role(principal, (Role.ORG_VIEWER, Role.PLATFORM_ADMIN)) +# --------------------------------------------------------------------------- # +# RbacEngine.has_role honors the role hierarchy (Casbin g-rules) +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def engine() -> RbacEngine: + return RbacEngine() + + +@pytest.mark.parametrize( + "held,gate", + [ + (Role.PLATFORM_ADMIN, Role.ORG_ADMIN), + (Role.PLATFORM_ADMIN, Role.ORG_VIEWER), + (Role.PLATFORM_ADMIN, Role.TEAM_ADMIN), + (Role.PLATFORM_ADMIN, Role.TEAM_MEMBER), + (Role.PLATFORM_ADMIN, Role.PLATFORM_VIEWER), + (Role.ORG_ADMIN, Role.ORG_VIEWER), + (Role.ORG_ADMIN, Role.ORG_ADMIN), # exact match + (Role.TEAM_ADMIN, Role.TEAM_MEMBER), + ], +) +def test_has_role_inherits_down_the_hierarchy(engine, held, gate): + assert engine.has_role(_principal(roles=[held]), (gate,)) + + +@pytest.mark.parametrize( + "held,gate", + [ + (Role.ORG_ADMIN, Role.TEAM_MEMBER), # sideways, no inheritance edge + (Role.TEAM_MEMBER, Role.ORG_ADMIN), # lower cannot reach higher + (Role.ORG_VIEWER, Role.ORG_ADMIN), + ], +) +def test_has_role_does_not_climb_the_hierarchy(engine, held, gate): + assert not engine.has_role(_principal(roles=[held]), (gate,)) + + +def test_has_role_false_without_roles(engine): + assert not engine.has_role(_principal(), (Role.TEAM_MEMBER,)) + + +# --------------------------------------------------------------------------- # +# RbacEngine.enforce against the default policy +# --------------------------------------------------------------------------- # -def test_has_any_role_rejects_when_no_overlap(): - principal = _principal(roles=[Role.TEAM_MEMBER]) - assert not has_any_role(principal, (Role.PLATFORM_ADMIN, Role.ORG_ADMIN)) +def test_platform_admin_enforces_any_object_and_action(engine): + assert engine.enforce(_principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "POST") + # keyMatch2: /scim/v2/* covers /scim/v2/Users + assert engine.enforce( + _principal(roles=[Role.PLATFORM_ADMIN]), "/scim/v2/Users", "DELETE" + ) + + +def test_platform_viewer_is_read_only(engine): + viewer = _principal(roles=[Role.PLATFORM_VIEWER]) + assert engine.enforce(viewer, "/anything", "GET") + assert not engine.enforce(viewer, "/anything", "POST") + + +def test_org_viewer_has_no_write_grant(engine): + assert not engine.enforce(_principal(roles=[Role.ORG_VIEWER]), "/widgets", "POST") + +def test_enforce_false_without_roles(engine): + assert not engine.enforce(_principal(), "/anything", "GET") -def test_has_any_role_false_when_principal_has_no_roles(): - assert not has_any_role(_principal(), (Role.PLATFORM_ADMIN,)) + +# --------------------------------------------------------------------------- # +# Operator CSV policy fully replaces the in-code defaults +# --------------------------------------------------------------------------- # + + +def test_csv_policy_overrides_defaults(tmp_path): + policy = tmp_path / "policy.csv" + policy.write_text("p, platform_viewer, /reports, POST\n") + engine = RbacEngine(policy_path=str(policy)) + + # the operator rule is honored + assert engine.enforce(_principal(roles=[Role.PLATFORM_VIEWER]), "/reports", "POST") + # the built-in platform_admin "/*" grant is gone, not merged + assert not engine.enforce( + _principal(roles=[Role.PLATFORM_ADMIN]), "/reports", "POST" + ) + assert not engine.enforce( + _principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "GET" + ) diff --git a/tests/test_litellm/auth_v2/test_security.py b/tests/test_litellm/auth_v2/test_security.py index f47466f2d296..e3e0e10acc09 100644 --- a/tests/test_litellm/auth_v2/test_security.py +++ b/tests/test_litellm/auth_v2/test_security.py @@ -18,11 +18,12 @@ OidcProviderConfig, ) from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.auth_v2.rbac import Role +from litellm.auth_v2.rbac import RbacEngine, Role from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key from litellm.auth_v2.security import ( AuthContext, get_current_principal, + require_permission, require_roles, ) @@ -31,6 +32,8 @@ ADMIN_KEY = "sk-admin-key" READER_KEY = "sk-reader-key" NOSCOPE_KEY = "sk-noscope-key" +PLATFORM_ADMIN_KEY = "sk-platform-admin-key" +PLATFORM_VIEWER_KEY = "sk-platform-viewer-key" def _principal(subject: str, *, scopes=None, roles=None) -> Principal: @@ -61,6 +64,12 @@ def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]: "reader-principal", scopes=["models:read"] ), _hash_api_key(NOSCOPE_KEY): _principal("noscope-principal"), + _hash_api_key(PLATFORM_ADMIN_KEY): _principal( + "platform-admin-principal", roles=[Role.PLATFORM_ADMIN] + ), + _hash_api_key(PLATFORM_VIEWER_KEY): _principal( + "platform-viewer-principal", roles=[Role.PLATFORM_VIEWER] + ), } ) ctx = AuthContext(AuthConfig(), authenticators, resolver) @@ -92,6 +101,14 @@ async def admin_route( ): return {"subject": principal.subject} + @app.post("/perm-widgets") + async def widgets_route( + principal: Annotated[ + Principal, Security(require_permission("/widgets", "POST")) + ], + ): + return {"subject": principal.subject} + return app, resolver @@ -201,3 +218,64 @@ def test_required_role_present_returns_200(client): def test_required_role_missing_returns_403(client): response = client.get("/admin", headers={"x-litellm-api-key": READER_KEY}) assert response.status_code == 403 + + +def test_required_role_honors_hierarchy(client): + # platform_admin inherits org_admin via the Casbin g-rules, so it passes a + # require_roles(ORG_ADMIN) gate without holding org_admin explicitly + response = client.get("/admin", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY}) + assert response.status_code == 200 + assert response.json()["subject"] == "platform-admin-principal" + + +# --------------------------------------------------------------------------- # +# Permission enforcement (require_permission -> RbacEngine.enforce) +# --------------------------------------------------------------------------- # + + +def test_require_permission_allows_platform_admin(client): + response = client.post( + "/perm-widgets", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY} + ) + assert response.status_code == 200 + + +def test_require_permission_denies_viewer_on_write(client): + # platform_viewer is GET-only in the default policy -> POST /widgets is denied + response = client.post( + "/perm-widgets", headers={"x-litellm-api-key": PLATFORM_VIEWER_KEY} + ) + assert response.status_code == 403 + assert response.json()["detail"] == "Forbidden" + + +def test_require_permission_unauthenticated_returns_401(client): + response = client.post("/perm-widgets") + assert response.status_code == 401 + assert "WWW-Authenticate" in response.headers + + +def test_injected_rbac_engine_overrides_default_policy(rsa_keypair, tmp_path): + # operator CSV grants only platform_viewer POST /widgets and drops the + # built-in platform_admin "/*" grant; the injected engine governs enforce + policy = tmp_path / "policy.csv" + policy.write_text("p, platform_viewer, /widgets, POST\n") + + _, public_key = rsa_keypair + app, _ = _build_app(public_key) + app.state.auth_v2.rbac = RbacEngine(policy_path=str(policy)) + client = TestClient(app) + + # viewer now passes, platform_admin (default grant removed) now fails + assert ( + client.post( + "/perm-widgets", headers={"x-litellm-api-key": PLATFORM_VIEWER_KEY} + ).status_code + == 200 + ) + assert ( + client.post( + "/perm-widgets", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY} + ).status_code + == 403 + ) From 104a5e14436709bd736eecc4af4d4548821fda37 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:28:14 -0700 Subject: [PATCH 12/51] refactor(auth_v2): move module under litellm/proxy/auth_v2 The module imports FastAPI and is proxy-only, so it belongs under litellm/proxy beside the legacy litellm/proxy/auth rather than at the top level. git mv preserves history; the package is self-contained so the relative imports are unchanged, and the scim2-models mypy override is path-independent. --- litellm/{ => proxy}/auth_v2/__init__.py | 0 litellm/{ => proxy}/auth_v2/authenticators.py | 0 litellm/{ => proxy}/auth_v2/config.py | 0 litellm/{ => proxy}/auth_v2/errors.py | 0 litellm/{ => proxy}/auth_v2/models.py | 0 litellm/{ => proxy}/auth_v2/network.py | 0 litellm/{ => proxy}/auth_v2/oidc.py | 0 litellm/{ => proxy}/auth_v2/rbac.py | 0 litellm/{ => proxy}/auth_v2/resolver.py | 0 litellm/{ => proxy}/auth_v2/saml.py | 0 litellm/{ => proxy}/auth_v2/scim.py | 0 litellm/{ => proxy}/auth_v2/security.py | 0 12 files changed, 0 insertions(+), 0 deletions(-) rename litellm/{ => proxy}/auth_v2/__init__.py (100%) rename litellm/{ => proxy}/auth_v2/authenticators.py (100%) rename litellm/{ => proxy}/auth_v2/config.py (100%) rename litellm/{ => proxy}/auth_v2/errors.py (100%) rename litellm/{ => proxy}/auth_v2/models.py (100%) rename litellm/{ => proxy}/auth_v2/network.py (100%) rename litellm/{ => proxy}/auth_v2/oidc.py (100%) rename litellm/{ => proxy}/auth_v2/rbac.py (100%) rename litellm/{ => proxy}/auth_v2/resolver.py (100%) rename litellm/{ => proxy}/auth_v2/saml.py (100%) rename litellm/{ => proxy}/auth_v2/scim.py (100%) rename litellm/{ => proxy}/auth_v2/security.py (100%) diff --git a/litellm/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py similarity index 100% rename from litellm/auth_v2/__init__.py rename to litellm/proxy/auth_v2/__init__.py diff --git a/litellm/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py similarity index 100% rename from litellm/auth_v2/authenticators.py rename to litellm/proxy/auth_v2/authenticators.py diff --git a/litellm/auth_v2/config.py b/litellm/proxy/auth_v2/config.py similarity index 100% rename from litellm/auth_v2/config.py rename to litellm/proxy/auth_v2/config.py diff --git a/litellm/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py similarity index 100% rename from litellm/auth_v2/errors.py rename to litellm/proxy/auth_v2/errors.py diff --git a/litellm/auth_v2/models.py b/litellm/proxy/auth_v2/models.py similarity index 100% rename from litellm/auth_v2/models.py rename to litellm/proxy/auth_v2/models.py diff --git a/litellm/auth_v2/network.py b/litellm/proxy/auth_v2/network.py similarity index 100% rename from litellm/auth_v2/network.py rename to litellm/proxy/auth_v2/network.py diff --git a/litellm/auth_v2/oidc.py b/litellm/proxy/auth_v2/oidc.py similarity index 100% rename from litellm/auth_v2/oidc.py rename to litellm/proxy/auth_v2/oidc.py diff --git a/litellm/auth_v2/rbac.py b/litellm/proxy/auth_v2/rbac.py similarity index 100% rename from litellm/auth_v2/rbac.py rename to litellm/proxy/auth_v2/rbac.py diff --git a/litellm/auth_v2/resolver.py b/litellm/proxy/auth_v2/resolver.py similarity index 100% rename from litellm/auth_v2/resolver.py rename to litellm/proxy/auth_v2/resolver.py diff --git a/litellm/auth_v2/saml.py b/litellm/proxy/auth_v2/saml.py similarity index 100% rename from litellm/auth_v2/saml.py rename to litellm/proxy/auth_v2/saml.py diff --git a/litellm/auth_v2/scim.py b/litellm/proxy/auth_v2/scim.py similarity index 100% rename from litellm/auth_v2/scim.py rename to litellm/proxy/auth_v2/scim.py diff --git a/litellm/auth_v2/security.py b/litellm/proxy/auth_v2/security.py similarity index 100% rename from litellm/auth_v2/security.py rename to litellm/proxy/auth_v2/security.py From 2dd336a79523af81c2f327453319b7c269a6066f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:32:12 -0700 Subject: [PATCH 13/51] test(auth_v2): move tests under proxy/ to mirror the module relocation The module moved from litellm/auth_v2 to litellm/proxy/auth_v2 (commit 104a5e1), so the mirrored tests move from tests/test_litellm/auth_v2 to tests/test_litellm/proxy/auth_v2 and their imports switch to litellm.proxy.auth_v2. No behavior change; 134 tests still pass at the new path. --- .../{ => proxy}/auth_v2/auth_v2_helpers.py | 0 .../{ => proxy}/auth_v2/conftest.py | 4 ++-- .../auth_v2/test_authenticators.py | 8 ++++---- .../{ => proxy}/auth_v2/test_config.py | 2 +- .../{ => proxy}/auth_v2/test_models.py | 4 ++-- .../{ => proxy}/auth_v2/test_network.py | 4 ++-- .../{ => proxy}/auth_v2/test_oidc.py | 6 +++--- .../{ => proxy}/auth_v2/test_rbac.py | 4 ++-- .../{ => proxy}/auth_v2/test_resolver.py | 8 ++++---- .../{ => proxy}/auth_v2/test_saml.py | 20 +++++++++---------- .../{ => proxy}/auth_v2/test_scim.py | 8 ++++---- .../{ => proxy}/auth_v2/test_security.py | 12 +++++------ 12 files changed, 40 insertions(+), 40 deletions(-) rename tests/test_litellm/{ => proxy}/auth_v2/auth_v2_helpers.py (100%) rename tests/test_litellm/{ => proxy}/auth_v2/conftest.py (91%) rename tests/test_litellm/{ => proxy}/auth_v2/test_authenticators.py (98%) rename tests/test_litellm/{ => proxy}/auth_v2/test_config.py (97%) rename tests/test_litellm/{ => proxy}/auth_v2/test_models.py (96%) rename tests/test_litellm/{ => proxy}/auth_v2/test_network.py (94%) rename tests/test_litellm/{ => proxy}/auth_v2/test_oidc.py (88%) rename tests/test_litellm/{ => proxy}/auth_v2/test_rbac.py (96%) rename tests/test_litellm/{ => proxy}/auth_v2/test_resolver.py (94%) rename tests/test_litellm/{ => proxy}/auth_v2/test_saml.py (95%) rename tests/test_litellm/{ => proxy}/auth_v2/test_scim.py (95%) rename tests/test_litellm/{ => proxy}/auth_v2/test_security.py (96%) diff --git a/tests/test_litellm/auth_v2/auth_v2_helpers.py b/tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py similarity index 100% rename from tests/test_litellm/auth_v2/auth_v2_helpers.py rename to tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py diff --git a/tests/test_litellm/auth_v2/conftest.py b/tests/test_litellm/proxy/auth_v2/conftest.py similarity index 91% rename from tests/test_litellm/auth_v2/conftest.py rename to tests/test_litellm/proxy/auth_v2/conftest.py index 3a1b8af831ce..fe4df1984e04 100644 --- a/tests/test_litellm/auth_v2/conftest.py +++ b/tests/test_litellm/proxy/auth_v2/conftest.py @@ -6,8 +6,8 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from litellm.auth_v2.authenticators import JwtVerifier -from litellm.auth_v2.config import OidcProviderConfig +from litellm.proxy.auth_v2.authenticators import JwtVerifier +from litellm.proxy.auth_v2.config import OidcProviderConfig from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient, TokenFactory diff --git a/tests/test_litellm/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py similarity index 98% rename from tests/test_litellm/auth_v2/test_authenticators.py rename to tests/test_litellm/proxy/auth_v2/test_authenticators.py index 791c3f07f749..78a788430e69 100644 --- a/tests/test_litellm/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -6,7 +6,7 @@ import pytest -from litellm.auth_v2.authenticators import ( +from litellm.proxy.auth_v2.authenticators import ( ApiKeyAuthenticator, HttpAuthenticator, JwtVerifier, @@ -15,7 +15,7 @@ OidcAuthenticator, build_authenticators, ) -from litellm.auth_v2.config import ( +from litellm.proxy.auth_v2.config import ( ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, @@ -23,8 +23,8 @@ OAuth2IntrospectionConfig, OidcProviderConfig, ) -from litellm.auth_v2.errors import AuthError -from litellm.auth_v2.models import AuthMethod, SecuritySchemeType +from litellm.proxy.auth_v2.errors import AuthError +from litellm.proxy.auth_v2.models import AuthMethod, SecuritySchemeType from auth_v2_helpers import ( TEST_AUDIENCE, diff --git a/tests/test_litellm/auth_v2/test_config.py b/tests/test_litellm/proxy/auth_v2/test_config.py similarity index 97% rename from tests/test_litellm/auth_v2/test_config.py rename to tests/test_litellm/proxy/auth_v2/test_config.py index 8050ef3eba0e..86da462fff76 100644 --- a/tests/test_litellm/auth_v2/test_config.py +++ b/tests/test_litellm/proxy/auth_v2/test_config.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from litellm.auth_v2.config import ( +from litellm.proxy.auth_v2.config import ( OAuth2IntrospectionConfig, OidcProviderConfig, SamlConfig, diff --git a/tests/test_litellm/auth_v2/test_models.py b/tests/test_litellm/proxy/auth_v2/test_models.py similarity index 96% rename from tests/test_litellm/auth_v2/test_models.py rename to tests/test_litellm/proxy/auth_v2/test_models.py index f77bf0f3cbcf..4c0a5e74f9df 100644 --- a/tests/test_litellm/auth_v2/test_models.py +++ b/tests/test_litellm/proxy/auth_v2/test_models.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from litellm.auth_v2.models import ( +from litellm.proxy.auth_v2.models import ( AuthMethod, Credential, Principal, @@ -13,7 +13,7 @@ TeamRole, UserIdentity, ) -from litellm.auth_v2.rbac import Role +from litellm.proxy.auth_v2.rbac import Role def _credential() -> Credential: diff --git a/tests/test_litellm/auth_v2/test_network.py b/tests/test_litellm/proxy/auth_v2/test_network.py similarity index 94% rename from tests/test_litellm/auth_v2/test_network.py rename to tests/test_litellm/proxy/auth_v2/test_network.py index 968130d2d6ad..e0804524670c 100644 --- a/tests/test_litellm/auth_v2/test_network.py +++ b/tests/test_litellm/proxy/auth_v2/test_network.py @@ -1,7 +1,7 @@ from __future__ import annotations -from litellm.auth_v2.config import TrustedProxyConfig -from litellm.auth_v2.network import resolve_client_ip, resolve_network_context +from litellm.proxy.auth_v2.config import TrustedProxyConfig +from litellm.proxy.auth_v2.network import resolve_client_ip, resolve_network_context from auth_v2_helpers import make_request diff --git a/tests/test_litellm/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py similarity index 88% rename from tests/test_litellm/auth_v2/test_oidc.py rename to tests/test_litellm/proxy/auth_v2/test_oidc.py index c2b04c571f22..52218423d6ec 100644 --- a/tests/test_litellm/auth_v2/test_oidc.py +++ b/tests/test_litellm/proxy/auth_v2/test_oidc.py @@ -1,8 +1,8 @@ from __future__ import annotations -from litellm.auth_v2.config import OidcProviderConfig -from litellm.auth_v2.oidc import _provider_key, _user_from_userinfo -from litellm.auth_v2.resolver import InMemoryIdentityStore +from litellm.proxy.auth_v2.config import OidcProviderConfig +from litellm.proxy.auth_v2.oidc import _provider_key, _user_from_userinfo +from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore def test_userinfo_maps_standard_claims_to_scim_user(): diff --git a/tests/test_litellm/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py similarity index 96% rename from tests/test_litellm/auth_v2/test_rbac.py rename to tests/test_litellm/proxy/auth_v2/test_rbac.py index 2a83a40ba069..d16432b9fb30 100644 --- a/tests/test_litellm/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -3,8 +3,8 @@ import pytest from fastapi.security import SecurityScopes -from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.auth_v2.rbac import RbacEngine, Role, has_required_scopes +from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.proxy.auth_v2.rbac import RbacEngine, Role, has_required_scopes def _principal(*, scopes=None, roles=None) -> Principal: diff --git a/tests/test_litellm/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py similarity index 94% rename from tests/test_litellm/auth_v2/test_resolver.py rename to tests/test_litellm/proxy/auth_v2/test_resolver.py index 6526f314819b..3c534b6a4d6a 100644 --- a/tests/test_litellm/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -2,8 +2,8 @@ import pytest -from litellm.auth_v2.errors import AuthError -from litellm.auth_v2.models import ( +from litellm.proxy.auth_v2.errors import AuthError +from litellm.proxy.auth_v2.models import ( AuthMethod, ClientCertificate, Credential, @@ -11,8 +11,8 @@ PrincipalType, SecuritySchemeType, ) -from litellm.auth_v2.rbac import Role -from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.proxy.auth_v2.rbac import Role +from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key def _api_key_credential(raw: str) -> Credential: diff --git a/tests/test_litellm/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py similarity index 95% rename from tests/test_litellm/auth_v2/test_saml.py rename to tests/test_litellm/proxy/auth_v2/test_saml.py index a4f9f15b68d7..83a13c72654c 100644 --- a/tests/test_litellm/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -94,7 +94,7 @@ def saml_env(tmp_path: Path) -> SamlEnv: from saml2.saml import NAMEID_FORMAT_EMAILADDRESS from saml2.server import Server - from litellm.auth_v2.config import SamlConfig + from litellm.proxy.auth_v2.config import SamlConfig idp_key, idp_cert = _gen_cert(tmp_path, "idp") sp_key, sp_cert = _gen_cert(tmp_path, "sp") @@ -155,10 +155,10 @@ def saml_env(tmp_path: Path) -> SamlEnv: def _build_app(saml_env: SamlEnv): - from litellm.auth_v2.config import AuthConfig - from litellm.auth_v2.models import Principal - from litellm.auth_v2.resolver import InMemoryIdentityStore - from litellm.auth_v2.security import get_current_principal, install_auth + from litellm.proxy.auth_v2.config import AuthConfig + from litellm.proxy.auth_v2.models import Principal + from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore + from litellm.proxy.auth_v2.security import get_current_principal, install_auth app = FastAPI() store = InMemoryIdentityStore() @@ -354,8 +354,8 @@ def test_login_rejects_open_redirect_next(saml_env): def test_map_attributes_applies_attribute_map(): - from litellm.auth_v2.config import DEFAULT_SAML_ATTRIBUTE_MAP - from litellm.auth_v2.saml import _map_attributes + from litellm.proxy.auth_v2.config import DEFAULT_SAML_ATTRIBUTE_MAP + from litellm.proxy.auth_v2.saml import _map_attributes ava = { "email": ["alice@example.com"], @@ -371,7 +371,7 @@ def test_map_attributes_applies_attribute_map(): def test_user_from_mapped_builds_name_and_email(): - from litellm.auth_v2.saml import _user_from_mapped + from litellm.proxy.auth_v2.saml import _user_from_mapped user = _user_from_mapped( "alice@example.com", @@ -398,7 +398,7 @@ def test_user_from_mapped_builds_name_and_email(): ], ) def test_safe_relay_state_blocks_open_redirects(candidate, expected): - from litellm.auth_v2.saml import _safe_relay_state + from litellm.proxy.auth_v2.saml import _safe_relay_state assert _safe_relay_state(candidate, "/") == expected @@ -412,6 +412,6 @@ def test_safe_relay_state_blocks_open_redirects(candidate, expected): ], ) def test_metadata_source_classifies_input(metadata, expected_key): - from litellm.auth_v2.saml import _metadata_source + from litellm.proxy.auth_v2.saml import _metadata_source assert expected_key in _metadata_source(metadata) diff --git a/tests/test_litellm/auth_v2/test_scim.py b/tests/test_litellm/proxy/auth_v2/test_scim.py similarity index 95% rename from tests/test_litellm/auth_v2/test_scim.py rename to tests/test_litellm/proxy/auth_v2/test_scim.py index b58529161bd9..06edb468b242 100644 --- a/tests/test_litellm/auth_v2/test_scim.py +++ b/tests/test_litellm/proxy/auth_v2/test_scim.py @@ -4,10 +4,10 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from litellm.auth_v2.config import AuthConfig -from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key -from litellm.auth_v2.security import install_auth +from litellm.proxy.auth_v2.config import AuthConfig +from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.proxy.auth_v2.security import install_auth USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" diff --git a/tests/test_litellm/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py similarity index 96% rename from tests/test_litellm/auth_v2/test_security.py rename to tests/test_litellm/proxy/auth_v2/test_security.py index e3e0e10acc09..f9f92c1e0ed9 100644 --- a/tests/test_litellm/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -6,21 +6,21 @@ from fastapi import FastAPI, Security from fastapi.testclient import TestClient -from litellm.auth_v2.authenticators import ( +from litellm.proxy.auth_v2.authenticators import ( ApiKeyAuthenticator, HttpAuthenticator, JwtVerifier, ) -from litellm.auth_v2.config import ( +from litellm.proxy.auth_v2.config import ( ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, OidcProviderConfig, ) -from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.auth_v2.rbac import RbacEngine, Role -from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key -from litellm.auth_v2.security import ( +from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType +from litellm.proxy.auth_v2.rbac import RbacEngine, Role +from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.proxy.auth_v2.security import ( AuthContext, get_current_principal, require_permission, From 71a189bf6559aab125100580abc4b8690a128b31 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:34:10 -0700 Subject: [PATCH 14/51] fix(auth_v2): harden HTTP basic, SAML sessions, and JWKS fetch Address Greptile security findings in the authenticator, SAML and config layers: - HTTP Basic accepted any password and copied the cleartext password into Principal.claims. Verify the password against an injected BasicAuthVerifier (InMemoryBasicAuthStore holds username -> salted sha256, constant-time compared with hmac.compare_digest) and stop putting the password in the credential; basic with no configured verifier now rejects rather than trusting the caller. - SAML session cookie gains the Secure flag (httponly and samesite=lax already set), gated by SamlConfig.cookie_secure. - SAML session store gains TTL expiry and max-size eviction (SamlConfig.session_ttl_seconds / session_max_size) so it can no longer grow unbounded or hand out stale sessions. - JWKS signing-key lookup ran synchronously inside the async request path and blocked the event loop on a cache miss; run the JWT verify off-loop via starlette run_in_threadpool on the http-bearer, oauth2 at+jwt and oidc paths. --- litellm/proxy/auth_v2/authenticators.py | 87 ++++++++++++++++++++----- litellm/proxy/auth_v2/config.py | 3 + litellm/proxy/auth_v2/saml.py | 37 +++++++++-- litellm/proxy/auth_v2/security.py | 15 ++++- pyproject.toml | 2 +- 5 files changed, 117 insertions(+), 27 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index 6f22bf639910..db8273bea831 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -2,6 +2,10 @@ import base64 import binascii +import functools +import hashlib +import hmac +import secrets from typing import Any, Dict, List, Optional, Protocol, runtime_checkable import httpx @@ -9,6 +13,7 @@ from fastapi import Request from jwt import PyJWKClient from jwt import decode as jwt_decode +from starlette.concurrency import run_in_threadpool from . import errors from .config import ( @@ -86,6 +91,35 @@ def _credential_from_claims( ) +@runtime_checkable +class BasicAuthVerifier(Protocol): + def verify(self, username: str, password: str) -> bool: ... + + +def hash_basic_password(password: str, salt: Optional[str] = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.sha256(bytes.fromhex(salt) + password.encode()).hexdigest() + return f"{salt}${digest}" + + +class InMemoryBasicAuthStore: + def __init__(self, credentials: Dict[str, str]) -> None: + self._credentials = credentials + + def verify(self, username: str, password: str) -> bool: + stored = self._credentials.get(username) + if stored is None: + return False + salt, _, expected = stored.partition("$") + try: + candidate = hashlib.sha256( + bytes.fromhex(salt) + password.encode() + ).hexdigest() + except ValueError: + return False + return hmac.compare_digest(candidate, expected) + + class JwtVerifier: def __init__( self, @@ -134,6 +168,14 @@ def verify( raise errors.invalid_token(str(exc)) from exc +async def _verify_jwt_off_loop( + verifier: JwtVerifier, token: str, *, require_at_jwt: Optional[bool] = None +) -> Dict[str, Any]: + return await run_in_threadpool( + functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt) + ) + + def _select_verifier(token: str, verifiers: List[JwtVerifier]) -> Optional[JwtVerifier]: if not verifiers: return None @@ -173,10 +215,14 @@ class HttpAuthenticator: scheme = SecuritySchemeType.HTTP def __init__( - self, basic: HttpBasicConfig, jwt_verifiers: List[JwtVerifier] + self, + basic: HttpBasicConfig, + jwt_verifiers: List[JwtVerifier], + basic_verifier: Optional[BasicAuthVerifier] = None, ) -> None: self._basic = basic self._verifiers = jwt_verifiers + self._basic_verifier = basic_verifier async def authenticate(self, request: Request) -> Optional[Credential]: header = request.headers.get("authorization") @@ -185,35 +231,38 @@ async def authenticate(self, request: Request) -> Optional[Credential]: scheme, _, value = header.partition(" ") scheme_lower = scheme.lower() if scheme_lower == "bearer" and value: - return self._verify_bearer(value) + return await self._verify_bearer(value) if scheme_lower == "basic" and self._basic.enabled and value: return self._verify_basic(value) return None - def _verify_bearer(self, token: str) -> Credential: + async def _verify_bearer(self, token: str) -> Credential: verifier = _select_verifier(token, self._verifiers) if verifier is None: raise errors.invalid_token("no issuer match") - claims = verifier.verify(token) + claims = await _verify_jwt_off_loop(verifier, token) return _credential_from_claims( self.scheme, AuthMethod.BEARER_JWT, token, claims ) def _verify_basic(self, value: str) -> Credential: + challenge = errors.basic_challenge(self._basic.realm) try: decoded = base64.b64decode(value).decode("utf-8") except (binascii.Error, UnicodeDecodeError) as exc: - raise errors.unauthenticated( - errors.basic_challenge(self._basic.realm) - ) from exc - username, _, password = decoded.partition(":") - if not username: - raise errors.unauthenticated(errors.basic_challenge(self._basic.realm)) + raise errors.unauthenticated(challenge) from exc + username, separator, password = decoded.partition(":") + if ( + not username + or separator != ":" + or self._basic_verifier is None + or not self._basic_verifier.verify(username, password) + ): + raise errors.unauthenticated(challenge) return Credential( scheme=self.scheme, method=AuthMethod.HTTP_BASIC, subject=username, - claims={"_basic_password": password}, ) def challenge(self) -> str: @@ -239,16 +288,16 @@ async def authenticate(self, request: Request) -> Optional[Credential]: if token is None: return None if _looks_like_jwt(token): - return self._verify_at_jwt(token) + return await self._verify_at_jwt(token) if self._introspection is not None: return await self._introspect(token) raise errors.invalid_token() - def _verify_at_jwt(self, token: str) -> Credential: + async def _verify_at_jwt(self, token: str) -> Credential: verifier = _select_verifier(token, self._verifiers) if verifier is None: raise errors.invalid_token("no issuer match") - claims = verifier.verify(token, require_at_jwt=True) + claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=True) return _credential_from_claims( self.scheme, AuthMethod.BEARER_JWT, token, claims ) @@ -301,7 +350,7 @@ async def authenticate(self, request: Request) -> Optional[Credential]: verifier = _select_verifier(token, self._verifiers) if verifier is None: raise errors.invalid_token("no issuer match") - claims = verifier.verify(token) + claims = await _verify_jwt_off_loop(verifier, token) return _credential_from_claims(self.scheme, AuthMethod.OIDC, token, claims) def challenge(self) -> str: @@ -337,12 +386,16 @@ def challenge(self) -> str: return "" -def build_authenticators(config: AuthConfig) -> List[Authenticator]: +def build_authenticators( + config: AuthConfig, *, basic_verifier: Optional[BasicAuthVerifier] = None +) -> List[Authenticator]: verifiers = [JwtVerifier(provider) for provider in config.oidc_providers] by_scheme: Dict[SecuritySchemeType, Authenticator] = {} if config.api_key is not None: by_scheme[SecuritySchemeType.API_KEY] = ApiKeyAuthenticator(config.api_key) - by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(config.http_basic, verifiers) + by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator( + config.http_basic, verifiers, basic_verifier + ) by_scheme[SecuritySchemeType.OPENID_CONNECT] = OidcAuthenticator(verifiers) by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator( verifiers, config.oauth2_introspection diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index 4da9447b9a36..738f1a03fcbf 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -68,6 +68,9 @@ class SamlConfig(BaseModel): sp_cert_file: Optional[str] = None allow_unsolicited: bool = True session_cookie: str = "saml_session" + cookie_secure: bool = True + session_ttl_seconds: int = 3600 + session_max_size: int = 10000 default_redirect_path: str = "/" xmlsec_binary: Optional[str] = None attribute_map: Dict[str, str] = Field( diff --git a/litellm/proxy/auth_v2/saml.py b/litellm/proxy/auth_v2/saml.py index 9f3d1773fed4..b46ff2f2bded 100644 --- a/litellm/proxy/auth_v2/saml.py +++ b/litellm/proxy/auth_v2/saml.py @@ -1,7 +1,8 @@ from __future__ import annotations import secrets -from typing import Any, Dict, List, Optional +import time +from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse, Response @@ -138,20 +139,40 @@ def build_sp_client(config: SamlConfig) -> Saml2Client: class SamlSessionStore: - def __init__(self) -> None: - self._sessions: Dict[str, Dict[str, Any]] = {} + def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: + self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {} self.outstanding: Dict[str, str] = {} + self._ttl = ttl_seconds + self._max_size = max_size def remember_request(self, request_id: str, relay_state: str = "/") -> None: self.outstanding[request_id] = relay_state def create_session(self, identity: Dict[str, Any]) -> str: + now = time.time() + self._evict(now) session_id = secrets.token_urlsafe(32) - self._sessions[session_id] = identity + self._sessions[session_id] = (now + self._ttl, identity) return session_id def get(self, session_id: str) -> Optional[Dict[str, Any]]: - return self._sessions.get(session_id) + entry = self._sessions.get(session_id) + if entry is None: + return None + expires_at, identity = entry + if expires_at < time.time(): + self._sessions.pop(session_id, None) + return None + return identity + + def _evict(self, now: float) -> None: + for key in [k for k, (exp, _) in self._sessions.items() if exp < now]: + self._sessions.pop(key, None) + overflow = len(self._sessions) - self._max_size + 1 + if overflow > 0: + oldest = sorted(self._sessions, key=lambda k: self._sessions[k][0]) + for key in oldest[:overflow]: + self._sessions.pop(key, None) class SamlAuthenticator: @@ -248,7 +269,11 @@ async def assertion_consumer_service(request: Request) -> Response: ) response = RedirectResponse(target, status_code=303) response.set_cookie( - config.session_cookie, session_id, httponly=True, samesite="lax" + config.session_cookie, + session_id, + httponly=True, + samesite="lax", + secure=config.cookie_secure, ) return response diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index 7d7099553814..c5858563df42 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -7,7 +7,7 @@ from fastapi.security import SecurityScopes from . import errors -from .authenticators import Authenticator, build_authenticators +from .authenticators import Authenticator, BasicAuthVerifier, build_authenticators from .config import AuthConfig from .models import Principal from .network import resolve_network_context @@ -29,6 +29,7 @@ def install_auth( resolver: IdentityResolver, *, rbac: Optional[RbacEngine] = None, + basic_verifier: Optional[BasicAuthVerifier] = None, mount_scim: bool = True, mount_oidc: bool = True, mount_saml: bool = True, @@ -43,7 +44,12 @@ def install_auth( rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both. """ engine = rbac if rbac is not None else RbacEngine(config.casbin_policy_path) - ctx = AuthContext(config, build_authenticators(config), resolver, engine) + ctx = AuthContext( + config, + build_authenticators(config, basic_verifier=basic_verifier), + resolver, + engine, + ) app.state.auth_v2 = ctx if mount_scim: from .scim import build_scim_router @@ -56,7 +62,10 @@ def install_auth( if mount_saml and config.saml is not None and config.saml.enabled: from .saml import SamlAuthenticator, SamlSessionStore, build_saml_router - session_store = SamlSessionStore() + session_store = SamlSessionStore( + ttl_seconds=config.saml.session_ttl_seconds, + max_size=config.saml.session_max_size, + ) ctx.authenticators.append(SamlAuthenticator(config.saml, session_store)) app.include_router(build_saml_router(config.saml, session_store)) return ctx diff --git a/pyproject.toml b/pyproject.toml index 478390dee9d8..418e3d6e7194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -287,7 +287,7 @@ plugins = "pydantic.mypy" # scim2-models ships py.typed, but its generic, alias-driven SCIM models report # phantom call-arg errors under mypy though they work at runtime. Treat the -# library as untyped at the boundary; litellm/auth_v2 is its only consumer. +# library as untyped at the boundary; litellm/proxy/auth_v2 is its only consumer. # CI runs mypy from litellm/ against litellm/mypy.ini, which carries the same # override; this block keeps root-level mypy runs consistent. [[tool.mypy.overrides]] From ca896ac073bca7bc2261bbe6d05128a3f0d0e620 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:34:37 -0700 Subject: [PATCH 15/51] fix(auth_v2): make SCIM discovery public and return 404 on missing DELETE RFC 7644 requires /ServiceProviderConfig, /ResourceTypes and /Schemas to be publicly readable; split them onto an unguarded router while Users and Groups stay behind scim:write. DELETE on a missing User or Group now returns a 404 SCIM Error instead of a misleading 204. --- litellm/proxy/auth_v2/scim.py | 147 ++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 67 deletions(-) diff --git a/litellm/proxy/auth_v2/scim.py b/litellm/proxy/auth_v2/scim.py index 0f9b13f1e333..a22135789a23 100644 --- a/litellm/proxy/auth_v2/scim.py +++ b/litellm/proxy/auth_v2/scim.py @@ -63,14 +63,71 @@ def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: return resource.model_dump(scim_ctx=ctx) +def _build_discovery_router() -> APIRouter: + router = APIRouter() + + @router.get("/ServiceProviderConfig") + async def service_provider_config() -> Response: + config = ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=False, max_results=0), + change_password=ChangePassword(supported=False), + sort=Sort(supported=False), + etag=None, + authentication_schemes=[], + ) + return JSONResponse(content=config.model_dump()) + + @router.get("/ResourceTypes") + async def resource_types() -> Response: + types = [ + ResourceType( + id="User", + name="User", + endpoint="/Users", + schema="urn:ietf:params:scim:schemas:core:2.0:User", + ), + ResourceType( + id="Group", + name="Group", + endpoint="/Groups", + schema="urn:ietf:params:scim:schemas:core:2.0:Group", + ), + ] + listing: ListResponse[ResourceType] = ListResponse[ResourceType]( + total_results=len(types), + start_index=1, + items_per_page=len(types), + resources=types, + ) + return JSONResponse(content=listing.model_dump()) + + @router.get("/Schemas") + async def schemas() -> Response: + return JSONResponse( + content={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": [ + User.to_schema().model_dump(), + Group.to_schema().model_dump(), + ], + } + ) + + return router + + def build_scim_router() -> APIRouter: - router = APIRouter( - prefix="/scim/v2", - tags=["scim"], + router = APIRouter(prefix="/scim/v2", tags=["scim"]) + protected = APIRouter( dependencies=[Security(get_current_principal, scopes=["scim:write"])], ) - @router.post("/Users", status_code=status.HTTP_201_CREATED) + @protected.post("/Users", status_code=status.HTTP_201_CREATED) async def create_user(request: Request) -> Response: try: user = await _parse(request, User) @@ -82,14 +139,14 @@ async def create_user(request: Request) -> Response: content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), ) - @router.get("/Users/{resource_id}") + @protected.get("/Users/{resource_id}") async def get_user(resource_id: str, request: Request) -> Response: user = await _store(request).get_user(resource_id) if user is None: return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") return JSONResponse(content=_dump(user, Context.RESOURCE_QUERY_RESPONSE)) - @router.patch("/Users/{resource_id}") + @protected.patch("/Users/{resource_id}") async def patch_user(resource_id: str, request: Request) -> Response: store = _store(request) user = await store.get_user(resource_id) @@ -102,12 +159,15 @@ async def patch_user(resource_id: str, request: Request) -> Response: updated = await store.upsert_user(_apply_patch(user, patch)) return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) - @router.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) + @protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) async def deactivate_user(resource_id: str, request: Request) -> Response: - await _store(request).deactivate_user(resource_id) + store = _store(request) + if await store.get_user(resource_id) is None: + return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + await store.deactivate_user(resource_id) return Response(status_code=status.HTTP_204_NO_CONTENT) - @router.get("/Users") + @protected.get("/Users") async def list_users(request: Request, filter: Optional[str] = None) -> Response: users = await _store(request).list_users(filter) listing: ListResponse[User] = ListResponse[User]( @@ -118,7 +178,7 @@ async def list_users(request: Request, filter: Optional[str] = None) -> Response ) return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) - @router.post("/Groups", status_code=status.HTTP_201_CREATED) + @protected.post("/Groups", status_code=status.HTTP_201_CREATED) async def create_group(request: Request) -> Response: try: group = await _parse(request, Group) @@ -130,14 +190,14 @@ async def create_group(request: Request) -> Response: content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), ) - @router.get("/Groups/{resource_id}") + @protected.get("/Groups/{resource_id}") async def get_group(resource_id: str, request: Request) -> Response: group = await _store(request).get_group(resource_id) if group is None: return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") return JSONResponse(content=_dump(group, Context.RESOURCE_QUERY_RESPONSE)) - @router.patch("/Groups/{resource_id}") + @protected.patch("/Groups/{resource_id}") async def patch_group(resource_id: str, request: Request) -> Response: store = _store(request) group = await store.get_group(resource_id) @@ -150,12 +210,15 @@ async def patch_group(resource_id: str, request: Request) -> Response: updated = await store.upsert_group(_apply_patch(group, patch)) return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) - @router.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) + @protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_group(resource_id: str, request: Request) -> Response: - await _store(request).delete_group(resource_id) + store = _store(request) + if await store.get_group(resource_id) is None: + return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + await store.delete_group(resource_id) return Response(status_code=status.HTTP_204_NO_CONTENT) - @router.get("/Groups") + @protected.get("/Groups") async def list_groups(request: Request, filter: Optional[str] = None) -> Response: groups = await _store(request).list_groups(filter) listing: ListResponse[Group] = ListResponse[Group]( @@ -166,56 +229,6 @@ async def list_groups(request: Request, filter: Optional[str] = None) -> Respons ) return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) - @router.get("/ServiceProviderConfig") - async def service_provider_config() -> Response: - config = ServiceProviderConfig( - patch=Patch(supported=True), - bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), - filter=Filter(supported=False, max_results=0), - change_password=ChangePassword(supported=False), - sort=Sort(supported=False), - etag=None, - authentication_schemes=[], - ) - return JSONResponse(content=config.model_dump()) - - @router.get("/ResourceTypes") - async def resource_types() -> Response: - types = [ - ResourceType( - id="User", - name="User", - endpoint="/Users", - schema="urn:ietf:params:scim:schemas:core:2.0:User", - ), - ResourceType( - id="Group", - name="Group", - endpoint="/Groups", - schema="urn:ietf:params:scim:schemas:core:2.0:Group", - ), - ] - listing: ListResponse[ResourceType] = ListResponse[ResourceType]( - total_results=len(types), - start_index=1, - items_per_page=len(types), - resources=types, - ) - return JSONResponse(content=listing.model_dump()) - - @router.get("/Schemas") - async def schemas() -> Response: - return JSONResponse( - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 2, - "startIndex": 1, - "itemsPerPage": 2, - "Resources": [ - User.to_schema().model_dump(), - Group.to_schema().model_dump(), - ], - } - ) - + router.include_router(protected) + router.include_router(_build_discovery_router()) return router From 6f3fc5eba4b20a37f0051f6c150fd999138ea794 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:52:26 -0700 Subject: [PATCH 16/51] fix(auth_v2): close mTLS spoofing, introspection audience, SAML replay, and deactivated-user gaps Address the SSO and credential-flow security review findings: - mTLS (HIGH): the forwarded subject-DN header was trusted unconditionally, so any caller could send it and mint a service-account principal. Trust it only when the immediate peer is inside trusted_proxy_cidrs (same model as XFF) and fail closed otherwise; the ASGI-TLS-extension path already fails closed when no verified cert is present. - OAuth2 introspection (HIGH): RFC 7662 responses were accepted regardless of audience. Enforce the response aud against OAuth2IntrospectionConfig.audience and reject active tokens whose audience does not match. - SAML (HIGH): default allow_unsolicited to False so IdP-initiated/login-CSRF responses are rejected, add a single-use assertion-id replay cache, and bind the post-login redirect to the RelayState stored against the matched InResponseTo request rather than trusting the echoed form field. - Deactivated users (M1): the resolver now rejects a credential that resolves to a SCIM user with active=False, so deactivation actually blocks authentication. - Stop carrying underscore-prefixed carrier keys (raw api key, basic password) into Principal.claims, which is documented for audit logging. --- litellm/proxy/auth_v2/authenticators.py | 15 ++++++++--- litellm/proxy/auth_v2/config.py | 3 ++- litellm/proxy/auth_v2/errors.py | 4 +++ litellm/proxy/auth_v2/network.py | 4 +++ litellm/proxy/auth_v2/resolver.py | 33 ++++++++++++++++++++++--- litellm/proxy/auth_v2/saml.py | 32 ++++++++++++++++++------ 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index db8273bea831..35425cbd0671 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -23,6 +23,7 @@ MutualTlsConfig, OAuth2IntrospectionConfig, OidcProviderConfig, + TrustedProxyConfig, ) from .models import ( AuthMethod, @@ -31,6 +32,7 @@ CredentialRef, SecuritySchemeType, ) +from .network import ip_in_trusted_proxies AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} @@ -323,12 +325,15 @@ async def _introspect(self, token: str) -> Credential: body = response.json() if not body.get("active"): raise errors.invalid_token("token inactive") + token_audience = _normalize_audience(body.get("aud")) + if config.audience and not set(token_audience) & set(config.audience): + raise errors.invalid_token("audience mismatch") return Credential( scheme=self.scheme, method=AuthMethod.OAUTH2_INTROSPECTION, subject=str(body.get(config.subject_field, "")), issuer=body.get("iss"), - audience=_normalize_audience(body.get("aud")), + audience=token_audience, scopes=_split_scope(body.get("scope")), claims=body, ) @@ -360,8 +365,9 @@ def challenge(self) -> str: class MutualTlsAuthenticator: scheme = SecuritySchemeType.MUTUAL_TLS - def __init__(self, config: MutualTlsConfig) -> None: + def __init__(self, config: MutualTlsConfig, network: TrustedProxyConfig) -> None: self._config = config + self._network = network async def authenticate(self, request: Request) -> Optional[Credential]: cert = self._read_client_cert(request) @@ -376,6 +382,9 @@ async def authenticate(self, request: Request) -> Optional[Credential]: def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: if self._config.forwarded_subject_header: + peer = request.client.host if request.client else None + if not ip_in_trusted_proxies(peer, self._network): + return None dn = request.headers.get(self._config.forwarded_subject_header) return ClientCertificate(subject_dn=dn) if dn else None tls = request.scope.get("extensions", {}).get("tls", {}) @@ -402,6 +411,6 @@ def build_authenticators( ) if config.mutual_tls.enabled: by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTlsAuthenticator( - config.mutual_tls + config.mutual_tls, config.network ) return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index 738f1a03fcbf..9026a23af2d1 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -47,6 +47,7 @@ class OAuth2IntrospectionConfig(BaseModel): client_id: str client_secret: SecretStr subject_field: str = "sub" + audience: List[str] = Field(default_factory=list) class MutualTlsConfig(BaseModel): @@ -66,7 +67,7 @@ class SamlConfig(BaseModel): idp_metadata: str = "" sp_key_file: Optional[str] = None sp_cert_file: Optional[str] = None - allow_unsolicited: bool = True + allow_unsolicited: bool = False session_cookie: str = "saml_session" cookie_secure: bool = True session_ttl_seconds: int = 3600 diff --git a/litellm/proxy/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py index 82953124496f..a7c5950790fb 100644 --- a/litellm/proxy/auth_v2/errors.py +++ b/litellm/proxy/auth_v2/errors.py @@ -48,3 +48,7 @@ def forbidden_role() -> AuthError: def forbidden_permission() -> AuthError: return AuthError(403, "Forbidden") + + +def account_disabled() -> AuthError: + return AuthError(403, "Account disabled") diff --git a/litellm/proxy/auth_v2/network.py b/litellm/proxy/auth_v2/network.py index c8aee43fffb1..4852cf3ddbef 100644 --- a/litellm/proxy/auth_v2/network.py +++ b/litellm/proxy/auth_v2/network.py @@ -30,6 +30,10 @@ def _ip_in_cidrs(ip: Optional[str], cidrs: List[str]) -> bool: return False +def ip_in_trusted_proxies(ip: Optional[str], config: TrustedProxyConfig) -> bool: + return _ip_in_cidrs(ip, config.trusted_proxy_cidrs) + + def resolve_client_ip( request: Request, config: TrustedProxyConfig ) -> Tuple[Optional[str], bool]: diff --git a/litellm/proxy/auth_v2/resolver.py b/litellm/proxy/auth_v2/resolver.py index 55a5883689f9..0c33763843f9 100644 --- a/litellm/proxy/auth_v2/resolver.py +++ b/litellm/proxy/auth_v2/resolver.py @@ -48,6 +48,10 @@ def _roles_from_claims(claims: Dict[str, Any]) -> List[Role]: return [Role(value) for value in raw if value in valid] +def _public_claims(claims: Dict[str, Any]) -> Dict[str, Any]: + return {key: value for key, value in claims.items() if not key.startswith("_")} + + def _teams_from_claims(claims: Dict[str, Any]) -> List[TeamIdentity]: groups = claims.get("groups", []) if not isinstance(groups, list): @@ -70,8 +74,29 @@ def __init__( async def resolve(self, credential: Credential) -> Principal: if credential.method == AuthMethod.API_KEY: - return self._resolve_api_key(credential) - return self._resolve_subject(credential) + principal = self._resolve_api_key(credential) + else: + principal = self._resolve_subject(credential) + self._reject_if_deactivated(principal) + return principal + + def _reject_if_deactivated(self, principal: Principal) -> None: + user = self._lookup_scim_user(principal) + if user is not None and user.active is False: + raise errors.account_disabled() + + def _lookup_scim_user(self, principal: Principal) -> Optional[ScimUser]: + if principal.user is None: + return None + by_id = self._users.get(principal.user.id) + if by_id is not None: + return by_id + external = principal.user.external_id + if external: + for user in self._users.values(): + if user.external_id == external: + return user + return None def _resolve_api_key(self, credential: Credential) -> Principal: raw = credential.claims.get("_raw_api_key") @@ -99,7 +124,7 @@ def _principal_from_claims(self, credential: Credential) -> Principal: scopes=list(credential.scopes), auth_method=credential.method, credential_ref=credential.credential_ref, - claims=dict(claims), + claims=_public_claims(claims), ) return Principal( principal_type=PrincipalType.HUMAN, @@ -118,7 +143,7 @@ def _principal_from_claims(self, credential: Credential) -> Principal: scopes=list(credential.scopes), auth_method=credential.method, credential_ref=credential.credential_ref, - claims=dict(claims), + claims=_public_claims(claims), ) async def upsert_user(self, user: ScimUser) -> ScimUser: diff --git a/litellm/proxy/auth_v2/saml.py b/litellm/proxy/auth_v2/saml.py index b46ff2f2bded..d57811c8f575 100644 --- a/litellm/proxy/auth_v2/saml.py +++ b/litellm/proxy/auth_v2/saml.py @@ -141,6 +141,7 @@ def build_sp_client(config: SamlConfig) -> Saml2Client: class SamlSessionStore: def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._seen_assertions: Dict[str, float] = {} self.outstanding: Dict[str, str] = {} self._ttl = ttl_seconds self._max_size = max_size @@ -148,6 +149,16 @@ def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: def remember_request(self, request_id: str, relay_state: str = "/") -> None: self.outstanding[request_id] = relay_state + def consume_assertion(self, assertion_id: str) -> bool: + now = time.time() + self._seen_assertions = { + aid: exp for aid, exp in self._seen_assertions.items() if exp >= now + } + if assertion_id in self._seen_assertions: + return False + self._seen_assertions[assertion_id] = now + self._ttl + return True + def create_session(self, identity: Dict[str, Any]) -> str: now = time.time() self._evict(now) @@ -244,6 +255,18 @@ async def assertion_consumer_service(request: Request) -> Response: if authn_response is None: raise HTTPException(status_code=401, detail="invalid SAML response") + in_response_to = getattr(authn_response, "in_response_to", None) + bound_relay = ( + session_store.outstanding.pop(in_response_to, None) + if in_response_to + else None + ) + + assertion = getattr(authn_response, "assertion", None) + assertion_id = getattr(assertion, "id", None) + if assertion_id and not session_store.consume_assertion(assertion_id): + raise HTTPException(status_code=401, detail="SAML assertion replay") + name_id = authn_response.get_subject().text ava = authn_response.get_identity() or {} mapped = _map_attributes(ava, config.attribute_map) @@ -252,9 +275,6 @@ async def assertion_consumer_service(request: Request) -> Response: store: ProvisioningStore = request.app.state.auth_v2.resolver await store.upsert_user(user) - in_response_to = getattr(authn_response, "in_response_to", None) - if in_response_to: - session_store.outstanding.pop(in_response_to, None) session_id = session_store.create_session( { "name_id": name_id, @@ -262,11 +282,7 @@ async def assertion_consumer_service(request: Request) -> Response: "claims": _claims_from_mapped(mapped), } ) - relay_state = form.get("RelayState") - target = _safe_relay_state( - relay_state if isinstance(relay_state, str) else None, - config.default_redirect_path, - ) + target = _safe_relay_state(bound_relay, config.default_redirect_path) response = RedirectResponse(target, status_code=303) response.set_cookie( config.session_cookie, From 450349965c51365980fcefa76bc17999a384ba6e Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:52:58 -0700 Subject: [PATCH 17/51] fix(auth_v2): honor nested SCIM patch paths, align /Schemas, unshadow filter PATCH now applies dotted attribute paths like name.givenName instead of silently dropping them, and rejects unsupported value-filter paths (emails[type eq "work"].value) with a 400 SCIM Error so behavior matches the advertised patch support. /Schemas now uses the ListResponse envelope like the other discovery endpoints, and the list route's query parameter no longer shadows the builtin while keeping the RFC 7644 ?filter= wire contract. --- litellm/proxy/auth_v2/scim.py | 84 ++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/auth_v2/scim.py b/litellm/proxy/auth_v2/scim.py index a22135789a23..eafe83f88784 100644 --- a/litellm/proxy/auth_v2/scim.py +++ b/litellm/proxy/auth_v2/scim.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional, Type, TypeVar -from fastapi import APIRouter, Request, Response, Security, status +from fastapi import APIRouter, Query, Request, Response, Security, status from fastapi.responses import JSONResponse from pydantic import ValidationError from scim2_models import ( @@ -17,6 +17,7 @@ PatchOp, Resource, ResourceType, + Schema, ServiceProviderConfig, Sort, User, @@ -44,18 +45,43 @@ async def _parse(request: Request, model: Type[R]) -> R: return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST) +def _set_path(data: Dict[str, Any], path: str, value: Any) -> None: + keys = path.split(".") + node = data + for key in keys[:-1]: + child = node.get(key) + if not isinstance(child, dict): + child = {} + node[key] = child + node = child + node[keys[-1]] = value + + +def _remove_path(data: Dict[str, Any], path: str) -> None: + keys = path.split(".") + node = data + for key in keys[:-1]: + child = node.get(key) + if not isinstance(child, dict): + return + node = child + node.pop(keys[-1], None) + + def _apply_patch(resource: R, patch: PatchOp) -> R: data: Dict[str, Any] = resource.model_dump() for op in patch.operations: action = op.op.value if hasattr(op.op, "value") else str(op.op) + if op.path is not None and ("[" in op.path or "]" in op.path): + raise ValueError(f"unsupported SCIM patch path filter: {op.path}") if action == "remove": if op.path: - data.pop(op.path, None) + _remove_path(data, op.path) continue if op.path is None and isinstance(op.value, dict): data.update(op.value) elif op.path is not None: - data[op.path] = op.value + _set_path(data, op.path, op.value) return type(resource).model_validate(data) @@ -105,24 +131,19 @@ async def resource_types() -> Response: @router.get("/Schemas") async def schemas() -> Response: - return JSONResponse( - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 2, - "startIndex": 1, - "itemsPerPage": 2, - "Resources": [ - User.to_schema().model_dump(), - Group.to_schema().model_dump(), - ], - } + resources = [User.to_schema(), Group.to_schema()] + listing: ListResponse[Schema] = ListResponse[Schema]( + total_results=len(resources), + start_index=1, + items_per_page=len(resources), + resources=resources, ) + return JSONResponse(content=listing.model_dump()) return router -def build_scim_router() -> APIRouter: - router = APIRouter(prefix="/scim/v2", tags=["scim"]) +def _build_protected_router() -> APIRouter: protected = APIRouter( dependencies=[Security(get_current_principal, scopes=["scim:write"])], ) @@ -154,9 +175,10 @@ async def patch_user(resource_id: str, request: Request) -> Response: return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") try: patch = PatchOp[User].model_validate(await request.json()) - except ValidationError as exc: + patched = _apply_patch(user, patch) + except (ValidationError, ValueError) as exc: return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - updated = await store.upsert_user(_apply_patch(user, patch)) + updated = await store.upsert_user(patched) return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) @protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -168,8 +190,11 @@ async def deactivate_user(resource_id: str, request: Request) -> Response: return Response(status_code=status.HTTP_204_NO_CONTENT) @protected.get("/Users") - async def list_users(request: Request, filter: Optional[str] = None) -> Response: - users = await _store(request).list_users(filter) + async def list_users( + request: Request, + filter_expr: Optional[str] = Query(default=None, alias="filter"), + ) -> Response: + users = await _store(request).list_users(filter_expr) listing: ListResponse[User] = ListResponse[User]( total_results=len(users), start_index=1, @@ -205,9 +230,10 @@ async def patch_group(resource_id: str, request: Request) -> Response: return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") try: patch = PatchOp[Group].model_validate(await request.json()) - except ValidationError as exc: + patched = _apply_patch(group, patch) + except (ValidationError, ValueError) as exc: return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - updated = await store.upsert_group(_apply_patch(group, patch)) + updated = await store.upsert_group(patched) return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) @protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -219,8 +245,11 @@ async def delete_group(resource_id: str, request: Request) -> Response: return Response(status_code=status.HTTP_204_NO_CONTENT) @protected.get("/Groups") - async def list_groups(request: Request, filter: Optional[str] = None) -> Response: - groups = await _store(request).list_groups(filter) + async def list_groups( + request: Request, + filter_expr: Optional[str] = Query(default=None, alias="filter"), + ) -> Response: + groups = await _store(request).list_groups(filter_expr) listing: ListResponse[Group] = ListResponse[Group]( total_results=len(groups), start_index=1, @@ -229,6 +258,11 @@ async def list_groups(request: Request, filter: Optional[str] = None) -> Respons ) return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) - router.include_router(protected) + return protected + + +def build_scim_router() -> APIRouter: + router = APIRouter(prefix="/scim/v2", tags=["scim"]) + router.include_router(_build_protected_router()) router.include_router(_build_discovery_router()) return router From c512a49fa5ba97b16cb041dffd67def799a20e81 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:59:41 -0700 Subject: [PATCH 18/51] test(auth_v2): pin the hardened auth behaviors from the security fixes Cover the security fixes landed in 71a189b, ca896ac, 6f3fc5e and 4503499: - HTTP basic now verifies the password via an injected BasicAuthVerifier: correct creds 200, wrong password / unknown user / no verifier wired all 401 (fail closed), and the password is never carried on the credential; plus a unit test that hash_basic_password is salted and InMemoryBasicAuthStore verifies it - mTLS only trusts the forwarded subject-DN header from a peer inside the trusted-proxy CIDRs; a forged header from an untrusted peer is ignored - SCIM discovery endpoints (ServiceProviderConfig, ResourceTypes, Schemas) are public, Users/Groups stay guarded, DELETE on a missing resource is a SCIM 404 Error, and PATCH honors nested dotted paths while rejecting filter paths 400 - SAML ACS sets a Secure session cookie, binds the redirect target server-side so a client-supplied form RelayState is never trusted (falls back to the default path), and the session store enforces TTL expiry and size eviction Mutation-checked: removing the basic-auth password check or the mTLS trusted-peer gate fails these. --- .../proxy/auth_v2/test_authenticators.py | 103 +++++++++++++++--- tests/test_litellm/proxy/auth_v2/test_saml.py | 49 ++++++++- tests/test_litellm/proxy/auth_v2/test_scim.py | 83 ++++++++++++++ 3 files changed, 218 insertions(+), 17 deletions(-) diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index 78a788430e69..30a18d461920 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -9,11 +9,13 @@ from litellm.proxy.auth_v2.authenticators import ( ApiKeyAuthenticator, HttpAuthenticator, + InMemoryBasicAuthStore, JwtVerifier, MutualTlsAuthenticator, OAuth2Authenticator, OidcAuthenticator, build_authenticators, + hash_basic_password, ) from litellm.proxy.auth_v2.config import ( ApiKeySchemeConfig, @@ -22,6 +24,7 @@ MutualTlsConfig, OAuth2IntrospectionConfig, OidcProviderConfig, + TrustedProxyConfig, ) from litellm.proxy.auth_v2.errors import AuthError from litellm.proxy.auth_v2.models import AuthMethod, SecuritySchemeType @@ -121,12 +124,16 @@ async def test_api_key_authenticator_returns_none_when_absent(): # --------------------------------------------------------------------------- # -def _http_auth(public_key: Any, *, basic: HttpBasicConfig = None) -> HttpAuthenticator: +def _http_auth( + public_key: Any, *, basic: HttpBasicConfig = None, basic_verifier=None +) -> HttpAuthenticator: verifier = JwtVerifier( OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), ) - return HttpAuthenticator(basic or HttpBasicConfig(), [verifier]) + return HttpAuthenticator( + basic or HttpBasicConfig(), [verifier], basic_verifier=basic_verifier + ) async def test_http_bearer_valid_token_resolves_credential(rsa_keypair, token_factory): @@ -167,15 +174,59 @@ async def test_http_basic_disabled_ignores_basic_scheme(rsa_keypair): assert await auth.authenticate(request) is None -async def test_http_basic_enabled_decodes_username(rsa_keypair): +def _basic_store() -> InMemoryBasicAuthStore: + return InMemoryBasicAuthStore({"alice": hash_basic_password("supersecret")}) + + +async def test_http_basic_verifies_correct_credentials(rsa_keypair): _, public_key = rsa_keypair - auth = _http_auth(public_key, basic=HttpBasicConfig(enabled=True)) + auth = _http_auth( + public_key, basic=HttpBasicConfig(enabled=True), basic_verifier=_basic_store() + ) creds = base64.b64encode(b"alice:supersecret").decode() request = make_request(headers={"authorization": f"Basic {creds}"}) credential = await auth.authenticate(request) assert credential is not None assert credential.method == AuthMethod.HTTP_BASIC assert credential.subject == "alice" + # the password must never be carried on the credential (leak regression) + assert "_basic_password" not in credential.claims + assert "supersecret" not in str(credential.claims) + + +async def test_http_basic_wrong_password_rejected(rsa_keypair): + _, public_key = rsa_keypair + auth = _http_auth( + public_key, basic=HttpBasicConfig(enabled=True), basic_verifier=_basic_store() + ) + creds = base64.b64encode(b"alice:WRONG").decode() + request = make_request(headers={"authorization": f"Basic {creds}"}) + with pytest.raises(AuthError) as exc: + await auth.authenticate(request) + assert exc.value.status_code == 401 + + +async def test_http_basic_unknown_user_rejected(rsa_keypair): + _, public_key = rsa_keypair + auth = _http_auth( + public_key, basic=HttpBasicConfig(enabled=True), basic_verifier=_basic_store() + ) + creds = base64.b64encode(b"mallory:supersecret").decode() + request = make_request(headers={"authorization": f"Basic {creds}"}) + with pytest.raises(AuthError) as exc: + await auth.authenticate(request) + assert exc.value.status_code == 401 + + +async def test_http_basic_without_verifier_fails_closed(rsa_keypair): + # basic enabled but no verifier wired -> must never accept (fail closed) + _, public_key = rsa_keypair + auth = _http_auth(public_key, basic=HttpBasicConfig(enabled=True)) + creds = base64.b64encode(b"alice:supersecret").decode() + request = make_request(headers={"authorization": f"Basic {creds}"}) + with pytest.raises(AuthError) as exc: + await auth.authenticate(request) + assert exc.value.status_code == 401 async def test_http_basic_malformed_payload_raises(rsa_keypair): @@ -195,6 +246,19 @@ def test_http_challenge_advertises_basic_only_when_enabled(rsa_keypair): assert "Bearer" in enabled.challenge() +def test_hash_basic_password_is_salted_and_verifiable(): + # the stored hash is never the plaintext, and re-hashing yields a fresh salt + first = hash_basic_password("supersecret") + second = hash_basic_password("supersecret") + assert "supersecret" not in first + assert first != second # random salt per call + + store = InMemoryBasicAuthStore({"alice": first}) + assert store.verify("alice", "supersecret") + assert not store.verify("alice", "supersecre") + assert not store.verify("unknown", "supersecret") + + # --------------------------------------------------------------------------- # # OAuth2Authenticator (at+jwt enforcement + opaque token path) # --------------------------------------------------------------------------- # @@ -345,10 +409,16 @@ async def test_oidc_unknown_issuer_raises(rsa_keypair, token_factory): # --------------------------------------------------------------------------- # -async def test_mtls_reads_forwarded_subject_header(): - auth = MutualTlsAuthenticator( - MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn") - ) +# make_request's default peer is 203.0.113.7; trust that /24 for the proxy path +_TRUSTED_NET = TrustedProxyConfig(trusted_proxy_cidrs=["203.0.113.0/24"]) + + +def _mtls(config: MutualTlsConfig, network: TrustedProxyConfig = None): + return MutualTlsAuthenticator(config, network or _TRUSTED_NET) + + +async def test_mtls_reads_forwarded_subject_header_from_trusted_peer(): + auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) request = make_request(headers={"x-client-dn": "CN=svc-a,O=Co,C=US"}) credential = await auth.authenticate(request) assert credential is not None @@ -357,15 +427,22 @@ async def test_mtls_reads_forwarded_subject_header(): assert credential.client_certificate.subject_dn == "CN=svc-a,O=Co,C=US" -async def test_mtls_forwarded_header_absent_returns_none(): - auth = MutualTlsAuthenticator( - MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn") +async def test_mtls_forwarded_header_from_untrusted_peer_is_ignored(): + # spoofing guard: a client that is not a trusted proxy cannot forge the DN header + auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) + request = make_request( + headers={"x-client-dn": "CN=attacker"}, client=("8.8.8.8", 4444) ) + assert await auth.authenticate(request) is None + + +async def test_mtls_forwarded_header_absent_returns_none(): + auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) assert await auth.authenticate(make_request()) is None async def test_mtls_reads_asgi_tls_extension(): - auth = MutualTlsAuthenticator(MutualTlsConfig(enabled=True)) + auth = _mtls(MutualTlsConfig(enabled=True)) request = make_request( scope_extra={"extensions": {"tls": {"client_cert_name": "CN=from-asgi"}}} ) @@ -375,7 +452,7 @@ async def test_mtls_reads_asgi_tls_extension(): async def test_mtls_no_cert_returns_none(): - auth = MutualTlsAuthenticator(MutualTlsConfig(enabled=True)) + auth = _mtls(MutualTlsConfig(enabled=True)) assert await auth.authenticate(make_request()) is None diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index 83a13c72654c..e3c5f655fe0b 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -150,6 +150,9 @@ def saml_env(tmp_path: Path) -> SamlEnv: sp_key_file=sp_key, sp_cert_file=sp_cert, xmlsec_binary=xmlsec1, + # this harness mints IdP-initiated (unsolicited) responses; pin the config + # explicitly so the suite is independent of the allow_unsolicited default + allow_unsolicited=True, ) return SamlEnv(config=config, idp=idp) @@ -299,7 +302,10 @@ def test_acs_rejects_garbage_response(saml_env): assert store._users == {} -def test_acs_redirects_to_safe_relay_state(saml_env): +def test_acs_ignores_untrusted_form_relay_state(saml_env): + # the redirect target is bound server-side to the originating AuthnRequest, so a + # client-supplied form RelayState on an (unsolicited) response is NOT trusted and + # the ACS falls back to default_redirect_path app, _ = _build_app(saml_env) client = TestClient(app) acs = client.post( @@ -308,10 +314,10 @@ def test_acs_redirects_to_safe_relay_state(saml_env): follow_redirects=False, ) assert acs.status_code == 303 - assert acs.headers["location"] == "/dashboard" + assert acs.headers["location"] == "/" -def test_acs_rejects_open_redirect_relay_state(saml_env): +def test_acs_never_redirects_to_attacker_relay_state(saml_env): app, _ = _build_app(saml_env) client = TestClient(app) acs = client.post( @@ -323,7 +329,7 @@ def test_acs_rejects_open_redirect_relay_state(saml_env): follow_redirects=False, ) assert acs.status_code == 303 - # unsafe RelayState falls back to default_redirect_path, never the attacker URL + assert "evil.example.com" not in acs.headers["location"] assert acs.headers["location"] == "/" @@ -415,3 +421,38 @@ def test_metadata_source_classifies_input(metadata, expected_key): from litellm.proxy.auth_v2.saml import _metadata_source assert expected_key in _metadata_source(metadata) + + +def test_acs_session_cookie_is_secure(saml_env): + app, _ = _build_app(saml_env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={"SAMLResponse": saml_env.mint_response()}, + follow_redirects=False, + ) + assert "saml_session" in acs.cookies + assert "secure" in acs.headers["set-cookie"].lower() + + +# --------------------------------------------------------------------------- # +# SamlSessionStore TTL + size eviction (no xmlsec1 needed) +# --------------------------------------------------------------------------- # + + +def test_session_store_expires_entries(): + from litellm.proxy.auth_v2.saml import SamlSessionStore + + store = SamlSessionStore(ttl_seconds=0) + session_id = store.create_session({"name_id": "alice@example.com"}) + # ttl of 0 means the entry is already past its expiry on the next read + assert store.get(session_id) is None + + +def test_session_store_evicts_when_over_capacity(): + from litellm.proxy.auth_v2.saml import SamlSessionStore + + store = SamlSessionStore(max_size=3) + ids = [store.create_session({"name_id": f"user-{i}"}) for i in range(5)] + live = [sid for sid in ids if store.get(sid) is not None] + assert len(live) <= 3 diff --git a/tests/test_litellm/proxy/auth_v2/test_scim.py b/tests/test_litellm/proxy/auth_v2/test_scim.py index 06edb468b242..e05e90861156 100644 --- a/tests/test_litellm/proxy/auth_v2/test_scim.py +++ b/tests/test_litellm/proxy/auth_v2/test_scim.py @@ -189,3 +189,86 @@ def test_scim_requires_scim_write_scope(): response = underscoped.get("/scim/v2/Users") assert response.status_code == 403 assert "insufficient_scope" in response.headers.get("WWW-Authenticate", "") + + +# --------------------------------------------------------------------------- # +# Discovery endpoints are public (RFC 7644); Users/Groups stay guarded +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "path", ["/ServiceProviderConfig", "/ResourceTypes", "/Schemas"] +) +def test_discovery_endpoints_are_public(path): + # no credential at all -> still 200 (provisioning clients negotiate before auth) + unauth = TestClient(_app()) + assert unauth.get(f"/scim/v2{path}").status_code == 200 + + +def test_users_endpoint_is_not_public(): + unauth = TestClient(_app()) + assert unauth.get("/scim/v2/Users").status_code == 401 + + +# --------------------------------------------------------------------------- # +# DELETE on a missing resource returns a SCIM 404 Error, not 204 +# --------------------------------------------------------------------------- # + + +def test_delete_missing_user_returns_scim_404(client): + response = client.delete("/scim/v2/Users/no-such-user") + assert response.status_code == 404 + body = response.json() + assert body["schemas"] == [ERROR_SCHEMA] + assert body["status"] == "404" + + +def test_delete_missing_group_returns_scim_404(client): + response = client.delete("/scim/v2/Groups/no-such-group") + assert response.status_code == 404 + body = response.json() + assert body["schemas"] == [ERROR_SCHEMA] + assert body["status"] == "404" + + +def test_second_delete_of_group_returns_404(client): + group_id = client.post( + "/scim/v2/Groups", + json={"schemas": [GROUP_SCHEMA], "displayName": "Temp"}, + ).json()["id"] + assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 204 + assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 404 + + +# --------------------------------------------------------------------------- # +# PATCH supports nested dotted paths; filter paths are rejected +# --------------------------------------------------------------------------- # + + +def test_patch_nested_path_sets_subattribute(client): + user_id = _create_user(client).json()["id"] + response = client.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "name.givenName", "value": "Ada"}], + }, + ) + assert response.status_code == 200 + assert response.json()["name"]["givenName"] == "Ada" + assert client.get(f"/scim/v2/Users/{user_id}").json()["name"]["givenName"] == "Ada" + + +def test_patch_filter_path_is_rejected(client): + user_id = _create_user(client).json()["id"] + response = client.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "replace", "path": 'emails[type eq "work"].value', "value": "x"} + ], + }, + ) + assert response.status_code == 400 + assert response.json()["schemas"] == [ERROR_SCHEMA] From 8302f559950707470b962a81e1bba05a68b52207 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:08:13 -0700 Subject: [PATCH 19/51] refactor(auth_v2): replace install_auth with AuthSecurity DI Per user direction, drop install_auth/AuthContext/app.state entirely; the enforcement layer is now an AuthSecurity object whose bound methods are the FastAPI Security() dependencies. The app constructs AuthSecurity(config, resolver) once and passes auth.principal / auth.require_roles / auth.require_permission to Security(); routers take the instance explicitly via build_*_router(auth) and read auth.resolver/auth.config rather than request.app.state. Browser sessions are unified behind a shared SessionStore + SessionAuthenticator (session.py): one cookie, keyed on identity["method"], so SAML and the upcoming OIDC login flow share one store. AuthSecurity owns the post-login session_store and a short-TTL oauth_txn_store for OIDC state/nonce/PKCE; SessionConfig moves the cookie/TTL/redirect settings off SAMLConfig. SAML keeps its protocol-specific outstanding/replay state local. Fold in the standing judge findings: collapse the triplicated http/oauth2/oidc bearer paths into one _authenticate_bearer_jwt helper, inject the introspection async-client via a factory instead of importing litellm inline, drop the dead scheme attribute from the authenticators, and rename to PEP 8 acronym casing (JWTVerifier, OIDCAuthenticator, OIDCProviderConfig, SAMLConfig, RBACEngine, APIKeyAuthenticator, MutualTLSAuthenticator). __all__ now exports AuthSecurity, Role and the resolver protocols. scim.py and oidc.py move to build_*_router(auth) separately. --- litellm/proxy/auth_v2/__init__.py | 42 ++++-- litellm/proxy/auth_v2/authenticators.py | 182 ++++++++++++----------- litellm/proxy/auth_v2/config.py | 34 ++--- litellm/proxy/auth_v2/rbac.py | 4 +- litellm/proxy/auth_v2/saml.py | 126 +++++----------- litellm/proxy/auth_v2/security.py | 183 +++++++++++------------- litellm/proxy/auth_v2/session.py | 88 ++++++++++++ 7 files changed, 340 insertions(+), 319 deletions(-) create mode 100644 litellm/proxy/auth_v2/session.py diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 0e76c806a6b8..032bb0146453 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -1,17 +1,35 @@ -from .config import AuthConfig -from .models import Principal -from .security import ( - get_current_principal, - install_auth, - require_permission, - require_roles, +from .config import ( + ApiKeySchemeConfig, + AuthConfig, + HttpBasicConfig, + MutualTLSConfig, + OAuth2IntrospectionConfig, + OIDCProviderConfig, + SAMLConfig, + SessionConfig, + TrustedProxyConfig, ) +from .models import Principal +from .rbac import Role +from .resolver import IdentityResolver, InMemoryIdentityStore, ProvisioningStore +from .saml import build_saml_router +from .security import AuthSecurity __all__ = [ - "Principal", + "AuthSecurity", "AuthConfig", - "get_current_principal", - "require_roles", - "require_permission", - "install_auth", + "Principal", + "Role", + "IdentityResolver", + "ProvisioningStore", + "InMemoryIdentityStore", + "ApiKeySchemeConfig", + "HttpBasicConfig", + "OIDCProviderConfig", + "OAuth2IntrospectionConfig", + "MutualTLSConfig", + "TrustedProxyConfig", + "SessionConfig", + "SAMLConfig", + "build_saml_router", ] diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index 35425cbd0671..7f4ba45b5f4a 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -6,9 +6,8 @@ import hashlib import hmac import secrets -from typing import Any, Dict, List, Optional, Protocol, runtime_checkable +from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable -import httpx import jwt from fastapi import Request from jwt import PyJWKClient @@ -20,9 +19,9 @@ ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, - MutualTlsConfig, + MutualTLSConfig, OAuth2IntrospectionConfig, - OidcProviderConfig, + OIDCProviderConfig, TrustedProxyConfig, ) from .models import ( @@ -39,13 +38,40 @@ @runtime_checkable class Authenticator(Protocol): - scheme: SecuritySchemeType - async def authenticate(self, request: Request) -> Optional[Credential]: ... def challenge(self) -> str: ... +@runtime_checkable +class BasicAuthVerifier(Protocol): + def verify(self, username: str, password: str) -> bool: ... + + +def hash_basic_password(password: str, salt: Optional[str] = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.sha256(bytes.fromhex(salt) + password.encode()).hexdigest() + return f"{salt}${digest}" + + +class InMemoryBasicAuthStore: + def __init__(self, credentials: Dict[str, str]) -> None: + self._credentials = credentials + + def verify(self, username: str, password: str) -> bool: + stored = self._credentials.get(username) + if stored is None: + return False + salt, _, expected = stored.partition("$") + try: + candidate = hashlib.sha256( + bytes.fromhex(salt) + password.encode() + ).hexdigest() + except ValueError: + return False + return hmac.compare_digest(candidate, expected) + + def _extract_bearer(request: Request) -> Optional[str]: header = request.headers.get("authorization") if not header: @@ -93,39 +119,10 @@ def _credential_from_claims( ) -@runtime_checkable -class BasicAuthVerifier(Protocol): - def verify(self, username: str, password: str) -> bool: ... - - -def hash_basic_password(password: str, salt: Optional[str] = None) -> str: - salt = salt or secrets.token_hex(16) - digest = hashlib.sha256(bytes.fromhex(salt) + password.encode()).hexdigest() - return f"{salt}${digest}" - - -class InMemoryBasicAuthStore: - def __init__(self, credentials: Dict[str, str]) -> None: - self._credentials = credentials - - def verify(self, username: str, password: str) -> bool: - stored = self._credentials.get(username) - if stored is None: - return False - salt, _, expected = stored.partition("$") - try: - candidate = hashlib.sha256( - bytes.fromhex(salt) + password.encode() - ).hexdigest() - except ValueError: - return False - return hmac.compare_digest(candidate, expected) - - -class JwtVerifier: +class JWTVerifier: def __init__( self, - provider: OidcProviderConfig, + provider: OIDCProviderConfig, jwks_client: Optional[PyJWKClient] = None, ) -> None: self.provider = provider @@ -138,6 +135,8 @@ def __init__( self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True) def _discover_jwks(self) -> str: + import httpx + url = f"{self.provider.issuer.rstrip('/')}/.well-known/openid-configuration" response = httpx.get(url, timeout=10.0) response.raise_for_status() @@ -171,14 +170,14 @@ def verify( async def _verify_jwt_off_loop( - verifier: JwtVerifier, token: str, *, require_at_jwt: Optional[bool] = None + verifier: JWTVerifier, token: str, *, require_at_jwt: Optional[bool] = None ) -> Dict[str, Any]: return await run_in_threadpool( functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt) ) -def _select_verifier(token: str, verifiers: List[JwtVerifier]) -> Optional[JwtVerifier]: +def _select_verifier(token: str, verifiers: List[JWTVerifier]) -> Optional[JWTVerifier]: if not verifiers: return None try: @@ -191,9 +190,22 @@ def _select_verifier(token: str, verifiers: List[JwtVerifier]) -> Optional[JwtVe return None -class ApiKeyAuthenticator: - scheme = SecuritySchemeType.API_KEY +async def _authenticate_bearer_jwt( + token: str, + verifiers: List[JWTVerifier], + scheme: SecuritySchemeType, + method: AuthMethod, + *, + require_at_jwt: bool = False, +) -> Credential: + verifier = _select_verifier(token, verifiers) + if verifier is None: + raise errors.invalid_token("no issuer match") + claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=require_at_jwt) + return _credential_from_claims(scheme, method, token, claims) + +class APIKeyAuthenticator: def __init__(self, config: ApiKeySchemeConfig) -> None: self._header_name = config.header_name @@ -202,7 +214,7 @@ async def authenticate(self, request: Request) -> Optional[Credential]: if not raw: return None return Credential( - scheme=self.scheme, + scheme=SecuritySchemeType.API_KEY, method=AuthMethod.API_KEY, subject=raw, credential_ref=CredentialRef(key_id=raw[:10]), @@ -214,12 +226,10 @@ def challenge(self) -> str: class HttpAuthenticator: - scheme = SecuritySchemeType.HTTP - def __init__( self, basic: HttpBasicConfig, - jwt_verifiers: List[JwtVerifier], + jwt_verifiers: List[JWTVerifier], basic_verifier: Optional[BasicAuthVerifier] = None, ) -> None: self._basic = basic @@ -233,20 +243,13 @@ async def authenticate(self, request: Request) -> Optional[Credential]: scheme, _, value = header.partition(" ") scheme_lower = scheme.lower() if scheme_lower == "bearer" and value: - return await self._verify_bearer(value) + return await _authenticate_bearer_jwt( + value, self._verifiers, SecuritySchemeType.HTTP, AuthMethod.BEARER_JWT + ) if scheme_lower == "basic" and self._basic.enabled and value: return self._verify_basic(value) return None - async def _verify_bearer(self, token: str) -> Credential: - verifier = _select_verifier(token, self._verifiers) - if verifier is None: - raise errors.invalid_token("no issuer match") - claims = await _verify_jwt_off_loop(verifier, token) - return _credential_from_claims( - self.scheme, AuthMethod.BEARER_JWT, token, claims - ) - def _verify_basic(self, value: str) -> Credential: challenge = errors.basic_challenge(self._basic.realm) try: @@ -262,7 +265,7 @@ def _verify_basic(self, value: str) -> Credential: ): raise errors.unauthenticated(challenge) return Credential( - scheme=self.scheme, + scheme=SecuritySchemeType.HTTP, method=AuthMethod.HTTP_BASIC, subject=username, ) @@ -274,46 +277,47 @@ def challenge(self) -> str: return bearer -class OAuth2Authenticator: - scheme = SecuritySchemeType.OAUTH2 +def _default_introspection_client() -> Any: + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + from litellm.types.llms.custom_http import httpxSpecialProvider + + return get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + +class OAuth2Authenticator: def __init__( self, - jwt_verifiers: List[JwtVerifier], + jwt_verifiers: List[JWTVerifier], introspection: Optional[OAuth2IntrospectionConfig], + client_factory: Optional[Callable[[], Any]] = None, ) -> None: self._verifiers = jwt_verifiers self._introspection = introspection + self._client_factory = client_factory or _default_introspection_client async def authenticate(self, request: Request) -> Optional[Credential]: token = _extract_bearer(request) if token is None: return None if _looks_like_jwt(token): - return await self._verify_at_jwt(token) + return await _authenticate_bearer_jwt( + token, + self._verifiers, + SecuritySchemeType.OAUTH2, + AuthMethod.BEARER_JWT, + require_at_jwt=True, + ) if self._introspection is not None: return await self._introspect(token) raise errors.invalid_token() - async def _verify_at_jwt(self, token: str) -> Credential: - verifier = _select_verifier(token, self._verifiers) - if verifier is None: - raise errors.invalid_token("no issuer match") - claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=True) - return _credential_from_claims( - self.scheme, AuthMethod.BEARER_JWT, token, claims - ) - async def _introspect(self, token: str) -> Credential: config = self._introspection assert config is not None - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client - from litellm.types.llms.custom_http import httpxSpecialProvider - basic = base64.b64encode( f"{config.client_id}:{config.client_secret.get_secret_value()}".encode() ).decode() - client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + client = self._client_factory() response = await client.post( str(config.introspection_endpoint), data={"token": token}, @@ -329,7 +333,7 @@ async def _introspect(self, token: str) -> Credential: if config.audience and not set(token_audience) & set(config.audience): raise errors.invalid_token("audience mismatch") return Credential( - scheme=self.scheme, + scheme=SecuritySchemeType.OAUTH2, method=AuthMethod.OAUTH2_INTROSPECTION, subject=str(body.get(config.subject_field, "")), issuer=body.get("iss"), @@ -342,30 +346,24 @@ def challenge(self) -> str: return errors.bearer_challenge() -class OidcAuthenticator: - scheme = SecuritySchemeType.OPENID_CONNECT - - def __init__(self, jwt_verifiers: List[JwtVerifier]) -> None: +class OIDCAuthenticator: + def __init__(self, jwt_verifiers: List[JWTVerifier]) -> None: self._verifiers = jwt_verifiers async def authenticate(self, request: Request) -> Optional[Credential]: token = _extract_bearer(request) if token is None: return None - verifier = _select_verifier(token, self._verifiers) - if verifier is None: - raise errors.invalid_token("no issuer match") - claims = await _verify_jwt_off_loop(verifier, token) - return _credential_from_claims(self.scheme, AuthMethod.OIDC, token, claims) + return await _authenticate_bearer_jwt( + token, self._verifiers, SecuritySchemeType.OPENID_CONNECT, AuthMethod.OIDC + ) def challenge(self) -> str: return errors.bearer_challenge() -class MutualTlsAuthenticator: - scheme = SecuritySchemeType.MUTUAL_TLS - - def __init__(self, config: MutualTlsConfig, network: TrustedProxyConfig) -> None: +class MutualTLSAuthenticator: + def __init__(self, config: MutualTLSConfig, network: TrustedProxyConfig) -> None: self._config = config self._network = network @@ -374,7 +372,7 @@ async def authenticate(self, request: Request) -> Optional[Credential]: if cert is None: return None return Credential( - scheme=self.scheme, + scheme=SecuritySchemeType.MUTUAL_TLS, method=AuthMethod.MUTUAL_TLS, subject=cert.subject_dn, client_certificate=cert, @@ -398,19 +396,19 @@ def challenge(self) -> str: def build_authenticators( config: AuthConfig, *, basic_verifier: Optional[BasicAuthVerifier] = None ) -> List[Authenticator]: - verifiers = [JwtVerifier(provider) for provider in config.oidc_providers] + verifiers = [JWTVerifier(provider) for provider in config.oidc_providers] by_scheme: Dict[SecuritySchemeType, Authenticator] = {} if config.api_key is not None: - by_scheme[SecuritySchemeType.API_KEY] = ApiKeyAuthenticator(config.api_key) + by_scheme[SecuritySchemeType.API_KEY] = APIKeyAuthenticator(config.api_key) by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator( config.http_basic, verifiers, basic_verifier ) - by_scheme[SecuritySchemeType.OPENID_CONNECT] = OidcAuthenticator(verifiers) + by_scheme[SecuritySchemeType.OPENID_CONNECT] = OIDCAuthenticator(verifiers) by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator( verifiers, config.oauth2_introspection ) if config.mutual_tls.enabled: - by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTlsAuthenticator( + by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTLSAuthenticator( config.mutual_tls, config.network ) return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index 9026a23af2d1..41558a45de22 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -29,7 +29,7 @@ class HttpBasicConfig(BaseModel): realm: str = "litellm" -class OidcProviderConfig(BaseModel): +class OIDCProviderConfig(BaseModel): issuer: str audience: List[str] jwks_uri: Optional[AnyHttpUrl] = None @@ -50,7 +50,7 @@ class OAuth2IntrospectionConfig(BaseModel): audience: List[str] = Field(default_factory=list) -class MutualTlsConfig(BaseModel): +class MutualTLSConfig(BaseModel): enabled: bool = False forwarded_subject_header: Optional[str] = None @@ -60,7 +60,17 @@ class TrustedProxyConfig(BaseModel): trusted_proxy_cidrs: List[str] = Field(default_factory=list) -class SamlConfig(BaseModel): +class SessionConfig(BaseModel): + cookie: str = "litellm_session" + secure: bool = True + ttl_seconds: int = 3600 + max_size: int = 10000 + default_redirect_path: str = "/" + login_cookie: str = "litellm_oidc_txn" + login_state_ttl: int = 300 + + +class SAMLConfig(BaseModel): enabled: bool = False entity_id: str acs_url: str @@ -68,18 +78,13 @@ class SamlConfig(BaseModel): sp_key_file: Optional[str] = None sp_cert_file: Optional[str] = None allow_unsolicited: bool = False - session_cookie: str = "saml_session" - cookie_secure: bool = True - session_ttl_seconds: int = 3600 - session_max_size: int = 10000 - default_redirect_path: str = "/" xmlsec_binary: Optional[str] = None attribute_map: Dict[str, str] = Field( default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) ) @model_validator(mode="after") - def _require_idp_metadata(self) -> "SamlConfig": + def _require_idp_metadata(self) -> "SAMLConfig": if self.enabled and not self.idp_metadata.strip(): raise ValueError( "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" @@ -88,10 +93,6 @@ def _require_idp_metadata(self) -> "SamlConfig": class AuthConfig(BaseModel): - # First-match-wins precedence. HTTP precedes OPENID_CONNECT, so a bearer JWT - # is claimed by HttpAuthenticator (auth_method=bearer_jwt) and OidcAuthenticator - # never runs; both share the same JwtVerifiers and verify identically, so this - # only changes the auth_method label. Reorder if openIdConnect labeling matters. scheme_order: List[SecuritySchemeType] = Field( default_factory=lambda: [ SecuritySchemeType.API_KEY, @@ -103,9 +104,10 @@ class AuthConfig(BaseModel): ) api_key: Optional[ApiKeySchemeConfig] = Field(default_factory=ApiKeySchemeConfig) http_basic: HttpBasicConfig = Field(default_factory=HttpBasicConfig) - oidc_providers: List[OidcProviderConfig] = Field(default_factory=list) + oidc_providers: List[OIDCProviderConfig] = Field(default_factory=list) oauth2_introspection: Optional[OAuth2IntrospectionConfig] = None - mutual_tls: MutualTlsConfig = Field(default_factory=MutualTlsConfig) + mutual_tls: MutualTLSConfig = Field(default_factory=MutualTLSConfig) network: TrustedProxyConfig = Field(default_factory=TrustedProxyConfig) - saml: Optional[SamlConfig] = None + session: SessionConfig = Field(default_factory=SessionConfig) + saml: Optional[SAMLConfig] = None casbin_policy_path: Optional[str] = None diff --git a/litellm/proxy/auth_v2/rbac.py b/litellm/proxy/auth_v2/rbac.py index 2290fd671dd5..927a242e6e80 100644 --- a/litellm/proxy/auth_v2/rbac.py +++ b/litellm/proxy/auth_v2/rbac.py @@ -57,7 +57,7 @@ def has_required_scopes( ] -class RbacEngine: +class RBACEngine: def __init__(self, policy_path: Optional[str] = None) -> None: model = casbin.Model() model.load_model_from_text(_MODEL_TEXT) @@ -75,7 +75,7 @@ def enforce(self, principal: "Principal", obj: str, act: str) -> bool: self._enforcer.enforce(role.value, obj, act) for role in principal.roles ) - def has_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: + def has_any_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: allowed_values = {role.value for role in allowed} for role in principal.roles: if role.value in allowed_values: diff --git a/litellm/proxy/auth_v2/saml.py b/litellm/proxy/auth_v2/saml.py index d57811c8f575..5f0a2e8bfb9f 100644 --- a/litellm/proxy/auth_v2/saml.py +++ b/litellm/proxy/auth_v2/saml.py @@ -1,8 +1,7 @@ from __future__ import annotations -import secrets import time -from typing import Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse, Response @@ -13,9 +12,12 @@ from scim2_models import Email, Name from scim2_models import User as ScimUser -from .config import SamlConfig -from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +from .config import SAMLConfig from .resolver import ProvisioningStore +from .session import safe_relay_state + +if TYPE_CHECKING: + from .security import AuthSecurity _SINGLE_VALUE_TARGETS = { "email", @@ -85,18 +87,6 @@ def _claims_from_mapped(mapped: Dict[str, Any]) -> Dict[str, Any]: return claims -def _safe_relay_state(target: Optional[str], default: str) -> str: - if ( - target - and target.startswith("/") - and not target.startswith("//") - and "://" not in target - and "\\" not in target - ): - return target - return default - - def _metadata_source(idp_metadata: str) -> Dict[str, Any]: stripped = idp_metadata.strip() if stripped.startswith("<"): @@ -106,7 +96,7 @@ def _metadata_source(idp_metadata: str) -> Dict[str, Any]: return {"local": [idp_metadata]} -def _sp_config_dict(config: SamlConfig) -> Dict[str, Any]: +def _sp_config_dict(config: SAMLConfig) -> Dict[str, Any]: cfg: Dict[str, Any] = { "entityid": config.entity_id, "service": { @@ -132,21 +122,19 @@ def _sp_config_dict(config: SamlConfig) -> Dict[str, Any]: return cfg -def build_sp_client(config: SamlConfig) -> Saml2Client: +def build_sp_client(config: SAMLConfig) -> Saml2Client: conf = SPConfig() conf.load(_sp_config_dict(config)) return Saml2Client(config=conf) -class SamlSessionStore: - def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: - self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {} - self._seen_assertions: Dict[str, float] = {} +class SAMLProtocolStore: + def __init__(self, replay_ttl_seconds: int) -> None: self.outstanding: Dict[str, str] = {} - self._ttl = ttl_seconds - self._max_size = max_size + self._seen_assertions: Dict[str, float] = {} + self._replay_ttl = replay_ttl_seconds - def remember_request(self, request_id: str, relay_state: str = "/") -> None: + def remember_request(self, request_id: str, relay_state: str) -> None: self.outstanding[request_id] = relay_state def consume_assertion(self, assertion_id: str) -> bool: @@ -156,65 +144,16 @@ def consume_assertion(self, assertion_id: str) -> bool: } if assertion_id in self._seen_assertions: return False - self._seen_assertions[assertion_id] = now + self._ttl + self._seen_assertions[assertion_id] = now + self._replay_ttl return True - def create_session(self, identity: Dict[str, Any]) -> str: - now = time.time() - self._evict(now) - session_id = secrets.token_urlsafe(32) - self._sessions[session_id] = (now + self._ttl, identity) - return session_id - - def get(self, session_id: str) -> Optional[Dict[str, Any]]: - entry = self._sessions.get(session_id) - if entry is None: - return None - expires_at, identity = entry - if expires_at < time.time(): - self._sessions.pop(session_id, None) - return None - return identity - - def _evict(self, now: float) -> None: - for key in [k for k, (exp, _) in self._sessions.items() if exp < now]: - self._sessions.pop(key, None) - overflow = len(self._sessions) - self._max_size + 1 - if overflow > 0: - oldest = sorted(self._sessions, key=lambda k: self._sessions[k][0]) - for key in oldest[:overflow]: - self._sessions.pop(key, None) - - -class SamlAuthenticator: - scheme = SecuritySchemeType.HTTP - - def __init__(self, config: SamlConfig, session_store: SamlSessionStore) -> None: - self._config = config - self._store = session_store - - async def authenticate(self, request: Request) -> Optional[Credential]: - session_id = request.cookies.get(self._config.session_cookie) - if not session_id: - return None - identity = self._store.get(session_id) - if identity is None: - return None - return Credential( - scheme=self.scheme, - method=AuthMethod.SAML, - subject=identity["name_id"], - issuer=identity.get("issuer"), - claims=identity.get("claims", {}), - credential_ref=CredentialRef(token_id=session_id), - ) - - def challenge(self) -> str: - return "" - -def build_saml_router(config: SamlConfig, session_store: SamlSessionStore) -> APIRouter: +def build_saml_router(auth: "AuthSecurity") -> APIRouter: + config = auth.config.saml + assert config is not None + session = auth.config.session client = build_sp_client(config) + protocol = SAMLProtocolStore(session.ttl_seconds) router = APIRouter(prefix="/auth/saml", tags=["saml"]) @router.get("/metadata") @@ -226,11 +165,11 @@ async def metadata() -> Response: @router.get("/login") async def login(request: Request) -> RedirectResponse: - relay_state = _safe_relay_state( - request.query_params.get("next"), config.default_redirect_path + relay_state = safe_relay_state( + request.query_params.get("next"), session.default_redirect_path ) request_id, info = client.prepare_for_authenticate(relay_state=relay_state) - session_store.remember_request(request_id, relay_state) + protocol.remember_request(request_id, relay_state) location = dict(info["headers"]).get("Location") if not location: raise HTTPException(status_code=500, detail="no SAML redirect produced") @@ -246,7 +185,7 @@ async def assertion_consumer_service(request: Request) -> Response: authn_response = client.parse_authn_request_response( saml_response, BINDING_HTTP_POST, - outstanding=session_store.outstanding or None, + outstanding=protocol.outstanding or None, ) except Exception as exc: raise HTTPException( @@ -257,14 +196,12 @@ async def assertion_consumer_service(request: Request) -> Response: in_response_to = getattr(authn_response, "in_response_to", None) bound_relay = ( - session_store.outstanding.pop(in_response_to, None) - if in_response_to - else None + protocol.outstanding.pop(in_response_to, None) if in_response_to else None ) assertion = getattr(authn_response, "assertion", None) assertion_id = getattr(assertion, "id", None) - if assertion_id and not session_store.consume_assertion(assertion_id): + if assertion_id and not protocol.consume_assertion(assertion_id): raise HTTPException(status_code=401, detail="SAML assertion replay") name_id = authn_response.get_subject().text @@ -272,24 +209,25 @@ async def assertion_consumer_service(request: Request) -> Response: mapped = _map_attributes(ava, config.attribute_map) user = _user_from_mapped(name_id, mapped) - store: ProvisioningStore = request.app.state.auth_v2.resolver + store = cast(ProvisioningStore, auth.resolver) await store.upsert_user(user) - session_id = session_store.create_session( + session_id = auth.session_store.create_session( { - "name_id": name_id, + "method": "saml", + "subject": name_id, "issuer": authn_response.issuer(), "claims": _claims_from_mapped(mapped), } ) - target = _safe_relay_state(bound_relay, config.default_redirect_path) + target = safe_relay_state(bound_relay, session.default_redirect_path) response = RedirectResponse(target, status_code=303) response.set_cookie( - config.session_cookie, + session.cookie, session_id, httponly=True, samesite="lax", - secure=config.cookie_secure, + secure=session.secure, ) return response diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index c5858563df42..f7c8044a5ee3 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -1,78 +1,20 @@ -from __future__ import annotations - -from dataclasses import dataclass, field from typing import Annotated, Callable, List, Optional -from fastapi import FastAPI, Request, Security +from fastapi import Request, Security from fastapi.security import SecurityScopes from . import errors -from .authenticators import Authenticator, BasicAuthVerifier, build_authenticators +from .authenticators import ( + Authenticator, + BasicAuthVerifier, + build_authenticators, +) from .config import AuthConfig from .models import Principal from .network import resolve_network_context -from .rbac import RbacEngine, Role, has_required_scopes +from .rbac import RBACEngine, Role, has_required_scopes from .resolver import IdentityResolver - - -@dataclass -class AuthContext: - config: AuthConfig - authenticators: List[Authenticator] - resolver: IdentityResolver - rbac: RbacEngine = field(default_factory=RbacEngine) - - -def install_auth( - app: FastAPI, - config: AuthConfig, - resolver: IdentityResolver, - *, - rbac: Optional[RbacEngine] = None, - basic_verifier: Optional[BasicAuthVerifier] = None, - mount_scim: bool = True, - mount_oidc: bool = True, - mount_saml: bool = True, -) -> AuthContext: - """Wire the authenticators, resolver and optional routers onto the app. - - Deployment requirement for trusted-proxy IP resolution: uvicorn's - ``--proxy-headers`` (enabled by default) overwrites ``request.client`` from - ``X-Forwarded-For`` before this module's ``trusted_proxy_cidrs`` check runs, - which silently bypasses it. Run uvicorn with ``--no-proxy-headers`` and let - this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and - rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both. - """ - engine = rbac if rbac is not None else RbacEngine(config.casbin_policy_path) - ctx = AuthContext( - config, - build_authenticators(config, basic_verifier=basic_verifier), - resolver, - engine, - ) - app.state.auth_v2 = ctx - if mount_scim: - from .scim import build_scim_router - - app.include_router(build_scim_router()) - if mount_oidc and config.oidc_providers: - from .oidc import build_oidc_router - - app.include_router(build_oidc_router(config)) - if mount_saml and config.saml is not None and config.saml.enabled: - from .saml import SamlAuthenticator, SamlSessionStore, build_saml_router - - session_store = SamlSessionStore( - ttl_seconds=config.saml.session_ttl_seconds, - max_size=config.saml.session_max_size, - ) - ctx.authenticators.append(SamlAuthenticator(config.saml, session_store)) - app.include_router(build_saml_router(config.saml, session_store)) - return ctx - - -def _ctx(request: Request) -> AuthContext: - return request.app.state.auth_v2 +from .session import SessionAuthenticator, SessionStore def _combined_challenge(authenticators: List[Authenticator]) -> str: @@ -84,47 +26,82 @@ def _combined_challenge(authenticators: List[Authenticator]) -> str: return ", ".join(seen) -async def get_current_principal( - security_scopes: SecurityScopes, request: Request -) -> Principal: - ctx = _ctx(request) - credential = None - for authenticator in ctx.authenticators: - credential = await authenticator.authenticate(request) - if credential is not None: - break - if credential is None: - raise errors.unauthenticated(_combined_challenge(ctx.authenticators)) - - resolved = await ctx.resolver.resolve(credential) - principal = resolved.model_copy( - update={"network": resolve_network_context(request, ctx.config.network)} - ) - - if not has_required_scopes(security_scopes, principal): - raise errors.insufficient_scope() - return principal +class AuthSecurity: + """Enforcement layer consumed purely through FastAPI ``Security()``. + Construct once at the composition root and pass the bound methods + (``principal``, ``require_roles``, ``require_permission``) to ``Security()``; + routers receive the instance explicitly via ``build_*_router(auth)``. There is + no app mutation and no ``app.state``. -def require_roles(*allowed: Role) -> Callable[..., object]: - async def dependency( - request: Request, - principal: Annotated[Principal, Security(get_current_principal)], - ) -> Principal: - if not _ctx(request).rbac.has_role(principal, allowed): - raise errors.forbidden_role() - return principal - - return dependency + Deployment note for trusted-proxy IP resolution: uvicorn's ``--proxy-headers`` + (on by default) overwrites ``request.client`` from ``X-Forwarded-For`` before + this module's ``trusted_proxy_cidrs`` check runs, silently bypassing it. Run + uvicorn with ``--no-proxy-headers`` and let this module resolve the client IP, + or leave ``trusted_proxy_cidrs`` empty and rely on uvicorn's + ``--forwarded-allow-ips``. Do not enable both. + """ + def __init__( + self, + config: AuthConfig, + resolver: IdentityResolver, + rbac: Optional[RBACEngine] = None, + authenticators: Optional[List[Authenticator]] = None, + basic_verifier: Optional[BasicAuthVerifier] = None, + ) -> None: + self.config = config + self.resolver = resolver + self.rbac = rbac or RBACEngine(config.casbin_policy_path) + self.session_store = SessionStore( + config.session.ttl_seconds, config.session.max_size + ) + self.oauth_txn_store = SessionStore( + config.session.login_state_ttl, config.session.max_size + ) + chain = ( + list(authenticators) + if authenticators is not None + else build_authenticators(config, basic_verifier=basic_verifier) + ) + chain.append(SessionAuthenticator(config.session.cookie, self.session_store)) + self.authenticators = chain -def require_permission(obj: str, act: str) -> Callable[..., object]: - async def dependency( - request: Request, - principal: Annotated[Principal, Security(get_current_principal)], + async def principal( + self, security_scopes: SecurityScopes, request: Request ) -> Principal: - if not _ctx(request).rbac.enforce(principal, obj, act): - raise errors.forbidden_permission() + credential = None + for authenticator in self.authenticators: + credential = await authenticator.authenticate(request) + if credential is not None: + break + if credential is None: + raise errors.unauthenticated(_combined_challenge(self.authenticators)) + + resolved = await self.resolver.resolve(credential) + principal = resolved.model_copy( + update={"network": resolve_network_context(request, self.config.network)} + ) + if not has_required_scopes(security_scopes, principal): + raise errors.insufficient_scope() return principal - return dependency + def require_roles(self, *allowed: Role) -> Callable[..., object]: + async def dependency( + principal: Annotated[Principal, Security(self.principal)], + ) -> Principal: + if not self.rbac.has_any_role(principal, allowed): + raise errors.forbidden_role() + return principal + + return dependency + + def require_permission(self, obj: str, act: str) -> Callable[..., object]: + async def dependency( + principal: Annotated[Principal, Security(self.principal)], + ) -> Principal: + if not self.rbac.enforce(principal, obj, act): + raise errors.forbidden_permission() + return principal + + return dependency diff --git a/litellm/proxy/auth_v2/session.py b/litellm/proxy/auth_v2/session.py new file mode 100644 index 000000000000..fe671ce40f65 --- /dev/null +++ b/litellm/proxy/auth_v2/session.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import secrets +import time +from typing import Any, Dict, Optional, Tuple + +from fastapi import Request + +from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType + + +def safe_relay_state(target: Optional[str], default: str) -> str: + if ( + target + and target.startswith("/") + and not target.startswith("//") + and "://" not in target + and "\\" not in target + ): + return target + return default + + +class SessionStore: + def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: + self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._ttl = ttl_seconds + self._max_size = max_size + + def create_session(self, identity: Dict[str, Any]) -> str: + now = time.time() + self._evict(now) + session_id = secrets.token_urlsafe(32) + self._sessions[session_id] = (now + self._ttl, identity) + return session_id + + def get(self, session_id: str) -> Optional[Dict[str, Any]]: + entry = self._sessions.get(session_id) + if entry is None: + return None + expires_at, identity = entry + if expires_at < time.time(): + self._sessions.pop(session_id, None) + return None + return identity + + def pop(self, session_id: str) -> Optional[Dict[str, Any]]: + entry = self._sessions.pop(session_id, None) + if entry is None: + return None + expires_at, identity = entry + if expires_at < time.time(): + return None + return identity + + def _evict(self, now: float) -> None: + for key in [k for k, (exp, _) in self._sessions.items() if exp < now]: + self._sessions.pop(key, None) + overflow = len(self._sessions) - self._max_size + 1 + if overflow > 0: + oldest = sorted(self._sessions, key=lambda k: self._sessions[k][0]) + for key in oldest[:overflow]: + self._sessions.pop(key, None) + + +class SessionAuthenticator: + def __init__(self, cookie_name: str, store: SessionStore) -> None: + self._cookie_name = cookie_name + self._store = store + + async def authenticate(self, request: Request) -> Optional[Credential]: + session_id = request.cookies.get(self._cookie_name) + if not session_id: + return None + identity = self._store.get(session_id) + if identity is None: + return None + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod(identity["method"]), + subject=identity["subject"], + issuer=identity.get("issuer"), + claims=identity.get("claims", {}), + credential_ref=CredentialRef(token_id=session_id), + ) + + def challenge(self) -> str: + return "" From 4ec48302a5b2870c2c963592a373a8e2e93c799b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:10:08 -0700 Subject: [PATCH 20/51] docs(auth_v2): one-line docstrings on AuthSecurity Security() entrypoints (D1) --- litellm/proxy/auth_v2/security.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index f7c8044a5ee3..d311019bec2e 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -70,6 +70,7 @@ def __init__( async def principal( self, security_scopes: SecurityScopes, request: Request ) -> Principal: + """Resolve the caller to a Principal, enforcing scheme OR and required scopes.""" credential = None for authenticator in self.authenticators: credential = await authenticator.authenticate(request) @@ -87,6 +88,8 @@ async def principal( return principal def require_roles(self, *allowed: Role) -> Callable[..., object]: + """Security() dependency that admits a principal holding any allowed role (hierarchy-aware).""" + async def dependency( principal: Annotated[Principal, Security(self.principal)], ) -> Principal: @@ -97,6 +100,8 @@ async def dependency( return dependency def require_permission(self, obj: str, act: str) -> Callable[..., object]: + """Security() dependency that admits a principal whose roles permit obj/act via Casbin.""" + async def dependency( principal: Annotated[Principal, Security(self.principal)], ) -> Principal: From 5f02c88369fbce04e789bbeed2ad668885d9e423 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:15:43 -0700 Subject: [PATCH 21/51] fix(auth_v2): authenticate OIDC login sessions and adapt SCIM to AuthSecurity The OIDC callback now mints a server-side session and sets the shared session cookie (httponly, secure, samesite=lax) before redirecting, so login yields an authenticated session that the shared SessionAuthenticator resolves; previously it returned the user record as JSON and left the caller unauthenticated. The login flow owns its CSRF protection without Starlette SessionMiddleware: it stores state, nonce and the PKCE (S256) verifier in the short-lived oauth_txn_store keyed by a temporary cookie, then on callback consumes the transaction one-time, checks the returned state, exchanges the code with the verifier and validates the nonce against the id_token. A replayed or expired state finds no transaction and returns 400. SCIM moves onto the AuthSecurity DI surface: build_scim_router(auth) reads auth.resolver and guards writes with Security(auth.principal, scopes= ["scim:write"]) instead of app.state and get_current_principal. --- litellm/proxy/auth_v2/__init__.py | 4 + litellm/proxy/auth_v2/oidc.py | 114 ++++++++++++++++++---- litellm/proxy/auth_v2/scim.py | 153 ++++++++++++++---------------- 3 files changed, 174 insertions(+), 97 deletions(-) diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 032bb0146453..2bd9527afc78 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -10,9 +10,11 @@ TrustedProxyConfig, ) from .models import Principal +from .oidc import build_oidc_router from .rbac import Role from .resolver import IdentityResolver, InMemoryIdentityStore, ProvisioningStore from .saml import build_saml_router +from .scim import build_scim_router from .security import AuthSecurity __all__ = [ @@ -32,4 +34,6 @@ "SessionConfig", "SAMLConfig", "build_saml_router", + "build_scim_router", + "build_oidc_router", ] diff --git a/litellm/proxy/auth_v2/oidc.py b/litellm/proxy/auth_v2/oidc.py index e463981f6e6e..287125978460 100644 --- a/litellm/proxy/auth_v2/oidc.py +++ b/litellm/proxy/auth_v2/oidc.py @@ -1,18 +1,24 @@ from __future__ import annotations import re -from typing import Any, Dict +from typing import TYPE_CHECKING, Any, Dict, cast from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi.responses import RedirectResponse from scim2_models import User as ScimUser -from .config import AuthConfig, OidcProviderConfig +from .config import OIDCProviderConfig from .resolver import ProvisioningStore +from .session import safe_relay_state +if TYPE_CHECKING: + from .security import AuthSecurity -def _provider_key(provider: OidcProviderConfig) -> str: +_CLAIM_KEYS = ("email", "preferred_username", "name", "groups", "roles") + + +def _provider_key(provider: OIDCProviderConfig) -> str: return re.sub(r"[^a-z0-9]+", "-", provider.issuer.lower()).strip("-") @@ -24,9 +30,15 @@ def _user_from_userinfo(userinfo: Dict[str, Any]) -> ScimUser: ) -def build_oidc_router(config: AuthConfig) -> APIRouter: +def _mapped_claims(userinfo: Dict[str, Any]) -> Dict[str, Any]: + return {key: userinfo[key] for key in _CLAIM_KEYS if userinfo.get(key) is not None} + + +def build_oidc_router(auth: AuthSecurity) -> APIRouter: + session = auth.config.session + issuers = {_provider_key(p): p.issuer for p in auth.config.oidc_providers} oauth = OAuth() - for provider in config.oidc_providers: + for provider in auth.config.oidc_providers: oauth.register( name=_provider_key(provider), server_metadata_url=f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration", @@ -36,30 +48,98 @@ def build_oidc_router(config: AuthConfig) -> APIRouter: if provider.client_secret else None ), - client_kwargs={"scope": " ".join(provider.login_scopes)}, + client_kwargs={ + "scope": " ".join(provider.login_scopes), + "code_challenge_method": "S256", + }, ) router = APIRouter(prefix="/auth/oidc", tags=["oidc"]) @router.get("/{provider}/login") - async def login(provider: str, request: Request) -> Any: + async def login(provider: str, request: Request) -> RedirectResponse: client = oauth.create_client(provider) if client is None: raise HTTPException(status_code=404, detail="unknown provider") - redirect_uri = request.url_for("oidc_callback", provider=provider) - return await client.authorize_redirect(request, str(redirect_uri)) + redirect_uri = str(request.url_for("oidc_callback", provider=provider)) + relay = safe_relay_state( + request.query_params.get("next"), session.default_redirect_path + ) + authorization = await client.create_authorization_url(redirect_uri) + txn_id = auth.oauth_txn_store.create_session( + { + "provider": provider, + "state": authorization["state"], + "nonce": authorization.get("nonce"), + "code_verifier": authorization.get("code_verifier"), + "redirect_uri": redirect_uri, + "relay": relay, + } + ) + response = RedirectResponse(authorization["url"], status_code=303) + response.set_cookie( + session.login_cookie, + txn_id, + httponly=True, + samesite="lax", + secure=session.secure, + max_age=session.login_state_ttl, + ) + return response @router.get("/{provider}/callback", name="oidc_callback") - async def callback(provider: str, request: Request) -> JSONResponse: + async def callback(provider: str, request: Request) -> RedirectResponse: client = oauth.create_client(provider) if client is None: raise HTTPException(status_code=404, detail="unknown provider") - token = await client.authorize_access_token(request) - userinfo = token.get("userinfo") - if userinfo is None: + txn_id = request.cookies.get(session.login_cookie) + txn = auth.oauth_txn_store.pop(txn_id) if txn_id else None + if txn is None or txn.get("provider") != provider: + raise HTTPException( + status_code=400, detail="invalid or expired login state" + ) + returned_state = request.query_params.get("state") + if not returned_state or returned_state != txn["state"]: + raise HTTPException(status_code=400, detail="state mismatch") + error = request.query_params.get("error") + if error: + raise HTTPException(status_code=400, detail=error) + code = request.query_params.get("code") + if not code: + raise HTTPException(status_code=400, detail="missing authorization code") + + token = await client.fetch_access_token( + redirect_uri=txn["redirect_uri"], + code=code, + code_verifier=txn.get("code_verifier"), + state=txn["state"], + ) + if token.get("id_token"): + userinfo = await client.parse_id_token(token, nonce=txn.get("nonce")) + else: userinfo = await client.userinfo(token=token) - store: ProvisioningStore = request.app.state.auth_v2.resolver - stored = await store.upsert_user(_user_from_userinfo(dict(userinfo))) - return JSONResponse(content=stored.model_dump()) + info = dict(userinfo) + + store = cast(ProvisioningStore, auth.resolver) + await store.upsert_user(_user_from_userinfo(info)) + + session_id = auth.session_store.create_session( + { + "method": "oidc", + "subject": info.get("sub"), + "issuer": info.get("iss") or issuers.get(provider), + "claims": _mapped_claims(info), + } + ) + target = safe_relay_state(txn.get("relay"), session.default_redirect_path) + response = RedirectResponse(target, status_code=303) + response.set_cookie( + session.cookie, + session_id, + httponly=True, + samesite="lax", + secure=session.secure, + ) + return response return router diff --git a/litellm/proxy/auth_v2/scim.py b/litellm/proxy/auth_v2/scim.py index eafe83f88784..64255db266a9 100644 --- a/litellm/proxy/auth_v2/scim.py +++ b/litellm/proxy/auth_v2/scim.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, cast from fastapi import APIRouter, Query, Request, Response, Security, status from fastapi.responses import JSONResponse @@ -24,13 +24,11 @@ ) from .resolver import ProvisioningStore -from .security import get_current_principal - -R = TypeVar("R", bound=Resource) +if TYPE_CHECKING: + from .security import AuthSecurity -def _store(request: Request) -> ProvisioningStore: - return request.app.state.auth_v2.resolver +R = TypeVar("R", bound=Resource) def _error(status_code: int, detail: str) -> JSONResponse: @@ -89,63 +87,10 @@ def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: return resource.model_dump(scim_ctx=ctx) -def _build_discovery_router() -> APIRouter: - router = APIRouter() - - @router.get("/ServiceProviderConfig") - async def service_provider_config() -> Response: - config = ServiceProviderConfig( - patch=Patch(supported=True), - bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), - filter=Filter(supported=False, max_results=0), - change_password=ChangePassword(supported=False), - sort=Sort(supported=False), - etag=None, - authentication_schemes=[], - ) - return JSONResponse(content=config.model_dump()) - - @router.get("/ResourceTypes") - async def resource_types() -> Response: - types = [ - ResourceType( - id="User", - name="User", - endpoint="/Users", - schema="urn:ietf:params:scim:schemas:core:2.0:User", - ), - ResourceType( - id="Group", - name="Group", - endpoint="/Groups", - schema="urn:ietf:params:scim:schemas:core:2.0:Group", - ), - ] - listing: ListResponse[ResourceType] = ListResponse[ResourceType]( - total_results=len(types), - start_index=1, - items_per_page=len(types), - resources=types, - ) - return JSONResponse(content=listing.model_dump()) - - @router.get("/Schemas") - async def schemas() -> Response: - resources = [User.to_schema(), Group.to_schema()] - listing: ListResponse[Schema] = ListResponse[Schema]( - total_results=len(resources), - start_index=1, - items_per_page=len(resources), - resources=resources, - ) - return JSONResponse(content=listing.model_dump()) - - return router - - -def _build_protected_router() -> APIRouter: +def _build_protected_router(auth: AuthSecurity) -> APIRouter: + store = cast(ProvisioningStore, auth.resolver) protected = APIRouter( - dependencies=[Security(get_current_principal, scopes=["scim:write"])], + dependencies=[Security(auth.principal, scopes=["scim:write"])], ) @protected.post("/Users", status_code=status.HTTP_201_CREATED) @@ -154,22 +99,21 @@ async def create_user(request: Request) -> Response: user = await _parse(request, User) except ValidationError as exc: return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - stored = await _store(request).upsert_user(user) + stored = await store.upsert_user(user) return JSONResponse( status_code=status.HTTP_201_CREATED, content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), ) @protected.get("/Users/{resource_id}") - async def get_user(resource_id: str, request: Request) -> Response: - user = await _store(request).get_user(resource_id) + async def get_user(resource_id: str) -> Response: + user = await store.get_user(resource_id) if user is None: return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") return JSONResponse(content=_dump(user, Context.RESOURCE_QUERY_RESPONSE)) @protected.patch("/Users/{resource_id}") async def patch_user(resource_id: str, request: Request) -> Response: - store = _store(request) user = await store.get_user(resource_id) if user is None: return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") @@ -182,8 +126,7 @@ async def patch_user(resource_id: str, request: Request) -> Response: return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) @protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) - async def deactivate_user(resource_id: str, request: Request) -> Response: - store = _store(request) + async def deactivate_user(resource_id: str) -> Response: if await store.get_user(resource_id) is None: return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") await store.deactivate_user(resource_id) @@ -191,10 +134,9 @@ async def deactivate_user(resource_id: str, request: Request) -> Response: @protected.get("/Users") async def list_users( - request: Request, filter_expr: Optional[str] = Query(default=None, alias="filter"), ) -> Response: - users = await _store(request).list_users(filter_expr) + users = await store.list_users(filter_expr) listing: ListResponse[User] = ListResponse[User]( total_results=len(users), start_index=1, @@ -209,22 +151,21 @@ async def create_group(request: Request) -> Response: group = await _parse(request, Group) except ValidationError as exc: return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - stored = await _store(request).upsert_group(group) + stored = await store.upsert_group(group) return JSONResponse( status_code=status.HTTP_201_CREATED, content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), ) @protected.get("/Groups/{resource_id}") - async def get_group(resource_id: str, request: Request) -> Response: - group = await _store(request).get_group(resource_id) + async def get_group(resource_id: str) -> Response: + group = await store.get_group(resource_id) if group is None: return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") return JSONResponse(content=_dump(group, Context.RESOURCE_QUERY_RESPONSE)) @protected.patch("/Groups/{resource_id}") async def patch_group(resource_id: str, request: Request) -> Response: - store = _store(request) group = await store.get_group(resource_id) if group is None: return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") @@ -237,8 +178,7 @@ async def patch_group(resource_id: str, request: Request) -> Response: return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) @protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) - async def delete_group(resource_id: str, request: Request) -> Response: - store = _store(request) + async def delete_group(resource_id: str) -> Response: if await store.get_group(resource_id) is None: return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") await store.delete_group(resource_id) @@ -246,10 +186,9 @@ async def delete_group(resource_id: str, request: Request) -> Response: @protected.get("/Groups") async def list_groups( - request: Request, filter_expr: Optional[str] = Query(default=None, alias="filter"), ) -> Response: - groups = await _store(request).list_groups(filter_expr) + groups = await store.list_groups(filter_expr) listing: ListResponse[Group] = ListResponse[Group]( total_results=len(groups), start_index=1, @@ -261,8 +200,62 @@ async def list_groups( return protected -def build_scim_router() -> APIRouter: +def _build_discovery_router() -> APIRouter: + router = APIRouter() + + @router.get("/ServiceProviderConfig") + async def service_provider_config() -> Response: + config = ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=False, max_results=0), + change_password=ChangePassword(supported=False), + sort=Sort(supported=False), + etag=None, + authentication_schemes=[], + ) + return JSONResponse(content=config.model_dump()) + + @router.get("/ResourceTypes") + async def resource_types() -> Response: + types = [ + ResourceType( + id="User", + name="User", + endpoint="/Users", + schema="urn:ietf:params:scim:schemas:core:2.0:User", + ), + ResourceType( + id="Group", + name="Group", + endpoint="/Groups", + schema="urn:ietf:params:scim:schemas:core:2.0:Group", + ), + ] + listing: ListResponse[ResourceType] = ListResponse[ResourceType]( + total_results=len(types), + start_index=1, + items_per_page=len(types), + resources=types, + ) + return JSONResponse(content=listing.model_dump()) + + @router.get("/Schemas") + async def schemas() -> Response: + resources = [User.to_schema(), Group.to_schema()] + listing: ListResponse[Schema] = ListResponse[Schema]( + total_results=len(resources), + start_index=1, + items_per_page=len(resources), + resources=resources, + ) + return JSONResponse(content=listing.model_dump()) + + return router + + +def build_scim_router(auth: AuthSecurity) -> APIRouter: router = APIRouter(prefix="/scim/v2", tags=["scim"]) - router.include_router(_build_protected_router()) + router.include_router(_build_protected_router(auth)) router.include_router(_build_discovery_router()) return router From 0ee4397a59964d501008c954092e27b8fc4c75fd Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:24:10 -0700 Subject: [PATCH 22/51] test(auth_v2): adapt the suite to the AuthSecurity refactor and renames Repoint the whole test surface off install_auth/app.state onto the AuthSecurity composition root: build AuthSecurity(config, store, ...) and declare routes with Security(auth.principal[, scopes]), auth.require_roles, auth.require_permission; mount routers via build_*_router(auth). Apply the PEP8 renames (JWTVerifier, APIKeyAuthenticator, OIDCAuthenticator, MutualTLSAuthenticator, OIDCProviderConfig, SAMLConfig, MutualTLSConfig, RBACEngine.has_any_role). SAML moves to the shared SessionStore + "litellm_session" cookie and session.safe_relay_state; the build_authenticators tests assert concrete types now that the scheme attribute is gone. No coverage lost; 151 tests pass. Note: routes use the default-value Security() style instead of Annotated[...] because this module runs under future annotations, where an Annotated marker is stringified and FastAPI re-evaluates it in module globals, which cannot see the closure-local auth instance. --- .../proxy/auth_v2/auth_v2_helpers.py | 2 +- tests/test_litellm/proxy/auth_v2/conftest.py | 14 ++-- .../proxy/auth_v2/test_authenticators.py | 81 +++++++++---------- .../test_litellm/proxy/auth_v2/test_config.py | 14 ++-- tests/test_litellm/proxy/auth_v2/test_oidc.py | 4 +- tests/test_litellm/proxy/auth_v2/test_rbac.py | 18 ++--- tests/test_litellm/proxy/auth_v2/test_saml.py | 39 ++++----- tests/test_litellm/proxy/auth_v2/test_scim.py | 22 +++-- .../proxy/auth_v2/test_security.py | 54 ++++++------- 9 files changed, 116 insertions(+), 132 deletions(-) diff --git a/tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py b/tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py index d2e503ea93c3..7a22d6fe2acd 100644 --- a/tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py +++ b/tests/test_litellm/proxy/auth_v2/auth_v2_helpers.py @@ -17,7 +17,7 @@ def __init__(self, key: Any) -> None: class FakeJwksClient: """Stands in for PyJWKClient. Returns one fixed key for every token so - JwtVerifier performs a real RS256 signature check against it via PyJWT.""" + JWTVerifier performs a real RS256 signature check against it via PyJWT.""" def __init__(self, public_key: Any) -> None: self._public_key = public_key diff --git a/tests/test_litellm/proxy/auth_v2/conftest.py b/tests/test_litellm/proxy/auth_v2/conftest.py index fe4df1984e04..2131ab25c783 100644 --- a/tests/test_litellm/proxy/auth_v2/conftest.py +++ b/tests/test_litellm/proxy/auth_v2/conftest.py @@ -6,8 +6,8 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from litellm.proxy.auth_v2.authenticators import JwtVerifier -from litellm.proxy.auth_v2.config import OidcProviderConfig +from litellm.proxy.auth_v2.authenticators import JWTVerifier +from litellm.proxy.auth_v2.config import OIDCProviderConfig from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient, TokenFactory @@ -39,13 +39,13 @@ def token_factory(rsa_keypair: Tuple[bytes, Any]) -> TokenFactory: @pytest.fixture -def oidc_provider() -> OidcProviderConfig: - return OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]) +def oidc_provider() -> OIDCProviderConfig: + return OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]) @pytest.fixture def jwt_verifier( - rsa_keypair: Tuple[bytes, Any], oidc_provider: OidcProviderConfig -) -> JwtVerifier: + rsa_keypair: Tuple[bytes, Any], oidc_provider: OIDCProviderConfig +) -> JWTVerifier: _, public_key = rsa_keypair - return JwtVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) + return JWTVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index 30a18d461920..b3da31202cec 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -7,13 +7,13 @@ import pytest from litellm.proxy.auth_v2.authenticators import ( - ApiKeyAuthenticator, + APIKeyAuthenticator, HttpAuthenticator, InMemoryBasicAuthStore, - JwtVerifier, - MutualTlsAuthenticator, + JWTVerifier, + MutualTLSAuthenticator, OAuth2Authenticator, - OidcAuthenticator, + OIDCAuthenticator, build_authenticators, hash_basic_password, ) @@ -21,13 +21,13 @@ ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, - MutualTlsConfig, + MutualTLSConfig, OAuth2IntrospectionConfig, - OidcProviderConfig, + OIDCProviderConfig, TrustedProxyConfig, ) from litellm.proxy.auth_v2.errors import AuthError -from litellm.proxy.auth_v2.models import AuthMethod, SecuritySchemeType +from litellm.proxy.auth_v2.models import AuthMethod from auth_v2_helpers import ( TEST_AUDIENCE, @@ -37,7 +37,7 @@ ) # --------------------------------------------------------------------------- # -# JwtVerifier: every RFC 7519 check must be enforced. +# JWTVerifier: every RFC 7519 check must be enforced. # --------------------------------------------------------------------------- # @@ -51,7 +51,7 @@ def test_jwt_verifier_rejects_bad_signature( rsa_keypair, other_rsa_keypair, oidc_provider, token_factory ): _, public_key = rsa_keypair - verifier = JwtVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) + verifier = JWTVerifier(oidc_provider, jwks_client=FakeJwksClient(public_key)) other_pem, _ = other_rsa_keypair forged = token_factory.mint(private_pem=other_pem) with pytest.raises(AuthError) as exc: @@ -99,12 +99,12 @@ def test_jwt_verifier_enforces_at_jwt_typ(jwt_verifier, token_factory): # --------------------------------------------------------------------------- # -# ApiKeyAuthenticator +# APIKeyAuthenticator # --------------------------------------------------------------------------- # async def test_api_key_authenticator_extracts_header(): - auth = ApiKeyAuthenticator(ApiKeySchemeConfig(header_name="x-litellm-api-key")) + auth = APIKeyAuthenticator(ApiKeySchemeConfig(header_name="x-litellm-api-key")) request = make_request(headers={"x-litellm-api-key": "sk-secret-value"}) credential = await auth.authenticate(request) assert credential is not None @@ -115,7 +115,7 @@ async def test_api_key_authenticator_extracts_header(): async def test_api_key_authenticator_returns_none_when_absent(): - auth = ApiKeyAuthenticator(ApiKeySchemeConfig()) + auth = APIKeyAuthenticator(ApiKeySchemeConfig()) assert await auth.authenticate(make_request()) is None @@ -127,8 +127,8 @@ async def test_api_key_authenticator_returns_none_when_absent(): def _http_auth( public_key: Any, *, basic: HttpBasicConfig = None, basic_verifier=None ) -> HttpAuthenticator: - verifier = JwtVerifier( - OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + verifier = JWTVerifier( + OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), ) return HttpAuthenticator( @@ -265,8 +265,8 @@ def test_hash_basic_password_is_salted_and_verifiable(): def _oauth2(public_key: Any) -> OAuth2Authenticator: - verifier = JwtVerifier( - OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), + verifier = JWTVerifier( + OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), ) return OAuth2Authenticator([verifier], introspection=None) @@ -373,16 +373,16 @@ async def test_oauth2_introspection_non_200_raises(): # --------------------------------------------------------------------------- # -# OidcAuthenticator +# OIDCAuthenticator # --------------------------------------------------------------------------- # -def _oidc(public_key: Any) -> OidcAuthenticator: - verifier = JwtVerifier( - OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), +def _oidc(public_key: Any) -> OIDCAuthenticator: + verifier = JWTVerifier( + OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), ) - return OidcAuthenticator([verifier]) + return OIDCAuthenticator([verifier]) async def test_oidc_valid_token_sets_oidc_method(rsa_keypair, token_factory): @@ -405,7 +405,7 @@ async def test_oidc_unknown_issuer_raises(rsa_keypair, token_factory): # --------------------------------------------------------------------------- # -# MutualTlsAuthenticator +# MutualTLSAuthenticator # --------------------------------------------------------------------------- # @@ -413,12 +413,12 @@ async def test_oidc_unknown_issuer_raises(rsa_keypair, token_factory): _TRUSTED_NET = TrustedProxyConfig(trusted_proxy_cidrs=["203.0.113.0/24"]) -def _mtls(config: MutualTlsConfig, network: TrustedProxyConfig = None): - return MutualTlsAuthenticator(config, network or _TRUSTED_NET) +def _mtls(config: MutualTLSConfig, network: TrustedProxyConfig = None): + return MutualTLSAuthenticator(config, network or _TRUSTED_NET) async def test_mtls_reads_forwarded_subject_header_from_trusted_peer(): - auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) + auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) request = make_request(headers={"x-client-dn": "CN=svc-a,O=Co,C=US"}) credential = await auth.authenticate(request) assert credential is not None @@ -429,7 +429,7 @@ async def test_mtls_reads_forwarded_subject_header_from_trusted_peer(): async def test_mtls_forwarded_header_from_untrusted_peer_is_ignored(): # spoofing guard: a client that is not a trusted proxy cannot forge the DN header - auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) + auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) request = make_request( headers={"x-client-dn": "CN=attacker"}, client=("8.8.8.8", 4444) ) @@ -437,12 +437,12 @@ async def test_mtls_forwarded_header_from_untrusted_peer_is_ignored(): async def test_mtls_forwarded_header_absent_returns_none(): - auth = _mtls(MutualTlsConfig(enabled=True, forwarded_subject_header="x-client-dn")) + auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) assert await auth.authenticate(make_request()) is None async def test_mtls_reads_asgi_tls_extension(): - auth = _mtls(MutualTlsConfig(enabled=True)) + auth = _mtls(MutualTLSConfig(enabled=True)) request = make_request( scope_extra={"extensions": {"tls": {"client_cert_name": "CN=from-asgi"}}} ) @@ -452,7 +452,7 @@ async def test_mtls_reads_asgi_tls_extension(): async def test_mtls_no_cert_returns_none(): - auth = _mtls(MutualTlsConfig(enabled=True)) + auth = _mtls(MutualTLSConfig(enabled=True)) assert await auth.authenticate(make_request()) is None @@ -463,24 +463,23 @@ async def test_mtls_no_cert_returns_none(): def test_build_authenticators_follows_scheme_order(): config = AuthConfig() - authenticators = build_authenticators(config) - schemes = [a.scheme for a in authenticators] + types = [type(a) for a in build_authenticators(config)] # mutual_tls disabled by default -> excluded - assert schemes == [ - SecuritySchemeType.API_KEY, - SecuritySchemeType.HTTP, - SecuritySchemeType.OPENID_CONNECT, - SecuritySchemeType.OAUTH2, + assert types == [ + APIKeyAuthenticator, + HttpAuthenticator, + OIDCAuthenticator, + OAuth2Authenticator, ] def test_build_authenticators_omits_api_key_when_unconfigured(): config = AuthConfig(api_key=None) - schemes = [a.scheme for a in build_authenticators(config)] - assert SecuritySchemeType.API_KEY not in schemes + types = [type(a) for a in build_authenticators(config)] + assert APIKeyAuthenticator not in types def test_build_authenticators_includes_mtls_when_enabled(): - config = AuthConfig(mutual_tls=MutualTlsConfig(enabled=True)) - schemes = [a.scheme for a in build_authenticators(config)] - assert SecuritySchemeType.MUTUAL_TLS == schemes[-1] + config = AuthConfig(mutual_tls=MutualTLSConfig(enabled=True)) + types = [type(a) for a in build_authenticators(config)] + assert types[-1] is MutualTLSAuthenticator diff --git a/tests/test_litellm/proxy/auth_v2/test_config.py b/tests/test_litellm/proxy/auth_v2/test_config.py index 86da462fff76..fd4c26100be3 100644 --- a/tests/test_litellm/proxy/auth_v2/test_config.py +++ b/tests/test_litellm/proxy/auth_v2/test_config.py @@ -5,23 +5,23 @@ from litellm.proxy.auth_v2.config import ( OAuth2IntrospectionConfig, - OidcProviderConfig, - SamlConfig, + OIDCProviderConfig, + SAMLConfig, ) def test_saml_config_requires_idp_metadata_when_enabled(): with pytest.raises(ValidationError): - SamlConfig(enabled=True, entity_id="sp", acs_url="https://sp/acs") + SAMLConfig(enabled=True, entity_id="sp", acs_url="https://sp/acs") def test_saml_config_allows_empty_metadata_when_disabled(): - config = SamlConfig(enabled=False, entity_id="sp", acs_url="https://sp/acs") + config = SAMLConfig(enabled=False, entity_id="sp", acs_url="https://sp/acs") assert config.idp_metadata == "" def test_saml_config_accepts_inline_metadata(): - config = SamlConfig( + config = SAMLConfig( enabled=True, entity_id="sp", acs_url="https://sp/acs", @@ -32,11 +32,11 @@ def test_saml_config_accepts_inline_metadata(): def test_oidc_provider_requires_audience(): with pytest.raises(ValidationError): - OidcProviderConfig(issuer="https://idp.example.com") + OIDCProviderConfig(issuer="https://idp.example.com") def test_oidc_provider_defaults_to_rs256(): - provider = OidcProviderConfig(issuer="https://idp.example.com", audience=["x"]) + provider = OIDCProviderConfig(issuer="https://idp.example.com", audience=["x"]) assert provider.algorithms == ["RS256"] assert provider.require_at_jwt is False diff --git a/tests/test_litellm/proxy/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py index 52218423d6ec..6c816dd338f8 100644 --- a/tests/test_litellm/proxy/auth_v2/test_oidc.py +++ b/tests/test_litellm/proxy/auth_v2/test_oidc.py @@ -1,6 +1,6 @@ from __future__ import annotations -from litellm.proxy.auth_v2.config import OidcProviderConfig +from litellm.proxy.auth_v2.config import OIDCProviderConfig from litellm.proxy.auth_v2.oidc import _provider_key, _user_from_userinfo from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore @@ -26,7 +26,7 @@ def test_userinfo_falls_back_to_email_when_no_preferred_username(): def test_provider_key_sanitizes_issuer_url(): key = _provider_key( - OidcProviderConfig(issuer="https://Login.Example.com/realm", audience=["x"]) + OIDCProviderConfig(issuer="https://Login.Example.com/realm", audience=["x"]) ) assert key == "https-login-example-com-realm" assert " " not in key diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index d16432b9fb30..d0094593995a 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -4,7 +4,7 @@ from fastapi.security import SecurityScopes from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.proxy.auth_v2.rbac import RbacEngine, Role, has_required_scopes +from litellm.proxy.auth_v2.rbac import RBACEngine, Role, has_required_scopes def _principal(*, scopes=None, roles=None) -> Principal: @@ -38,13 +38,13 @@ def test_empty_required_scopes_always_passes(): # --------------------------------------------------------------------------- # -# RbacEngine.has_role honors the role hierarchy (Casbin g-rules) +# RBACEngine.has_role honors the role hierarchy (Casbin g-rules) # --------------------------------------------------------------------------- # @pytest.fixture -def engine() -> RbacEngine: - return RbacEngine() +def engine() -> RBACEngine: + return RBACEngine() @pytest.mark.parametrize( @@ -61,7 +61,7 @@ def engine() -> RbacEngine: ], ) def test_has_role_inherits_down_the_hierarchy(engine, held, gate): - assert engine.has_role(_principal(roles=[held]), (gate,)) + assert engine.has_any_role(_principal(roles=[held]), (gate,)) @pytest.mark.parametrize( @@ -73,15 +73,15 @@ def test_has_role_inherits_down_the_hierarchy(engine, held, gate): ], ) def test_has_role_does_not_climb_the_hierarchy(engine, held, gate): - assert not engine.has_role(_principal(roles=[held]), (gate,)) + assert not engine.has_any_role(_principal(roles=[held]), (gate,)) def test_has_role_false_without_roles(engine): - assert not engine.has_role(_principal(), (Role.TEAM_MEMBER,)) + assert not engine.has_any_role(_principal(), (Role.TEAM_MEMBER,)) # --------------------------------------------------------------------------- # -# RbacEngine.enforce against the default policy +# RBACEngine.enforce against the default policy # --------------------------------------------------------------------------- # @@ -115,7 +115,7 @@ def test_enforce_false_without_roles(engine): def test_csv_policy_overrides_defaults(tmp_path): policy = tmp_path / "policy.csv" policy.write_text("p, platform_viewer, /reports, POST\n") - engine = RbacEngine(policy_path=str(policy)) + engine = RBACEngine(policy_path=str(policy)) # the operator rule is honored assert engine.enforce(_principal(roles=[Role.PLATFORM_VIEWER]), "/reports", "POST") diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index e3c5f655fe0b..f5ade9b0863d 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -94,7 +94,7 @@ def saml_env(tmp_path: Path) -> SamlEnv: from saml2.saml import NAMEID_FORMAT_EMAILADDRESS from saml2.server import Server - from litellm.proxy.auth_v2.config import SamlConfig + from litellm.proxy.auth_v2.config import SAMLConfig idp_key, idp_cert = _gen_cert(tmp_path, "idp") sp_key, sp_cert = _gen_cert(tmp_path, "sp") @@ -142,7 +142,7 @@ def saml_env(tmp_path: Path) -> SamlEnv: idp = Server(config=idp_conf) idp_metadata = str(entity_descriptor(idp.config)) - config = SamlConfig( + config = SAMLConfig( enabled=True, entity_id=SP_ENTITY_ID, acs_url=ACS_URL, @@ -161,22 +161,17 @@ def _build_app(saml_env: SamlEnv): from litellm.proxy.auth_v2.config import AuthConfig from litellm.proxy.auth_v2.models import Principal from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore - from litellm.proxy.auth_v2.security import get_current_principal, install_auth + from litellm.proxy.auth_v2.saml import build_saml_router + from litellm.proxy.auth_v2.security import AuthSecurity app = FastAPI() store = InMemoryIdentityStore() - install_auth( - app, - AuthConfig(saml=saml_env.config), - store, - mount_scim=False, - mount_oidc=False, - mount_saml=True, - ) + auth = AuthSecurity(AuthConfig(saml=saml_env.config), store) + app.include_router(build_saml_router(auth)) @app.get("/whoami") async def whoami( - principal: "Principal" = Security(get_current_principal), + principal: "Principal" = Security(auth.principal), ): return { "subject": principal.subject, @@ -226,7 +221,7 @@ def test_acs_accepts_signed_assertion_and_provisions_user(saml_env): follow_redirects=False, ) assert acs.status_code == 303 - assert "saml_session" in acs.cookies + assert "litellm_session" in acs.cookies # user was provisioned into the ProvisioningStore via the shared upsert seam users = list(store._users.values()) @@ -243,7 +238,7 @@ def test_session_cookie_authenticates_with_saml_method(saml_env): data={"SAMLResponse": saml_env.mint_response()}, follow_redirects=False, ) - client.cookies.set("saml_session", acs.cookies["saml_session"]) + client.cookies.set("litellm_session", acs.cookies["litellm_session"]) whoami = client.get("/whoami") assert whoami.status_code == 200 @@ -404,9 +399,9 @@ def test_user_from_mapped_builds_name_and_email(): ], ) def test_safe_relay_state_blocks_open_redirects(candidate, expected): - from litellm.proxy.auth_v2.saml import _safe_relay_state + from litellm.proxy.auth_v2.session import safe_relay_state - assert _safe_relay_state(candidate, "/") == expected + assert safe_relay_state(candidate, "/") == expected @pytest.mark.parametrize( @@ -431,28 +426,28 @@ def test_acs_session_cookie_is_secure(saml_env): data={"SAMLResponse": saml_env.mint_response()}, follow_redirects=False, ) - assert "saml_session" in acs.cookies + assert "litellm_session" in acs.cookies assert "secure" in acs.headers["set-cookie"].lower() # --------------------------------------------------------------------------- # -# SamlSessionStore TTL + size eviction (no xmlsec1 needed) +# SessionStore TTL + size eviction (no xmlsec1 needed) # --------------------------------------------------------------------------- # def test_session_store_expires_entries(): - from litellm.proxy.auth_v2.saml import SamlSessionStore + from litellm.proxy.auth_v2.session import SessionStore - store = SamlSessionStore(ttl_seconds=0) + store = SessionStore(ttl_seconds=0) session_id = store.create_session({"name_id": "alice@example.com"}) # ttl of 0 means the entry is already past its expiry on the next read assert store.get(session_id) is None def test_session_store_evicts_when_over_capacity(): - from litellm.proxy.auth_v2.saml import SamlSessionStore + from litellm.proxy.auth_v2.session import SessionStore - store = SamlSessionStore(max_size=3) + store = SessionStore(max_size=3) ids = [store.create_session({"name_id": f"user-{i}"}) for i in range(5)] live = [sid for sid in ids if store.get(sid) is not None] assert len(live) <= 3 diff --git a/tests/test_litellm/proxy/auth_v2/test_scim.py b/tests/test_litellm/proxy/auth_v2/test_scim.py index e05e90861156..ce463c0ed367 100644 --- a/tests/test_litellm/proxy/auth_v2/test_scim.py +++ b/tests/test_litellm/proxy/auth_v2/test_scim.py @@ -7,7 +7,8 @@ from litellm.proxy.auth_v2.config import AuthConfig from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key -from litellm.proxy.auth_v2.security import install_auth +from litellm.proxy.auth_v2.scim import build_scim_router +from litellm.proxy.auth_v2.security import AuthSecurity USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" @@ -28,19 +29,14 @@ def _principal(subject: str, scopes: list) -> Principal: def _app() -> FastAPI: app = FastAPI() - install_auth( - app, - AuthConfig(), - InMemoryIdentityStore( - api_keys={ - _hash_api_key(SCIM_KEY): _principal("scim-writer", ["scim:write"]), - _hash_api_key(NOSCOPE_KEY): _principal("no-scope", []), - } - ), - mount_scim=True, - mount_oidc=False, - mount_saml=False, + store = InMemoryIdentityStore( + api_keys={ + _hash_api_key(SCIM_KEY): _principal("scim-writer", ["scim:write"]), + _hash_api_key(NOSCOPE_KEY): _principal("no-scope", []), + } ) + auth = AuthSecurity(AuthConfig(), store) + app.include_router(build_scim_router(auth)) return app diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index f9f92c1e0ed9..96d240d86e52 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -1,31 +1,26 @@ from __future__ import annotations -from typing import Annotated, Any, Tuple +from typing import Any, Tuple import pytest from fastapi import FastAPI, Security from fastapi.testclient import TestClient from litellm.proxy.auth_v2.authenticators import ( - ApiKeyAuthenticator, + APIKeyAuthenticator, HttpAuthenticator, - JwtVerifier, + JWTVerifier, ) from litellm.proxy.auth_v2.config import ( ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, - OidcProviderConfig, + OIDCProviderConfig, ) from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.proxy.auth_v2.rbac import RbacEngine, Role +from litellm.proxy.auth_v2.rbac import RBACEngine, Role from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key -from litellm.proxy.auth_v2.security import ( - AuthContext, - get_current_principal, - require_permission, - require_roles, -) +from litellm.proxy.auth_v2.security import AuthSecurity from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient @@ -46,13 +41,15 @@ def _principal(subject: str, *, scopes=None, roles=None) -> Principal: ) -def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]: - verifier = JwtVerifier( - OidcProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), +def _build_app( + public_key: Any, *, rbac: RBACEngine = None +) -> Tuple[FastAPI, InMemoryIdentityStore]: + verifier = JWTVerifier( + OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), ) authenticators = [ - ApiKeyAuthenticator(ApiKeySchemeConfig()), + APIKeyAuthenticator(ApiKeySchemeConfig()), HttpAuthenticator(HttpBasicConfig(), [verifier]), ] resolver = InMemoryIdentityStore( @@ -72,15 +69,17 @@ def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]: ), } ) - ctx = AuthContext(AuthConfig(), authenticators, resolver) + auth = AuthSecurity( + AuthConfig(), resolver, rbac=rbac, authenticators=authenticators + ) app = FastAPI() - app.state.auth_v2 = ctx + # default-value Security() style: the dependency marker stays a real object even + # under `from __future__ import annotations`, where Annotated[...] would be a string + # that FastAPI re-evaluates in module globals (the closure-local `auth` is invisible) @app.get("/open") - async def open_route( - principal: Annotated[Principal, Security(get_current_principal)], - ): + async def open_route(principal: Principal = Security(auth.principal)): return { "subject": principal.subject, "auth_method": principal.auth_method.value, @@ -89,23 +88,19 @@ async def open_route( @app.get("/scoped") async def scoped_route( - principal: Annotated[ - Principal, Security(get_current_principal, scopes=["models:read"]) - ], + principal: Principal = Security(auth.principal, scopes=["models:read"]), ): return {"subject": principal.subject} @app.get("/admin") async def admin_route( - principal: Annotated[Principal, Security(require_roles(Role.ORG_ADMIN))], + principal: Principal = Security(auth.require_roles(Role.ORG_ADMIN)), ): return {"subject": principal.subject} @app.post("/perm-widgets") async def widgets_route( - principal: Annotated[ - Principal, Security(require_permission("/widgets", "POST")) - ], + principal: Principal = Security(auth.require_permission("/widgets", "POST")), ): return {"subject": principal.subject} @@ -229,7 +224,7 @@ def test_required_role_honors_hierarchy(client): # --------------------------------------------------------------------------- # -# Permission enforcement (require_permission -> RbacEngine.enforce) +# Permission enforcement (require_permission -> RBACEngine.enforce) # --------------------------------------------------------------------------- # @@ -262,8 +257,7 @@ def test_injected_rbac_engine_overrides_default_policy(rsa_keypair, tmp_path): policy.write_text("p, platform_viewer, /widgets, POST\n") _, public_key = rsa_keypair - app, _ = _build_app(public_key) - app.state.auth_v2.rbac = RbacEngine(policy_path=str(policy)) + app, _ = _build_app(public_key, rbac=RBACEngine(policy_path=str(policy))) client = TestClient(app) # viewer now passes, platform_admin (default grant removed) now fails From 9b9cc60994acacb053fe028c1e93e7ace2cffc32 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:25:44 -0700 Subject: [PATCH 23/51] refactor(auth_v2): split oidc/saml/scim into sub-packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the revised design §2, each protocol that carries its own config/routes becomes a sub-package while the shared core stays flat. oidc.py -> oidc/router.py with oidc/config.py (OIDCProviderConfig); saml.py -> saml/router.py with saml/config.py (SAMLConfig + the attribute-map default); scim.py -> scim/router.py. Each sub-package __init__ re-exports its public names so call sites read from litellm.proxy.auth_v2.saml import SAMLConfig, build_saml_router. SessionConfig moves next to the SessionStore it configures in session.py. git mv preserves history; AuthConfig now composes the protocol configs from their sub-packages. --- litellm/proxy/auth_v2/__init__.py | 8 +-- litellm/proxy/auth_v2/authenticators.py | 2 +- litellm/proxy/auth_v2/config.py | 67 ++----------------- litellm/proxy/auth_v2/oidc/__init__.py | 4 ++ litellm/proxy/auth_v2/oidc/config.py | 16 +++++ .../proxy/auth_v2/{oidc.py => oidc/router.py} | 6 +- litellm/proxy/auth_v2/saml/__init__.py | 4 ++ litellm/proxy/auth_v2/saml/config.py | 38 +++++++++++ .../proxy/auth_v2/{saml.py => saml/router.py} | 6 +- litellm/proxy/auth_v2/scim/__init__.py | 3 + .../proxy/auth_v2/{scim.py => scim/router.py} | 4 +- litellm/proxy/auth_v2/session.py | 11 +++ 12 files changed, 93 insertions(+), 76 deletions(-) create mode 100644 litellm/proxy/auth_v2/oidc/__init__.py create mode 100644 litellm/proxy/auth_v2/oidc/config.py rename litellm/proxy/auth_v2/{oidc.py => oidc/router.py} (97%) create mode 100644 litellm/proxy/auth_v2/saml/__init__.py create mode 100644 litellm/proxy/auth_v2/saml/config.py rename litellm/proxy/auth_v2/{saml.py => saml/router.py} (98%) create mode 100644 litellm/proxy/auth_v2/scim/__init__.py rename litellm/proxy/auth_v2/{scim.py => scim/router.py} (99%) diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 2bd9527afc78..9c31dbce9ed6 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -4,18 +4,16 @@ HttpBasicConfig, MutualTLSConfig, OAuth2IntrospectionConfig, - OIDCProviderConfig, - SAMLConfig, - SessionConfig, TrustedProxyConfig, ) from .models import Principal -from .oidc import build_oidc_router +from .oidc import OIDCProviderConfig, build_oidc_router from .rbac import Role from .resolver import IdentityResolver, InMemoryIdentityStore, ProvisioningStore -from .saml import build_saml_router +from .saml import SAMLConfig, build_saml_router from .scim import build_scim_router from .security import AuthSecurity +from .session import SessionConfig __all__ = [ "AuthSecurity", diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index 7f4ba45b5f4a..4ea6f86163c8 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -21,9 +21,9 @@ HttpBasicConfig, MutualTLSConfig, OAuth2IntrospectionConfig, - OIDCProviderConfig, TrustedProxyConfig, ) +from .oidc.config import OIDCProviderConfig from .models import ( AuthMethod, ClientCertificate, diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index 41558a45de22..e82cf10dc15d 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -1,23 +1,11 @@ -from __future__ import annotations +from typing import List, Optional -from typing import Dict, List, Optional - -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, model_validator +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr from .models import SecuritySchemeType - -DEFAULT_SAML_ATTRIBUTE_MAP = { - "email": "email", - "mail": "email", - "givenName": "given_name", - "surname": "family_name", - "sn": "family_name", - "displayName": "display_name", - "userName": "user_name", - "uid": "user_name", - "groups": "groups", - "roles": "roles", -} +from .oidc.config import OIDCProviderConfig +from .saml.config import SAMLConfig +from .session import SessionConfig class ApiKeySchemeConfig(BaseModel): @@ -29,19 +17,6 @@ class HttpBasicConfig(BaseModel): realm: str = "litellm" -class OIDCProviderConfig(BaseModel): - issuer: str - audience: List[str] - jwks_uri: Optional[AnyHttpUrl] = None - algorithms: List[str] = Field(default_factory=lambda: ["RS256"]) - require_at_jwt: bool = False - client_id: Optional[str] = None - client_secret: Optional[SecretStr] = None - login_scopes: List[str] = Field( - default_factory=lambda: ["openid", "email", "profile"] - ) - - class OAuth2IntrospectionConfig(BaseModel): introspection_endpoint: AnyHttpUrl client_id: str @@ -60,38 +35,6 @@ class TrustedProxyConfig(BaseModel): trusted_proxy_cidrs: List[str] = Field(default_factory=list) -class SessionConfig(BaseModel): - cookie: str = "litellm_session" - secure: bool = True - ttl_seconds: int = 3600 - max_size: int = 10000 - default_redirect_path: str = "/" - login_cookie: str = "litellm_oidc_txn" - login_state_ttl: int = 300 - - -class SAMLConfig(BaseModel): - enabled: bool = False - entity_id: str - acs_url: str - idp_metadata: str = "" - sp_key_file: Optional[str] = None - sp_cert_file: Optional[str] = None - allow_unsolicited: bool = False - xmlsec_binary: Optional[str] = None - attribute_map: Dict[str, str] = Field( - default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) - ) - - @model_validator(mode="after") - def _require_idp_metadata(self) -> "SAMLConfig": - if self.enabled and not self.idp_metadata.strip(): - raise ValueError( - "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" - ) - return self - - class AuthConfig(BaseModel): scheme_order: List[SecuritySchemeType] = Field( default_factory=lambda: [ diff --git a/litellm/proxy/auth_v2/oidc/__init__.py b/litellm/proxy/auth_v2/oidc/__init__.py new file mode 100644 index 000000000000..0e9c3e12a15b --- /dev/null +++ b/litellm/proxy/auth_v2/oidc/__init__.py @@ -0,0 +1,4 @@ +from .config import OIDCProviderConfig +from .router import build_oidc_router + +__all__ = ["OIDCProviderConfig", "build_oidc_router"] diff --git a/litellm/proxy/auth_v2/oidc/config.py b/litellm/proxy/auth_v2/oidc/config.py new file mode 100644 index 000000000000..5859ed07d9a2 --- /dev/null +++ b/litellm/proxy/auth_v2/oidc/config.py @@ -0,0 +1,16 @@ +from typing import List, Optional + +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr + + +class OIDCProviderConfig(BaseModel): + issuer: str + audience: List[str] + jwks_uri: Optional[AnyHttpUrl] = None + algorithms: List[str] = Field(default_factory=lambda: ["RS256"]) + require_at_jwt: bool = False + client_id: Optional[str] = None + client_secret: Optional[SecretStr] = None + login_scopes: List[str] = Field( + default_factory=lambda: ["openid", "email", "profile"] + ) diff --git a/litellm/proxy/auth_v2/oidc.py b/litellm/proxy/auth_v2/oidc/router.py similarity index 97% rename from litellm/proxy/auth_v2/oidc.py rename to litellm/proxy/auth_v2/oidc/router.py index 287125978460..61d72bcc8e53 100644 --- a/litellm/proxy/auth_v2/oidc.py +++ b/litellm/proxy/auth_v2/oidc/router.py @@ -9,11 +9,11 @@ from scim2_models import User as ScimUser from .config import OIDCProviderConfig -from .resolver import ProvisioningStore -from .session import safe_relay_state +from ..resolver import ProvisioningStore +from ..session import safe_relay_state if TYPE_CHECKING: - from .security import AuthSecurity + from ..security import AuthSecurity _CLAIM_KEYS = ("email", "preferred_username", "name", "groups", "roles") diff --git a/litellm/proxy/auth_v2/saml/__init__.py b/litellm/proxy/auth_v2/saml/__init__.py new file mode 100644 index 000000000000..c6eb35a995e1 --- /dev/null +++ b/litellm/proxy/auth_v2/saml/__init__.py @@ -0,0 +1,4 @@ +from .config import SAMLConfig +from .router import build_saml_router + +__all__ = ["SAMLConfig", "build_saml_router"] diff --git a/litellm/proxy/auth_v2/saml/config.py b/litellm/proxy/auth_v2/saml/config.py new file mode 100644 index 000000000000..4c83fd872259 --- /dev/null +++ b/litellm/proxy/auth_v2/saml/config.py @@ -0,0 +1,38 @@ +from typing import Dict, Optional + +from pydantic import BaseModel, Field, model_validator + +DEFAULT_SAML_ATTRIBUTE_MAP = { + "email": "email", + "mail": "email", + "givenName": "given_name", + "surname": "family_name", + "sn": "family_name", + "displayName": "display_name", + "userName": "user_name", + "uid": "user_name", + "groups": "groups", + "roles": "roles", +} + + +class SAMLConfig(BaseModel): + enabled: bool = False + entity_id: str + acs_url: str + idp_metadata: str = "" + sp_key_file: Optional[str] = None + sp_cert_file: Optional[str] = None + allow_unsolicited: bool = False + xmlsec_binary: Optional[str] = None + attribute_map: Dict[str, str] = Field( + default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) + ) + + @model_validator(mode="after") + def _require_idp_metadata(self) -> "SAMLConfig": + if self.enabled and not self.idp_metadata.strip(): + raise ValueError( + "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" + ) + return self diff --git a/litellm/proxy/auth_v2/saml.py b/litellm/proxy/auth_v2/saml/router.py similarity index 98% rename from litellm/proxy/auth_v2/saml.py rename to litellm/proxy/auth_v2/saml/router.py index 5f0a2e8bfb9f..c5018ac41690 100644 --- a/litellm/proxy/auth_v2/saml.py +++ b/litellm/proxy/auth_v2/saml/router.py @@ -13,11 +13,11 @@ from scim2_models import User as ScimUser from .config import SAMLConfig -from .resolver import ProvisioningStore -from .session import safe_relay_state +from ..resolver import ProvisioningStore +from ..session import safe_relay_state if TYPE_CHECKING: - from .security import AuthSecurity + from ..security import AuthSecurity _SINGLE_VALUE_TARGETS = { "email", diff --git a/litellm/proxy/auth_v2/scim/__init__.py b/litellm/proxy/auth_v2/scim/__init__.py new file mode 100644 index 000000000000..bb6917c367bb --- /dev/null +++ b/litellm/proxy/auth_v2/scim/__init__.py @@ -0,0 +1,3 @@ +from .router import build_scim_router + +__all__ = ["build_scim_router"] diff --git a/litellm/proxy/auth_v2/scim.py b/litellm/proxy/auth_v2/scim/router.py similarity index 99% rename from litellm/proxy/auth_v2/scim.py rename to litellm/proxy/auth_v2/scim/router.py index 64255db266a9..dc64380b1646 100644 --- a/litellm/proxy/auth_v2/scim.py +++ b/litellm/proxy/auth_v2/scim/router.py @@ -23,10 +23,10 @@ User, ) -from .resolver import ProvisioningStore +from ..resolver import ProvisioningStore if TYPE_CHECKING: - from .security import AuthSecurity + from ..security import AuthSecurity R = TypeVar("R", bound=Resource) diff --git a/litellm/proxy/auth_v2/session.py b/litellm/proxy/auth_v2/session.py index fe671ce40f65..2636e9a5c8ea 100644 --- a/litellm/proxy/auth_v2/session.py +++ b/litellm/proxy/auth_v2/session.py @@ -5,10 +5,21 @@ from typing import Any, Dict, Optional, Tuple from fastapi import Request +from pydantic import BaseModel from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +class SessionConfig(BaseModel): + cookie: str = "litellm_session" + secure: bool = True + ttl_seconds: int = 3600 + max_size: int = 10000 + default_redirect_path: str = "/" + login_cookie: str = "litellm_oidc_txn" + login_state_ttl: int = 300 + + def safe_relay_state(target: Optional[str], default: str) -> str: if ( target From 422c4df16e369d2fa23aa990023decc27dac0fc7 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:32:44 -0700 Subject: [PATCH 24/51] fix(auth_v2): close token-claim privilege escalation and related hardening Token-flows and credential-flows security review fixes: - H1 (privilege escalation): a validly-signed token could self-assert platform_admin and arbitrary teams via the roles/groups claims. Roles from a token are now filtered to a per-provider allowlist (OIDCProviderConfig.allowed_roles, default empty = none) with platform-level roles gated behind an explicit allow_platform_roles flag; groups become authoritative TeamIdentity only when they resolve to a provisioned SCIM Group in the store. Introspection responses carry no roles (no per-provider policy applies). - M1: enforce iss on introspection (OAuth2IntrospectionConfig.issuer) in addition to aud. - M2: bound JWKS refetch with cache_jwk_set + a 300s lifespan and add a 10s PyJWKClient timeout, so an unknown-kid stream can't amplify into per-request network fetches. - M3: require https for issuer/jwks_uri/introspection_endpoint (loopback excepted for dev). - rbac: anchor the Casbin act matcher (^(...)$) so a "GET" policy can't grant "GETX". - basic auth: switch the reference store to pbkdf2_hmac-sha256 (600k iterations); the verifier protocol still lets deployments plug argon2/bcrypt. - LOW: generic invalid_token description instead of echoing PyJWT internals; guard the introspection response.json() and require active to be boolean true. --- litellm/proxy/auth_v2/authenticators.py | 56 +++++++++++++++++++------ litellm/proxy/auth_v2/config.py | 11 ++++- litellm/proxy/auth_v2/models.py | 10 +++++ litellm/proxy/auth_v2/oidc/config.py | 18 +++++++- litellm/proxy/auth_v2/rbac.py | 2 +- litellm/proxy/auth_v2/resolver.py | 31 ++++++++++---- 6 files changed, 104 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index 4ea6f86163c8..dad667a57e36 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -23,7 +23,6 @@ OAuth2IntrospectionConfig, TrustedProxyConfig, ) -from .oidc.config import OIDCProviderConfig from .models import ( AuthMethod, ClientCertificate, @@ -32,8 +31,23 @@ SecuritySchemeType, ) from .network import ip_in_trusted_proxies +from .oidc.config import OIDCProviderConfig +from .rbac import Role AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} +_PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} + + +def _apply_role_policy(claims: Dict[str, Any], provider: OIDCProviderConfig) -> None: + raw = claims.get("roles") + if not isinstance(raw, list): + claims["roles"] = [] + return + allowed = set(provider.allowed_roles) + filtered = [role for role in raw if role in allowed] + if not provider.allow_platform_roles: + filtered = [role for role in filtered if role not in _PLATFORM_ROLE_VALUES] + claims["roles"] = filtered @runtime_checkable @@ -48,10 +62,15 @@ class BasicAuthVerifier(Protocol): def verify(self, username: str, password: str) -> bool: ... +_PBKDF2_ITERATIONS = 600_000 + + def hash_basic_password(password: str, salt: Optional[str] = None) -> str: salt = salt or secrets.token_hex(16) - digest = hashlib.sha256(bytes.fromhex(salt) + password.encode()).hexdigest() - return f"{salt}${digest}" + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS + ).hex() + return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest}" class InMemoryBasicAuthStore: @@ -62,11 +81,11 @@ def verify(self, username: str, password: str) -> bool: stored = self._credentials.get(username) if stored is None: return False - salt, _, expected = stored.partition("$") try: - candidate = hashlib.sha256( - bytes.fromhex(salt) + password.encode() - ).hexdigest() + _algorithm, iterations, salt, expected = stored.split("$") + candidate = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), int(iterations) + ).hex() except ValueError: return False return hmac.compare_digest(candidate, expected) @@ -132,7 +151,13 @@ def __init__( jwks_uri = ( str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() ) - self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True) + self._jwks_client = PyJWKClient( + jwks_uri, + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=10, + ) def _discover_jwks(self) -> str: import httpx @@ -166,7 +191,7 @@ def verify( options={"verify_exp": True, "require": ["exp", "iss", "aud"]}, ) except jwt.PyJWTError as exc: - raise errors.invalid_token(str(exc)) from exc + raise errors.invalid_token("token verification failed") from exc async def _verify_jwt_off_loop( @@ -202,6 +227,7 @@ async def _authenticate_bearer_jwt( if verifier is None: raise errors.invalid_token("no issuer match") claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=require_at_jwt) + _apply_role_policy(claims, verifier.provider) return _credential_from_claims(scheme, method, token, claims) @@ -326,12 +352,18 @@ async def _introspect(self, token: str) -> Credential: ) if response.status_code != 200: raise errors.invalid_token("introspection failed") - body = response.json() - if not body.get("active"): + try: + body = response.json() + except ValueError as exc: + raise errors.invalid_token("introspection failed") from exc + if not isinstance(body, dict) or body.get("active") is not True: raise errors.invalid_token("token inactive") token_audience = _normalize_audience(body.get("aud")) if config.audience and not set(token_audience) & set(config.audience): raise errors.invalid_token("audience mismatch") + if config.issuer is not None and body.get("iss") != config.issuer: + raise errors.invalid_token("issuer mismatch") + claims = {key: value for key, value in body.items() if key != "roles"} return Credential( scheme=SecuritySchemeType.OAUTH2, method=AuthMethod.OAUTH2_INTROSPECTION, @@ -339,7 +371,7 @@ async def _introspect(self, token: str) -> Credential: issuer=body.get("iss"), audience=token_audience, scopes=_split_scope(body.get("scope")), - claims=body, + claims=claims, ) def challenge(self) -> str: diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index e82cf10dc15d..9c172b472631 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -1,8 +1,8 @@ from typing import List, Optional -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, field_validator -from .models import SecuritySchemeType +from .models import SecuritySchemeType, require_secure_url from .oidc.config import OIDCProviderConfig from .saml.config import SAMLConfig from .session import SessionConfig @@ -23,6 +23,13 @@ class OAuth2IntrospectionConfig(BaseModel): client_secret: SecretStr subject_field: str = "sub" audience: List[str] = Field(default_factory=list) + issuer: Optional[str] = None + + @field_validator("introspection_endpoint") + @classmethod + def _endpoint_https(cls, value: AnyHttpUrl) -> AnyHttpUrl: + require_secure_url(str(value)) + return value class MutualTLSConfig(BaseModel): diff --git a/litellm/proxy/auth_v2/models.py b/litellm/proxy/auth_v2/models.py index 3c6ba79de130..0f58939cb823 100644 --- a/litellm/proxy/auth_v2/models.py +++ b/litellm/proxy/auth_v2/models.py @@ -2,11 +2,21 @@ from enum import Enum from typing import Any, Dict, List, Optional +from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, Field from .rbac import Role +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +def require_secure_url(value: str) -> str: + host = urlparse(value).hostname or "" + if value.startswith("https://") or host in _LOOPBACK_HOSTS: + return value + raise ValueError(f"insecure URL, https is required (loopback excepted): {value}") + class SecuritySchemeType(str, Enum): API_KEY = "apiKey" diff --git a/litellm/proxy/auth_v2/oidc/config.py b/litellm/proxy/auth_v2/oidc/config.py index 5859ed07d9a2..8e5138e2cec5 100644 --- a/litellm/proxy/auth_v2/oidc/config.py +++ b/litellm/proxy/auth_v2/oidc/config.py @@ -1,6 +1,8 @@ from typing import List, Optional -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr +from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, field_validator + +from ..models import require_secure_url class OIDCProviderConfig(BaseModel): @@ -14,3 +16,17 @@ class OIDCProviderConfig(BaseModel): login_scopes: List[str] = Field( default_factory=lambda: ["openid", "email", "profile"] ) + allowed_roles: List[str] = Field(default_factory=list) + allow_platform_roles: bool = False + + @field_validator("issuer") + @classmethod + def _issuer_https(cls, value: str) -> str: + return require_secure_url(value) + + @field_validator("jwks_uri") + @classmethod + def _jwks_https(cls, value: Optional[AnyHttpUrl]) -> Optional[AnyHttpUrl]: + if value is not None: + require_secure_url(str(value)) + return value diff --git a/litellm/proxy/auth_v2/rbac.py b/litellm/proxy/auth_v2/rbac.py index 927a242e6e80..31b2cfff907e 100644 --- a/litellm/proxy/auth_v2/rbac.py +++ b/litellm/proxy/auth_v2/rbac.py @@ -39,7 +39,7 @@ def has_required_scopes( e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act) +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, "^(" + p.act + ")$") """ _DEFAULT_GROUPING: List[Tuple[str, str]] = [ diff --git a/litellm/proxy/auth_v2/resolver.py b/litellm/proxy/auth_v2/resolver.py index 0c33763843f9..66ddf5a70c48 100644 --- a/litellm/proxy/auth_v2/resolver.py +++ b/litellm/proxy/auth_v2/resolver.py @@ -52,13 +52,6 @@ def _public_claims(claims: Dict[str, Any]) -> Dict[str, Any]: return {key: value for key, value in claims.items() if not key.startswith("_")} -def _teams_from_claims(claims: Dict[str, Any]) -> List[TeamIdentity]: - groups = claims.get("groups", []) - if not isinstance(groups, list): - return [] - return [TeamIdentity(id=str(group), name=str(group)) for group in groups] - - class InMemoryIdentityStore(IdentityResolver, ProvisioningStore): def __init__( self, @@ -85,6 +78,28 @@ def _reject_if_deactivated(self, principal: Principal) -> None: if user is not None and user.active is False: raise errors.account_disabled() + def _resolve_teams(self, claims: Dict[str, Any]) -> List[TeamIdentity]: + groups = claims.get("groups", []) + if not isinstance(groups, list): + return [] + teams: List[TeamIdentity] = [] + for group in groups: + scim_group = self._find_group(str(group)) + if scim_group is not None: + teams.append( + TeamIdentity( + id=scim_group.id or str(group), + name=scim_group.display_name or str(group), + ) + ) + return teams + + def _find_group(self, value: str) -> Optional[ScimGroup]: + for group in self._groups.values(): + if group.id == value or group.display_name == value: + return group + return None + def _lookup_scim_user(self, principal: Principal) -> Optional[ScimUser]: if principal.user is None: return None @@ -138,7 +153,7 @@ def _principal_from_claims(self, credential: Credential) -> Principal: user_name=claims.get("preferred_username"), display_name=claims.get("name"), ), - teams=_teams_from_claims(claims), + teams=self._resolve_teams(claims), roles=_roles_from_claims(claims), scopes=list(credential.scopes), auth_method=credential.method, From 75509686f7be7d7ce9d136593b1401dda5974cd4 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:38:20 -0700 Subject: [PATCH 25/51] fix(auth_v2): reject SCIM id mutation and SCIM-shape auth errors PATCH no longer lets a scim:write principal reassign the read-only id attribute (RFC 7643): any operation whose path targets id, or a no-path value object carrying id, is rejected with 400 instead of rewriting the record identifier and clobbering another resource. Authentication failures on the guarded SCIM routes now render the SCIM Error schema (RFC 7644) for 401 and 403 via a router-scoped route class, preserving the WWW-Authenticate challenge, rather than the generic {detail} body. --- litellm/proxy/auth_v2/scim/router.py | 47 ++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/auth_v2/scim/router.py b/litellm/proxy/auth_v2/scim/router.py index dc64380b1646..e31cd2ce7430 100644 --- a/litellm/proxy/auth_v2/scim/router.py +++ b/litellm/proxy/auth_v2/scim/router.py @@ -1,9 +1,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Coroutine, + Dict, + Optional, + Type, + TypeVar, + cast, +) -from fastapi import APIRouter, Query, Request, Response, Security, status +from fastapi import APIRouter, HTTPException, Query, Request, Response, Security, status from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute from pydantic import ValidationError from scim2_models import ( Bulk, @@ -38,6 +49,29 @@ def _error(status_code: int, detail: str) -> JSONResponse: ) +class _ScimRoute(APIRoute): + """Render authentication failures with the SCIM Error schema (RFC 7644).""" + + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def scim_handler(request: Request) -> Response: + try: + return await handler(request) + except HTTPException as exc: + if exc.status_code not in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ): + raise + response = _error(exc.status_code, str(exc.detail)) + if exc.headers: + response.headers.update(exc.headers) + return response + + return scim_handler + + async def _parse(request: Request, model: Type[R]) -> R: body = await request.json() return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST) @@ -66,12 +100,20 @@ def _remove_path(data: Dict[str, Any], path: str) -> None: node.pop(keys[-1], None) +def _targets_read_only_id(op: Any) -> bool: + if op.path is not None: + return op.path.split(".")[0].strip().lower() == "id" + return isinstance(op.value, dict) and any(str(k).lower() == "id" for k in op.value) + + def _apply_patch(resource: R, patch: PatchOp) -> R: data: Dict[str, Any] = resource.model_dump() for op in patch.operations: action = op.op.value if hasattr(op.op, "value") else str(op.op) if op.path is not None and ("[" in op.path or "]" in op.path): raise ValueError(f"unsupported SCIM patch path filter: {op.path}") + if _targets_read_only_id(op): + raise ValueError("the SCIM id attribute is read-only") if action == "remove": if op.path: _remove_path(data, op.path) @@ -90,6 +132,7 @@ def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: def _build_protected_router(auth: AuthSecurity) -> APIRouter: store = cast(ProvisioningStore, auth.resolver) protected = APIRouter( + route_class=_ScimRoute, dependencies=[Security(auth.principal, scopes=["scim:write"])], ) From fec8e0a0392d2f607472040f673f7188eef349c1 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:41:28 -0700 Subject: [PATCH 26/51] fix(auth_v2): complete the freeze batch (re-exports, SCIM error helper, bounded SAML login state) - Re-export the test/public helpers from the sub-package __init__s so existing imports resolve: oidc exposes _provider_key/_user_from_userinfo, saml exposes _map_attributes/_metadata_source/_user_from_mapped. - S7: add errors.scim_error_response(exc) rendering the RFC 7644 SCIM Error schema; a host registers it as the exception handler for the SCIM routes (noted on the AuthSecurity docstring). - veria-ai MEDIUM: bound the SAML outstanding-request map with a TTL (300s) and max-size eviction, the same treatment as the session store, so unauthenticated /auth/saml/login traffic can no longer accumulate login state unbounded. - Silence a fastapi/starlette generic-Request override quirk on the SCIM route's get_route_handler so mypy is clean at the freeze sha. --- litellm/proxy/auth_v2/errors.py | 16 ++++++++++ litellm/proxy/auth_v2/oidc/__init__.py | 9 ++++-- litellm/proxy/auth_v2/saml/__init__.py | 15 +++++++-- litellm/proxy/auth_v2/saml/router.py | 43 ++++++++++++++++++++++---- litellm/proxy/auth_v2/scim/router.py | 4 ++- litellm/proxy/auth_v2/security.py | 3 ++ 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py index a7c5950790fb..965589e3a587 100644 --- a/litellm/proxy/auth_v2/errors.py +++ b/litellm/proxy/auth_v2/errors.py @@ -3,6 +3,9 @@ from typing import Optional from fastapi import HTTPException +from fastapi.responses import JSONResponse + +SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" class AuthError(HTTPException): @@ -52,3 +55,16 @@ def forbidden_permission() -> AuthError: def account_disabled() -> AuthError: return AuthError(403, "Account disabled") + + +def scim_error_response(exc: Exception) -> JSONResponse: + status_code = exc.status_code if isinstance(exc, HTTPException) else 500 + detail = exc.detail if isinstance(exc, HTTPException) else "Internal server error" + return JSONResponse( + status_code=status_code, + content={ + "schemas": [SCIM_ERROR_SCHEMA], + "status": str(status_code), + "detail": str(detail), + }, + ) diff --git a/litellm/proxy/auth_v2/oidc/__init__.py b/litellm/proxy/auth_v2/oidc/__init__.py index 0e9c3e12a15b..1112293efd33 100644 --- a/litellm/proxy/auth_v2/oidc/__init__.py +++ b/litellm/proxy/auth_v2/oidc/__init__.py @@ -1,4 +1,9 @@ from .config import OIDCProviderConfig -from .router import build_oidc_router +from .router import _provider_key, _user_from_userinfo, build_oidc_router -__all__ = ["OIDCProviderConfig", "build_oidc_router"] +__all__ = [ + "OIDCProviderConfig", + "build_oidc_router", + "_provider_key", + "_user_from_userinfo", +] diff --git a/litellm/proxy/auth_v2/saml/__init__.py b/litellm/proxy/auth_v2/saml/__init__.py index c6eb35a995e1..c8e4e7548bbc 100644 --- a/litellm/proxy/auth_v2/saml/__init__.py +++ b/litellm/proxy/auth_v2/saml/__init__.py @@ -1,4 +1,15 @@ from .config import SAMLConfig -from .router import build_saml_router +from .router import ( + _map_attributes, + _metadata_source, + _user_from_mapped, + build_saml_router, +) -__all__ = ["SAMLConfig", "build_saml_router"] +__all__ = [ + "SAMLConfig", + "build_saml_router", + "_map_attributes", + "_metadata_source", + "_user_from_mapped", +] diff --git a/litellm/proxy/auth_v2/saml/router.py b/litellm/proxy/auth_v2/saml/router.py index c5018ac41690..20569d7324b5 100644 --- a/litellm/proxy/auth_v2/saml/router.py +++ b/litellm/proxy/auth_v2/saml/router.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse, Response @@ -129,13 +129,44 @@ def build_sp_client(config: SAMLConfig) -> Saml2Client: class SAMLProtocolStore: - def __init__(self, replay_ttl_seconds: int) -> None: - self.outstanding: Dict[str, str] = {} + def __init__( + self, + replay_ttl_seconds: int, + outstanding_ttl_seconds: int = 300, + max_outstanding: int = 10000, + ) -> None: + self._outstanding: Dict[str, Tuple[float, str]] = {} self._seen_assertions: Dict[str, float] = {} self._replay_ttl = replay_ttl_seconds + self._outstanding_ttl = outstanding_ttl_seconds + self._max_outstanding = max_outstanding def remember_request(self, request_id: str, relay_state: str) -> None: - self.outstanding[request_id] = relay_state + now = time.time() + self._evict_outstanding(now) + self._outstanding[request_id] = (now + self._outstanding_ttl, relay_state) + + def outstanding_relays(self) -> Dict[str, str]: + now = time.time() + return { + rid: relay for rid, (exp, relay) in self._outstanding.items() if exp >= now + } + + def consume_request(self, request_id: str) -> Optional[str]: + entry = self._outstanding.pop(request_id, None) + if entry is None: + return None + expires_at, relay = entry + return relay if expires_at >= time.time() else None + + def _evict_outstanding(self, now: float) -> None: + for rid in [r for r, (exp, _) in self._outstanding.items() if exp < now]: + self._outstanding.pop(rid, None) + overflow = len(self._outstanding) - self._max_outstanding + 1 + if overflow > 0: + oldest = sorted(self._outstanding, key=lambda r: self._outstanding[r][0]) + for rid in oldest[:overflow]: + self._outstanding.pop(rid, None) def consume_assertion(self, assertion_id: str) -> bool: now = time.time() @@ -185,7 +216,7 @@ async def assertion_consumer_service(request: Request) -> Response: authn_response = client.parse_authn_request_response( saml_response, BINDING_HTTP_POST, - outstanding=protocol.outstanding or None, + outstanding=protocol.outstanding_relays() or None, ) except Exception as exc: raise HTTPException( @@ -196,7 +227,7 @@ async def assertion_consumer_service(request: Request) -> Response: in_response_to = getattr(authn_response, "in_response_to", None) bound_relay = ( - protocol.outstanding.pop(in_response_to, None) if in_response_to else None + protocol.consume_request(in_response_to) if in_response_to else None ) assertion = getattr(authn_response, "assertion", None) diff --git a/litellm/proxy/auth_v2/scim/router.py b/litellm/proxy/auth_v2/scim/router.py index e31cd2ce7430..a106446f3677 100644 --- a/litellm/proxy/auth_v2/scim/router.py +++ b/litellm/proxy/auth_v2/scim/router.py @@ -52,7 +52,9 @@ def _error(status_code: int, detail: str) -> JSONResponse: class _ScimRoute(APIRoute): """Render authentication failures with the SCIM Error schema (RFC 7644).""" - def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + def get_route_handler( # type: ignore[override] + self, + ) -> Callable[[Request], Coroutine[Any, Any, Response]]: handler = super().get_route_handler() async def scim_handler(request: Request) -> Response: diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index d311019bec2e..ef0bc89e7825 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -40,6 +40,9 @@ class AuthSecurity: uvicorn with ``--no-proxy-headers`` and let this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and rely on uvicorn's ``--forwarded-allow-ips``. Do not enable both. + + To return SCIM-shaped error bodies, register ``errors.scim_error_response`` as + the host app's exception handler for the SCIM routes. """ def __init__( From a9be3d23e024e108d7da0c78967050b23046036c Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:45:14 -0700 Subject: [PATCH 27/51] refactor(auth_v2): drop duplicate SCIM error helper and private re-exports S7 is already handled self-contained by the SCIM router's route_class (renders 401/403 as a SCIM Error while preserving WWW-Authenticate), so remove the redundant errors.scim_error_response and its AuthSecurity docstring note. Also stop re-exporting private underscore helpers from the oidc/saml sub-package __init__s; the public names (config + build_*_router) remain re-exported and test code references the concrete modules for internals. --- litellm/proxy/auth_v2/errors.py | 16 ---------------- litellm/proxy/auth_v2/oidc/__init__.py | 9 ++------- litellm/proxy/auth_v2/saml/__init__.py | 15 ++------------- litellm/proxy/auth_v2/security.py | 3 --- 4 files changed, 4 insertions(+), 39 deletions(-) diff --git a/litellm/proxy/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py index 965589e3a587..a7c5950790fb 100644 --- a/litellm/proxy/auth_v2/errors.py +++ b/litellm/proxy/auth_v2/errors.py @@ -3,9 +3,6 @@ from typing import Optional from fastapi import HTTPException -from fastapi.responses import JSONResponse - -SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" class AuthError(HTTPException): @@ -55,16 +52,3 @@ def forbidden_permission() -> AuthError: def account_disabled() -> AuthError: return AuthError(403, "Account disabled") - - -def scim_error_response(exc: Exception) -> JSONResponse: - status_code = exc.status_code if isinstance(exc, HTTPException) else 500 - detail = exc.detail if isinstance(exc, HTTPException) else "Internal server error" - return JSONResponse( - status_code=status_code, - content={ - "schemas": [SCIM_ERROR_SCHEMA], - "status": str(status_code), - "detail": str(detail), - }, - ) diff --git a/litellm/proxy/auth_v2/oidc/__init__.py b/litellm/proxy/auth_v2/oidc/__init__.py index 1112293efd33..0e9c3e12a15b 100644 --- a/litellm/proxy/auth_v2/oidc/__init__.py +++ b/litellm/proxy/auth_v2/oidc/__init__.py @@ -1,9 +1,4 @@ from .config import OIDCProviderConfig -from .router import _provider_key, _user_from_userinfo, build_oidc_router +from .router import build_oidc_router -__all__ = [ - "OIDCProviderConfig", - "build_oidc_router", - "_provider_key", - "_user_from_userinfo", -] +__all__ = ["OIDCProviderConfig", "build_oidc_router"] diff --git a/litellm/proxy/auth_v2/saml/__init__.py b/litellm/proxy/auth_v2/saml/__init__.py index c8e4e7548bbc..c6eb35a995e1 100644 --- a/litellm/proxy/auth_v2/saml/__init__.py +++ b/litellm/proxy/auth_v2/saml/__init__.py @@ -1,15 +1,4 @@ from .config import SAMLConfig -from .router import ( - _map_attributes, - _metadata_source, - _user_from_mapped, - build_saml_router, -) +from .router import build_saml_router -__all__ = [ - "SAMLConfig", - "build_saml_router", - "_map_attributes", - "_metadata_source", - "_user_from_mapped", -] +__all__ = ["SAMLConfig", "build_saml_router"] diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index ef0bc89e7825..d311019bec2e 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -40,9 +40,6 @@ class AuthSecurity: uvicorn with ``--no-proxy-headers`` and let this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and rely on uvicorn's ``--forwarded-allow-ips``. Do not enable both. - - To return SCIM-shaped error bodies, register ``errors.scim_error_response`` as - the host app's exception handler for the SCIM routes. """ def __init__( From 341e75ab459178f5ec0b3e74815c3d87875bd763 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:50:23 -0700 Subject: [PATCH 28/51] test(auth_v2): repoint to frozen sub-package layout and pin H1 group provisioning Follow the sub-package split (oidc/saml/scim/*) and the token-claim hardening: - import config models (OIDCProviderConfig, SAMLConfig) from the top-level package and the moved helpers from their concrete sub-modules (saml.router, saml.config, oidc.router) so tests are stable against __init__ re-export churn - resolver: a token group claim is no longer authoritative on its own; it becomes a TeamIdentity only when it resolves to a provisioned SCIM Group in the store (split into provisioned vs not-provisioned cases) Full auth_v2 suite green (153) and stable across repeated runs. --- tests/test_litellm/proxy/auth_v2/conftest.py | 2 +- .../proxy/auth_v2/test_authenticators.py | 2 +- .../test_litellm/proxy/auth_v2/test_config.py | 3 +- tests/test_litellm/proxy/auth_v2/test_oidc.py | 4 +-- .../proxy/auth_v2/test_resolver.py | 34 +++++++++++++++---- tests/test_litellm/proxy/auth_v2/test_saml.py | 10 +++--- .../proxy/auth_v2/test_security.py | 2 +- 7 files changed, 38 insertions(+), 19 deletions(-) diff --git a/tests/test_litellm/proxy/auth_v2/conftest.py b/tests/test_litellm/proxy/auth_v2/conftest.py index 2131ab25c783..32d89d78cfe8 100644 --- a/tests/test_litellm/proxy/auth_v2/conftest.py +++ b/tests/test_litellm/proxy/auth_v2/conftest.py @@ -7,7 +7,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from litellm.proxy.auth_v2.authenticators import JWTVerifier -from litellm.proxy.auth_v2.config import OIDCProviderConfig +from litellm.proxy.auth_v2 import OIDCProviderConfig from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient, TokenFactory diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index b3da31202cec..20d1bb0ef9a9 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -23,9 +23,9 @@ HttpBasicConfig, MutualTLSConfig, OAuth2IntrospectionConfig, - OIDCProviderConfig, TrustedProxyConfig, ) +from litellm.proxy.auth_v2 import OIDCProviderConfig from litellm.proxy.auth_v2.errors import AuthError from litellm.proxy.auth_v2.models import AuthMethod diff --git a/tests/test_litellm/proxy/auth_v2/test_config.py b/tests/test_litellm/proxy/auth_v2/test_config.py index fd4c26100be3..7958660b8213 100644 --- a/tests/test_litellm/proxy/auth_v2/test_config.py +++ b/tests/test_litellm/proxy/auth_v2/test_config.py @@ -5,9 +5,8 @@ from litellm.proxy.auth_v2.config import ( OAuth2IntrospectionConfig, - OIDCProviderConfig, - SAMLConfig, ) +from litellm.proxy.auth_v2 import OIDCProviderConfig, SAMLConfig def test_saml_config_requires_idp_metadata_when_enabled(): diff --git a/tests/test_litellm/proxy/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py index 6c816dd338f8..36c5bca1bc2a 100644 --- a/tests/test_litellm/proxy/auth_v2/test_oidc.py +++ b/tests/test_litellm/proxy/auth_v2/test_oidc.py @@ -1,7 +1,7 @@ from __future__ import annotations -from litellm.proxy.auth_v2.config import OIDCProviderConfig -from litellm.proxy.auth_v2.oidc import _provider_key, _user_from_userinfo +from litellm.proxy.auth_v2 import OIDCProviderConfig +from litellm.proxy.auth_v2.oidc.router import _provider_key, _user_from_userinfo from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index 3c534b6a4d6a..89128c71f282 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from scim2_models import Group as ScimGroup from litellm.proxy.auth_v2.errors import AuthError from litellm.proxy.auth_v2.models import ( @@ -80,9 +81,8 @@ async def test_subject_lookup_prefers_stored_principal(): assert resolved.subject == "from-store" -async def test_self_describing_token_builds_principal_from_claims(): - store = InMemoryIdentityStore() - credential = Credential( +def _oidc_credential(**claims) -> Credential: + return Credential( scheme=SecuritySchemeType.OPENID_CONNECT, method=AuthMethod.OIDC, subject="sub-42", @@ -92,19 +92,39 @@ async def test_self_describing_token_builds_principal_from_claims(): "email": "dana@example.com", "preferred_username": "dana", "name": "Dana D", - "groups": ["eng", "oncall"], - "roles": ["org_admin", "bogus_role"], + **claims, }, ) - principal = await store.resolve(credential) + + +async def test_self_describing_token_builds_principal_from_claims(): + store = InMemoryIdentityStore() + principal = await store.resolve(_oidc_credential(roles=["org_admin", "bogus_role"])) assert principal.user.email == "dana@example.com" assert principal.user.user_name == "dana" - assert [team.id for team in principal.teams] == ["eng", "oncall"] # invalid role strings are filtered out, valid ones become Role enums assert principal.roles == [Role.ORG_ADMIN] assert principal.scopes == ["models:read"] +async def test_group_claim_without_provisioned_scim_group_is_not_a_team(): + # H1: a token group claim is not authoritative on its own + store = InMemoryIdentityStore() + principal = await store.resolve(_oidc_credential(groups=["eng", "oncall"])) + assert principal.teams == [] + + +async def test_group_claim_becomes_team_only_when_provisioned(): + store = InMemoryIdentityStore( + groups={"eng": ScimGroup(id="eng", display_name="Engineering")} + ) + principal = await store.resolve(_oidc_credential(groups=["eng", "unprovisioned"])) + # only the provisioned group resolves to a team; the unknown one is dropped + assert len(principal.teams) == 1 + assert principal.teams[0].id == "eng" + assert principal.teams[0].name == "Engineering" + + async def test_mtls_credential_resolves_to_service_account(): store = InMemoryIdentityStore() credential = Credential( diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index f5ade9b0863d..c674dc48c292 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -94,7 +94,7 @@ def saml_env(tmp_path: Path) -> SamlEnv: from saml2.saml import NAMEID_FORMAT_EMAILADDRESS from saml2.server import Server - from litellm.proxy.auth_v2.config import SAMLConfig + from litellm.proxy.auth_v2 import SAMLConfig idp_key, idp_cert = _gen_cert(tmp_path, "idp") sp_key, sp_cert = _gen_cert(tmp_path, "sp") @@ -355,8 +355,8 @@ def test_login_rejects_open_redirect_next(saml_env): def test_map_attributes_applies_attribute_map(): - from litellm.proxy.auth_v2.config import DEFAULT_SAML_ATTRIBUTE_MAP - from litellm.proxy.auth_v2.saml import _map_attributes + from litellm.proxy.auth_v2.saml.config import DEFAULT_SAML_ATTRIBUTE_MAP + from litellm.proxy.auth_v2.saml.router import _map_attributes ava = { "email": ["alice@example.com"], @@ -372,7 +372,7 @@ def test_map_attributes_applies_attribute_map(): def test_user_from_mapped_builds_name_and_email(): - from litellm.proxy.auth_v2.saml import _user_from_mapped + from litellm.proxy.auth_v2.saml.router import _user_from_mapped user = _user_from_mapped( "alice@example.com", @@ -413,7 +413,7 @@ def test_safe_relay_state_blocks_open_redirects(candidate, expected): ], ) def test_metadata_source_classifies_input(metadata, expected_key): - from litellm.proxy.auth_v2.saml import _metadata_source + from litellm.proxy.auth_v2.saml.router import _metadata_source assert expected_key in _metadata_source(metadata) diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index 96d240d86e52..e2f64fb44fc6 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -15,8 +15,8 @@ ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, - OIDCProviderConfig, ) +from litellm.proxy.auth_v2 import OIDCProviderConfig from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType from litellm.proxy.auth_v2.rbac import RBACEngine, Role from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key From fc6d51cfc0da35b9bd8776477baecb1fe4d1e8c9 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:52:15 -0700 Subject: [PATCH 29/51] fix(auth_v2): gate OIDC login roles through the provider allowlist The browser OIDC login/callback path accepted IdP-asserted roles straight into the session, so a malicious or misconfigured IdP could assert platform_admin over SSO and have it land in the Principal - the same escalation the bearer token path already closes. The callback now runs the mapped claims through the shared _apply_role_policy with the matched provider config before minting the session, so roles outside allowed_roles are dropped and platform roles require allow_platform_roles. With the defaults (empty allowlist, platform off) no IdP-asserted role survives. --- litellm/proxy/auth_v2/oidc/router.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth_v2/oidc/router.py b/litellm/proxy/auth_v2/oidc/router.py index 61d72bcc8e53..df8c8d6d9818 100644 --- a/litellm/proxy/auth_v2/oidc/router.py +++ b/litellm/proxy/auth_v2/oidc/router.py @@ -36,7 +36,7 @@ def _mapped_claims(userinfo: Dict[str, Any]) -> Dict[str, Any]: def build_oidc_router(auth: AuthSecurity) -> APIRouter: session = auth.config.session - issuers = {_provider_key(p): p.issuer for p in auth.config.oidc_providers} + providers = {_provider_key(p): p for p in auth.config.oidc_providers} oauth = OAuth() for provider in auth.config.oidc_providers: oauth.register( @@ -118,17 +118,22 @@ async def callback(provider: str, request: Request) -> RedirectResponse: userinfo = await client.parse_id_token(token, nonce=txn.get("nonce")) else: userinfo = await client.userinfo(token=token) + from ..authenticators import _apply_role_policy + info = dict(userinfo) + provider_config = providers[provider] store = cast(ProvisioningStore, auth.resolver) await store.upsert_user(_user_from_userinfo(info)) + claims = _mapped_claims(info) + _apply_role_policy(claims, provider_config) session_id = auth.session_store.create_session( { "method": "oidc", "subject": info.get("sub"), - "issuer": info.get("iss") or issuers.get(provider), - "claims": _mapped_claims(info), + "issuer": info.get("iss") or provider_config.issuer, + "claims": claims, } ) target = safe_relay_state(txn.get("relay"), session.default_redirect_path) From 99efd314f7ead3e060bf96d43d4d35864fa4a1eb Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:52:46 -0700 Subject: [PATCH 30/51] fix(auth_v2): enforce the role allowlist on the SAML SSO path too The per-provider role allowlist + platform-role gate only covered the bearer-JWT path, so a SAML IdP could still mint platform_admin (or any Role) through its attribute->roles mapping. Extract the filter into a shared rbac.filter_claim_roles, add allowed_roles/allow_platform_roles to SAMLConfig (default empty = no roles from the assertion), and apply it in the ACS before the attributes become claims, so SSO paths enforce the same role policy as token paths. The JWT path now reuses the same helper. --- litellm/proxy/auth_v2/authenticators.py | 15 ++++----------- litellm/proxy/auth_v2/rbac.py | 17 ++++++++++++++++- litellm/proxy/auth_v2/saml/config.py | 4 +++- litellm/proxy/auth_v2/saml/router.py | 4 ++++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index dad667a57e36..44cba56332b9 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -32,22 +32,15 @@ ) from .network import ip_in_trusted_proxies from .oidc.config import OIDCProviderConfig -from .rbac import Role +from .rbac import filter_claim_roles AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} -_PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} def _apply_role_policy(claims: Dict[str, Any], provider: OIDCProviderConfig) -> None: - raw = claims.get("roles") - if not isinstance(raw, list): - claims["roles"] = [] - return - allowed = set(provider.allowed_roles) - filtered = [role for role in raw if role in allowed] - if not provider.allow_platform_roles: - filtered = [role for role in filtered if role not in _PLATFORM_ROLE_VALUES] - claims["roles"] = filtered + claims["roles"] = filter_claim_roles( + claims.get("roles"), provider.allowed_roles, provider.allow_platform_roles + ) @runtime_checkable diff --git a/litellm/proxy/auth_v2/rbac.py b/litellm/proxy/auth_v2/rbac.py index 31b2cfff907e..1924497f9fea 100644 --- a/litellm/proxy/auth_v2/rbac.py +++ b/litellm/proxy/auth_v2/rbac.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, List, Optional, Tuple import casbin from fastapi.security import SecurityScopes @@ -19,6 +19,21 @@ class Role(str, Enum): TEAM_MEMBER = "team_member" +_PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} + + +def filter_claim_roles( + roles: Any, allowed_roles: List[str], allow_platform_roles: bool +) -> List[str]: + if not isinstance(roles, list): + return [] + allowed = set(allowed_roles) + filtered = [role for role in roles if role in allowed] + if not allow_platform_roles: + filtered = [role for role in filtered if role not in _PLATFORM_ROLE_VALUES] + return filtered + + def has_required_scopes( security_scopes: SecurityScopes, principal: "Principal" ) -> bool: diff --git a/litellm/proxy/auth_v2/saml/config.py b/litellm/proxy/auth_v2/saml/config.py index 4c83fd872259..c2910400245f 100644 --- a/litellm/proxy/auth_v2/saml/config.py +++ b/litellm/proxy/auth_v2/saml/config.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, List, Optional from pydantic import BaseModel, Field, model_validator @@ -28,6 +28,8 @@ class SAMLConfig(BaseModel): attribute_map: Dict[str, str] = Field( default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) ) + allowed_roles: List[str] = Field(default_factory=list) + allow_platform_roles: bool = False @model_validator(mode="after") def _require_idp_metadata(self) -> "SAMLConfig": diff --git a/litellm/proxy/auth_v2/saml/router.py b/litellm/proxy/auth_v2/saml/router.py index 20569d7324b5..3a260a983639 100644 --- a/litellm/proxy/auth_v2/saml/router.py +++ b/litellm/proxy/auth_v2/saml/router.py @@ -13,6 +13,7 @@ from scim2_models import User as ScimUser from .config import SAMLConfig +from ..rbac import filter_claim_roles from ..resolver import ProvisioningStore from ..session import safe_relay_state @@ -238,6 +239,9 @@ async def assertion_consumer_service(request: Request) -> Response: name_id = authn_response.get_subject().text ava = authn_response.get_identity() or {} mapped = _map_attributes(ava, config.attribute_map) + mapped["roles"] = filter_claim_roles( + mapped.get("roles"), config.allowed_roles, config.allow_platform_roles + ) user = _user_from_mapped(name_id, mapped) store = cast(ProvisioningStore, auth.resolver) From 200f674b9495aa3ea95b541faa2838e237de0cb7 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 19:58:44 -0700 Subject: [PATCH 31/51] test(auth_v2): pin the token-claim hardening and provisioning security fixes Regression coverage for the security review fixes (H1/M1/M3/S7): - resolver: a deactivated SCIM user (active=False) is rejected 403; claims whose keys start with "_" never surface on the Principal - authenticators: H1 privilege escalation - a self-asserted token role grants nothing without a per-provider allowlist, the allowlist filters roles, and platform-level roles need an explicit allow_platform_roles gate - rbac: the Casbin act matcher is anchored, so a "GET" policy does not grant "GETX" - scim: PATCH that targets the read-only id (replace, remove, no-path replace) is rejected 400 with the record's id unchanged; unauthenticated/under-scoped requests render a SCIM Error body (401/403); /Schemas is a ListResponse envelope - saml: a replayed signed assertion is rejected 401 (single-use), and an unsolicited IdP-initiated response is rejected 401 when allow_unsolicited is off (default secure) Full auth_v2 suite: 165 passing. --- .../proxy/auth_v2/test_authenticators.py | 60 +++++++++++++++++++ tests/test_litellm/proxy/auth_v2/test_rbac.py | 10 ++++ .../proxy/auth_v2/test_resolver.py | 55 +++++++++++++++++ tests/test_litellm/proxy/auth_v2/test_saml.py | 30 ++++++++++ tests/test_litellm/proxy/auth_v2/test_scim.py | 42 ++++++++++++- 5 files changed, 196 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index 20d1bb0ef9a9..1a635cf0bdcc 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -483,3 +483,63 @@ def test_build_authenticators_includes_mtls_when_enabled(): config = AuthConfig(mutual_tls=MutualTLSConfig(enabled=True)) types = [type(a) for a in build_authenticators(config)] assert types[-1] is MutualTLSAuthenticator + + +# --------------------------------------------------------------------------- # +# H1: token role claims are gated by the provider allowlist (privilege escalation) +# --------------------------------------------------------------------------- # + + +def _bearer_roles(public_key, token_factory, roles, **provider_kwargs): + provider = OIDCProviderConfig( + issuer=TEST_ISSUER, audience=[TEST_AUDIENCE], **provider_kwargs + ) + auth = HttpAuthenticator( + HttpBasicConfig(), + [JWTVerifier(provider, jwks_client=FakeJwksClient(public_key))], + ) + token = token_factory.mint(roles=roles) + return auth, make_request(headers={"authorization": f"Bearer {token}"}) + + +async def test_token_roles_are_dropped_without_allowlist(rsa_keypair, token_factory): + _, public_key = rsa_keypair + auth, request = _bearer_roles( + public_key, token_factory, ["platform_admin", "org_admin"] + ) + credential = await auth.authenticate(request) + # default allowed_roles=[] -> a self-asserted role grants nothing + assert credential.claims["roles"] == [] + + +async def test_token_roles_filtered_to_allowlist(rsa_keypair, token_factory): + _, public_key = rsa_keypair + auth, request = _bearer_roles( + public_key, + token_factory, + ["platform_admin", "org_admin"], + allowed_roles=["org_admin"], + ) + credential = await auth.authenticate(request) + # org_admin is allowed; platform_admin is dropped (and not in the allowlist anyway) + assert credential.claims["roles"] == ["org_admin"] + + +async def test_platform_role_requires_explicit_gate(rsa_keypair, token_factory): + _, public_key = rsa_keypair + gated_auth, gated_req = _bearer_roles( + public_key, token_factory, ["platform_admin"], allowed_roles=["platform_admin"] + ) + # allowed but platform gate off -> still dropped + assert (await gated_auth.authenticate(gated_req)).claims["roles"] == [] + + open_auth, open_req = _bearer_roles( + public_key, + token_factory, + ["platform_admin"], + allowed_roles=["platform_admin"], + allow_platform_roles=True, + ) + assert (await open_auth.authenticate(open_req)).claims["roles"] == [ + "platform_admin" + ] diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index d0094593995a..f034f9ba7dd2 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -126,3 +126,13 @@ def test_csv_policy_overrides_defaults(tmp_path): assert not engine.enforce( _principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "GET" ) + + +def test_act_matcher_is_anchored(tmp_path): + # a "GET" policy must not grant a superstring act like "GETX" (regexMatch ^(...)$) + policy = tmp_path / "policy.csv" + policy.write_text("p, platform_viewer, /x, GET\n") + engine = RBACEngine(policy_path=str(policy)) + viewer = _principal(roles=[Role.PLATFORM_VIEWER]) + assert engine.enforce(viewer, "/x", "GET") + assert not engine.enforce(viewer, "/x", "GETX") diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index 89128c71f282..8388a63200f5 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -2,6 +2,7 @@ import pytest from scim2_models import Group as ScimGroup +from scim2_models import User as ScimUser from litellm.proxy.auth_v2.errors import AuthError from litellm.proxy.auth_v2.models import ( @@ -11,6 +12,7 @@ Principal, PrincipalType, SecuritySchemeType, + UserIdentity, ) from litellm.proxy.auth_v2.rbac import Role from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key @@ -137,3 +139,56 @@ async def test_mtls_credential_resolves_to_service_account(): assert principal.principal_type == PrincipalType.SERVICE_ACCOUNT assert principal.user is None assert principal.subject == "CN=svc-a,O=Co" + + +# --------------------------------------------------------------------------- # +# Deactivated users (M1) and claims scrubbing +# --------------------------------------------------------------------------- # + + +async def test_deactivated_user_is_rejected(): + principal = Principal( + principal_type=PrincipalType.HUMAN, + subject="u-1", + auth_method=AuthMethod.API_KEY, + user=UserIdentity(id="u-1", email="u@example.com"), + ) + store = InMemoryIdentityStore( + api_keys={_hash_api_key("sk-deact"): principal}, + users={"u-1": ScimUser(id="u-1", user_name="u@example.com", active=False)}, + ) + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential("sk-deact")) + assert exc.value.status_code == 403 + + +async def test_active_user_is_allowed(): + principal = Principal( + principal_type=PrincipalType.HUMAN, + subject="u-2", + auth_method=AuthMethod.API_KEY, + user=UserIdentity(id="u-2", email="ok@example.com"), + ) + store = InMemoryIdentityStore( + api_keys={_hash_api_key("sk-ok"): principal}, + users={"u-2": ScimUser(id="u-2", user_name="ok@example.com", active=True)}, + ) + resolved = await store.resolve(_api_key_credential("sk-ok")) + assert resolved.subject == "u-2" + + +async def test_principal_claims_scrub_underscore_keys(): + # internal underscore-prefixed claims (e.g. _raw_api_key) must never surface + # on the Principal built from a self-describing credential + store = InMemoryIdentityStore() + credential = Credential( + scheme=SecuritySchemeType.OPENID_CONNECT, + method=AuthMethod.OIDC, + subject="sub-x", + issuer="https://idp", + claims={"_raw_api_key": "leak", "_basic_password": "leak", "email": "e@x.com"}, + ) + principal = await store.resolve(credential) + assert "_raw_api_key" not in principal.claims + assert "_basic_password" not in principal.claims + assert principal.claims.get("email") == "e@x.com" diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index c674dc48c292..1e971e0c84fa 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -278,6 +278,36 @@ def test_acs_rejects_unsigned_assertion(saml_env): assert store._users == {} +def test_acs_rejects_replayed_assertion(saml_env): + # a signed assertion is single-use; replaying it is rejected + app, _ = _build_app(saml_env) + client = TestClient(app) + response = saml_env.mint_response() + first = client.post( + "/auth/saml/acs", data={"SAMLResponse": response}, follow_redirects=False + ) + assert first.status_code == 303 + second = client.post( + "/auth/saml/acs", data={"SAMLResponse": response}, follow_redirects=False + ) + assert second.status_code == 401 + + +def test_acs_rejects_unsolicited_when_disabled(saml_env): + # default-secure: an IdP-initiated (no InResponseTo) response is rejected + disabled = saml_env.config.model_copy(update={"allow_unsolicited": False}) + env = SamlEnv(config=disabled, idp=saml_env.idp) + app, store = _build_app(env) + client = TestClient(app) + response = client.post( + "/auth/saml/acs", + data={"SAMLResponse": env.mint_response()}, + follow_redirects=False, + ) + assert response.status_code == 401 + assert store._users == {} + + def test_acs_missing_response_is_rejected(saml_env): app, _ = _build_app(saml_env) client = TestClient(app) diff --git a/tests/test_litellm/proxy/auth_v2/test_scim.py b/tests/test_litellm/proxy/auth_v2/test_scim.py index ce463c0ed367..f46677ac29ba 100644 --- a/tests/test_litellm/proxy/auth_v2/test_scim.py +++ b/tests/test_litellm/proxy/auth_v2/test_scim.py @@ -162,7 +162,11 @@ def test_resource_types_lists_user_and_group(client): def test_schemas_endpoint_returns_user_and_group(client): response = client.get("/scim/v2/Schemas") assert response.status_code == 200 - assert response.json()["totalResults"] == 2 + body = response.json() + assert body["totalResults"] == 2 + # a ListResponse envelope, not a bare dict (regression for the envelope fix) + assert body["schemas"][0].endswith(":ListResponse") + assert len(body["Resources"]) == 2 # --------------------------------------------------------------------------- # @@ -178,6 +182,10 @@ def test_scim_requires_authentication(): ) assert response.status_code == 401 assert "WWW-Authenticate" in response.headers + # S7: auth failures are rendered as a SCIM Error, not the generic body + body = response.json() + assert body["schemas"] == [ERROR_SCHEMA] + assert body["status"] == "401" def test_scim_requires_scim_write_scope(): @@ -185,6 +193,38 @@ def test_scim_requires_scim_write_scope(): response = underscoped.get("/scim/v2/Users") assert response.status_code == 403 assert "insufficient_scope" in response.headers.get("WWW-Authenticate", "") + body = response.json() + assert body["schemas"] == [ERROR_SCHEMA] + assert body["status"] == "403" + + +# --------------------------------------------------------------------------- # +# id is read-only: PATCH attempting to mutate it is rejected (RFC 7643) +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "operation", + [ + {"op": "replace", "path": "id", "value": "evil"}, + {"op": "remove", "path": "id"}, + {"op": "replace", "value": {"id": "evil", "displayName": "X"}}, + ], +) +def test_patch_id_mutation_is_rejected(client, operation): + user_id = _create_user(client).json()["id"] + response = client.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [operation], + }, + ) + assert response.status_code == 400 + assert response.json()["schemas"] == [ERROR_SCHEMA] + # the record keeps its id; the attacker id never materializes + assert client.get(f"/scim/v2/Users/{user_id}").status_code == 200 + assert client.get("/scim/v2/Users/evil").status_code == 404 # --------------------------------------------------------------------------- # From 44ac50493e76f8b035df2537a71d96e36eaba139 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:03:24 -0700 Subject: [PATCH 32/51] ci(auth_v2): run the proxy auth_v2 suite with xmlsec1 in the proxy-auth shard The proxy/auth_v2 tests were not picked up by any shard because the proxy-auth path list matched the literal proxy/auth directory, not its sibling. Add the path and install xmlsec1 so the pysaml2 signing tests run rather than skip; the install is gated behind a new optional apt-packages input so the other shards are unchanged. --- .github/workflows/_test-unit-base.yml | 11 +++++++++++ .github/workflows/test-unit-proxy-auth.yml | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index a42b2f8f9dfb..e40007e88074 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -36,6 +36,11 @@ on: description: "Unique name for the coverage artifact (must be unique per run)" required: true type: string + apt-packages: + description: "Optional space-separated apt packages installed before dependencies (e.g. xmlsec1 libxmlsec1-dev for the SAML signing tests)" + required: false + type: string + default: "" permissions: contents: read @@ -71,6 +76,12 @@ jobs: restore-keys: | ${{ runner.os }}-uv- + - name: Install system packages + if: ${{ inputs.apt-packages != '' }} + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ${{ inputs.apt-packages }} + - name: Install dependencies run: | uv sync --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router diff --git a/.github/workflows/test-unit-proxy-auth.yml b/.github/workflows/test-unit-proxy-auth.yml index 99882066a8eb..a0b6233d7a07 100644 --- a/.github/workflows/test-unit-proxy-auth.yml +++ b/.github/workflows/test-unit-proxy-auth.yml @@ -21,7 +21,8 @@ jobs: proxy-auth: uses: ./.github/workflows/_test-unit-base.yml with: - test-path: "tests/test_litellm/proxy/auth tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine tests/test_litellm/proxy/client" + test-path: "tests/test_litellm/proxy/auth tests/test_litellm/proxy/auth_v2 tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine tests/test_litellm/proxy/client" + apt-packages: "xmlsec1 libxmlsec1-dev" workers: 2 reruns: 2 artifact-name: proxy-auth From ac971d7e8b7881504b522b671996307638ab01e6 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:06:04 -0700 Subject: [PATCH 33/51] test(auth_v2): pin the role allowlist on the OIDC-login and SAML-SSO paths The veria review-response fix gates IdP-asserted roles through the same per-provider allowlist on every role-bearing path, not just JWT bearer tokens. - rbac: filter_claim_roles (the shared gate) denies a self-asserted role by default, filters to the allowlist, and only admits platform roles behind the explicit allow_platform_roles flag - saml: a signed SSO assertion asserting platform_admin yields a session whose Principal has no roles by default, and is filtered to the allowlist when set - oidc: the login callback's identity build (map userinfo -> gate roles -> session) denies platform_admin by default and filters to the allowlist Full auth_v2 suite: 173 passing. --- tests/test_litellm/proxy/auth_v2/test_oidc.py | 45 +++++++++++++++++++ tests/test_litellm/proxy/auth_v2/test_rbac.py | 23 ++++++++++ tests/test_litellm/proxy/auth_v2/test_saml.py | 34 ++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/tests/test_litellm/proxy/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py index 36c5bca1bc2a..441e828788fd 100644 --- a/tests/test_litellm/proxy/auth_v2/test_oidc.py +++ b/tests/test_litellm/proxy/auth_v2/test_oidc.py @@ -48,3 +48,48 @@ async def test_callback_seam_upserts_userinfo_into_store(): assert fetched is not None assert fetched.external_id == "idp-subject-123" assert fetched.user_name == "dana" + + +async def _oidc_login_session_roles(userinfo, provider): + # mirror the callback's identity build: map userinfo, gate roles, store a session, + # then authenticate + resolve through the same seam a request would + from litellm.proxy.auth_v2.authenticators import _apply_role_policy + from litellm.proxy.auth_v2.oidc.router import _mapped_claims + from litellm.proxy.auth_v2.session import SessionAuthenticator, SessionStore + + from auth_v2_helpers import make_request + + claims = _mapped_claims(userinfo) + _apply_role_policy(claims, provider) + store = SessionStore() + sid = store.create_session( + {"method": "oidc", "subject": userinfo["sub"], "claims": claims} + ) + authenticator = SessionAuthenticator("litellm_session", store) + credential = await authenticator.authenticate( + make_request(cookies={"litellm_session": sid}) + ) + principal = await InMemoryIdentityStore().resolve(credential) + return [role.value for role in principal.roles] + + +async def test_oidc_login_platform_role_denied_by_default(): + provider = OIDCProviderConfig(issuer="https://idp.example.com", audience=["x"]) + userinfo = { + "sub": "u", + "email": "e@x.com", + "roles": ["platform_admin", "org_admin"], + } + assert await _oidc_login_session_roles(userinfo, provider) == [] + + +async def test_oidc_login_roles_filtered_to_allowlist(): + provider = OIDCProviderConfig( + issuer="https://idp.example.com", audience=["x"], allowed_roles=["org_admin"] + ) + userinfo = { + "sub": "u", + "email": "e@x.com", + "roles": ["platform_admin", "org_admin"], + } + assert await _oidc_login_session_roles(userinfo, provider) == ["org_admin"] diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index f034f9ba7dd2..7dc9738bf578 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -136,3 +136,26 @@ def test_act_matcher_is_anchored(tmp_path): viewer = _principal(roles=[Role.PLATFORM_VIEWER]) assert engine.enforce(viewer, "/x", "GET") assert not engine.enforce(viewer, "/x", "GETX") + + +# --------------------------------------------------------------------------- # +# filter_claim_roles: the shared allowlist gate for JWT, OIDC-login and SAML +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "roles,allowed,allow_platform,expected", + [ + # default deny: a self-asserted role grants nothing + (["platform_admin", "org_admin"], [], False, []), + # allowlist filters; platform role excluded even if listed without the gate + (["platform_admin", "org_admin"], ["org_admin"], False, ["org_admin"]), + (["platform_admin"], ["platform_admin"], False, []), + # platform role only survives with the explicit gate + (["platform_admin"], ["platform_admin"], True, ["platform_admin"]), + ], +) +def test_filter_claim_roles(roles, allowed, allow_platform, expected): + from litellm.proxy.auth_v2.rbac import filter_claim_roles + + assert filter_claim_roles(roles, allowed, allow_platform) == expected diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py index 1e971e0c84fa..3d3413844500 100644 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ b/tests/test_litellm/proxy/auth_v2/test_saml.py @@ -177,11 +177,45 @@ async def whoami( "subject": principal.subject, "auth_method": principal.auth_method.value, "email": principal.user.email if principal.user else None, + "roles": [role.value for role in principal.roles], } return app, store +def _saml_session_roles(env, *, asserted_roles): + app, _ = _build_app(env) + client = TestClient(app) + acs = client.post( + "/auth/saml/acs", + data={ + "SAMLResponse": env.mint_response( + identity={"email": ["alice@example.com"], "roles": asserted_roles} + ) + }, + follow_redirects=False, + ) + client.cookies.set("litellm_session", acs.cookies["litellm_session"]) + return client.get("/whoami").json()["roles"] + + +def test_saml_sso_platform_role_denied_by_default(saml_env): + # H1 on the SSO path: an IdP-asserted platform_admin grants nothing by default + roles = _saml_session_roles( + saml_env, asserted_roles=["platform_admin", "org_admin"] + ) + assert roles == [] + + +def test_saml_sso_roles_filtered_to_allowlist(saml_env): + env = SamlEnv( + config=saml_env.config.model_copy(update={"allowed_roles": ["org_admin"]}), + idp=saml_env.idp, + ) + roles = _saml_session_roles(env, asserted_roles=["platform_admin", "org_admin"]) + assert roles == ["org_admin"] + + # --------------------------------------------------------------------------- # # Metadata + login redirect # --------------------------------------------------------------------------- # From f158a6c9e0595de6c022899e375d2f65fc6754ce Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:18:24 -0700 Subject: [PATCH 34/51] ci(auth_v2): run auth_v2 tests in a dedicated shard to avoid test_models basename clash Adding tests/test_litellm/proxy/auth_v2 to the proxy-auth shard collided with proxy/client/test_models.py (both bare test_models, no package __init__), failing collection with an import file mismatch. Run the auth_v2 suite in its own job with the xmlsec1 apt packages instead; the base apt-packages input added earlier is reused. --- .github/workflows/test-unit-proxy-auth-v2.yml | 28 +++++++++++++++++++ .github/workflows/test-unit-proxy-auth.yml | 3 +- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-unit-proxy-auth-v2.yml diff --git a/.github/workflows/test-unit-proxy-auth-v2.yml b/.github/workflows/test-unit-proxy-auth-v2.yml new file mode 100644 index 000000000000..bea3a01e6b6c --- /dev/null +++ b/.github/workflows/test-unit-proxy-auth-v2.yml @@ -0,0 +1,28 @@ +name: "Unit Tests: Proxy auth_v2" + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_branch + - "litellm_**" + +permissions: + contents: read + id-token: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + proxy-auth-v2: + uses: ./.github/workflows/_test-unit-base.yml + with: + test-path: "tests/test_litellm/proxy/auth_v2" + apt-packages: "xmlsec1 libxmlsec1-dev" + workers: 2 + reruns: 2 + artifact-name: proxy-auth-v2 diff --git a/.github/workflows/test-unit-proxy-auth.yml b/.github/workflows/test-unit-proxy-auth.yml index a0b6233d7a07..99882066a8eb 100644 --- a/.github/workflows/test-unit-proxy-auth.yml +++ b/.github/workflows/test-unit-proxy-auth.yml @@ -21,8 +21,7 @@ jobs: proxy-auth: uses: ./.github/workflows/_test-unit-base.yml with: - test-path: "tests/test_litellm/proxy/auth tests/test_litellm/proxy/auth_v2 tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine tests/test_litellm/proxy/client" - apt-packages: "xmlsec1 libxmlsec1-dev" + test-path: "tests/test_litellm/proxy/auth tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine tests/test_litellm/proxy/client" workers: 2 reruns: 2 artifact-name: proxy-auth From c9e7fd829c0f6f562d131c0a856f32d90c6df4e8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:25:37 -0700 Subject: [PATCH 35/51] fix(auth_v2): prefer verified mTLS cert and confirm forwarded-DN gates on the socket peer The forwarded subject-DN trust gate already compared the raw transport peer (request.client.host / ASGI scope client) against trusted_proxy_cidrs, never the XFF-resolved IP, so an attacker spoofing X-Forwarded-For cannot defeat it. Make that ordering explicit and stronger: a genuinely verified client certificate from the ASGI TLS extension is now preferred when present, and the spoofable forwarded-header path is only consulted as a fallback, still gated on the direct socket peer. --- litellm/proxy/auth_v2/authenticators.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py index 44cba56332b9..cf5715decb7b 100644 --- a/litellm/proxy/auth_v2/authenticators.py +++ b/litellm/proxy/auth_v2/authenticators.py @@ -404,15 +404,17 @@ async def authenticate(self, request: Request) -> Optional[Credential]: ) def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: + tls = request.scope.get("extensions", {}).get("tls", {}) + verified_dn = tls.get("client_cert_name") + if verified_dn: + return ClientCertificate(subject_dn=verified_dn) if self._config.forwarded_subject_header: peer = request.client.host if request.client else None if not ip_in_trusted_proxies(peer, self._network): return None dn = request.headers.get(self._config.forwarded_subject_header) return ClientCertificate(subject_dn=dn) if dn else None - tls = request.scope.get("extensions", {}).get("tls", {}) - dn = tls.get("client_cert_name") - return ClientCertificate(subject_dn=dn) if dn else None + return None def challenge(self) -> str: return "" From a7f9d5d6b988531930a1cd5756c6cee32246bb42 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 20:29:03 -0700 Subject: [PATCH 36/51] test(auth_v2): pin the mTLS forwarded-DN gate against XFF spoofing The forwarded subject-DN trust gate keys on the raw socket peer and prefers a verified TLS-layer cert: - an untrusted peer cannot smuggle a forged DN by claiming a trusted address via X-Forwarded-For (the gate ignores XFF) - a verified client cert from the ASGI TLS extension wins over a proxy-forwarded DN header Full auth_v2 suite: 175 passing. --- .../proxy/auth_v2/test_authenticators.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index 1a635cf0bdcc..7950649c06a7 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -436,6 +436,30 @@ async def test_mtls_forwarded_header_from_untrusted_peer_is_ignored(): assert await auth.authenticate(request) is None +async def test_mtls_forwarded_header_gate_ignores_spoofed_xff(): + # the gate keys on the raw socket peer, not X-Forwarded-For: an untrusted peer + # cannot claim a trusted address via XFF to smuggle a forged DN header + auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) + request = make_request( + headers={"x-client-dn": "CN=attacker", "x-forwarded-for": "10.0.0.5"}, + client=("8.8.8.8", 4444), + ) + assert await auth.authenticate(request) is None + + +async def test_mtls_prefers_verified_asgi_cert_over_forwarded_header(): + # a genuinely verified client cert from the TLS layer wins over a proxy header + auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) + request = make_request( + headers={"x-client-dn": "CN=from-header"}, + client=("10.0.0.9", 1), + scope_extra={"extensions": {"tls": {"client_cert_name": "CN=from-tls"}}}, + ) + credential = await auth.authenticate(request) + assert credential is not None + assert credential.subject == "CN=from-tls" + + async def test_mtls_forwarded_header_absent_returns_none(): auth = _mtls(MutualTLSConfig(enabled=True, forwarded_subject_header="x-client-dn")) assert await auth.authenticate(make_request()) is None From e989016c2d355694d7dbec1dc2926cb52df5805d Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 09:46:52 -0700 Subject: [PATCH 37/51] fix(auth_v2): match request paths with keyMatch so obj patterns span segments Use keyMatch instead of keyMatch2 in the Casbin matcher so a "/*" or "/scim/v2/*" obj pattern unambiguously spans path separators - a require_permission check on a multi-level route like /api/v1/models now matches the granting policy rather than risking a 403. keyMatch is the canonical trailing-wildcard route matcher; the anchored act matcher is unchanged, so a "GET" policy still cannot grant "GETX". --- litellm/proxy/auth_v2/rbac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/auth_v2/rbac.py b/litellm/proxy/auth_v2/rbac.py index 1924497f9fea..f679e1c20c31 100644 --- a/litellm/proxy/auth_v2/rbac.py +++ b/litellm/proxy/auth_v2/rbac.py @@ -54,7 +54,7 @@ def has_required_scopes( e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, "^(" + p.act + ")$") +m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && regexMatch(r.act, "^(" + p.act + ")$") """ _DEFAULT_GROUPING: List[Tuple[str, str]] = [ From 2fad79b4fecbeec0617bc759acabd7c2098108ed Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 09:51:14 -0700 Subject: [PATCH 38/51] test(auth_v2): pin multi-segment path authorization (keyMatch) The Casbin object matcher spans path separators now, so a "/*" or "/api/*" policy covers nested routes: - a granted role is allowed on a multi-level path (platform_viewer GET /api/v1/models, platform_admin POST /api/v1/x/y) - an ungranted role/verb is still denied across segments (org_viewer and GET-only viewers on writes), and the anchored act matcher still rejects a superstring verb (GETX) - an operator CSV object pattern spans segments the same way Full auth_v2 suite: 178 passing. --- tests/test_litellm/proxy/auth_v2/test_rbac.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index 7dc9738bf578..69f557762c4b 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -159,3 +159,41 @@ def test_filter_claim_roles(roles, allowed, allow_platform, expected): from litellm.proxy.auth_v2.rbac import filter_claim_roles assert filter_claim_roles(roles, allowed, allow_platform) == expected + + +# --------------------------------------------------------------------------- # +# Object matcher spans path separators (keyMatch): multi-segment authorization +# --------------------------------------------------------------------------- # + + +def test_enforce_matches_multi_segment_paths(engine): + # "/*" now spans separators, so nested routes are covered by the default policy + assert engine.enforce( + _principal(roles=[Role.PLATFORM_VIEWER]), "/api/v1/models", "GET" + ) + assert engine.enforce( + _principal(roles=[Role.PLATFORM_ADMIN]), "/api/v1/x/y", "POST" + ) + + +def test_enforce_denies_multi_segment_when_unauthorized(engine): + # viewer is GET-only and org_viewer has no write grant, even on nested paths; + # the act anchor still rejects a superstring verb + assert not engine.enforce( + _principal(roles=[Role.ORG_VIEWER]), "/api/v1/models", "POST" + ) + assert not engine.enforce( + _principal(roles=[Role.PLATFORM_VIEWER]), "/api/v1/models", "POST" + ) + assert not engine.enforce( + _principal(roles=[Role.PLATFORM_VIEWER]), "/api/v1/models", "GETX" + ) + + +def test_operator_csv_object_pattern_spans_segments(tmp_path): + policy = tmp_path / "policy.csv" + policy.write_text("p, org_viewer, /api/*, GET\n") + engine = RBACEngine(policy_path=str(policy)) + viewer = _principal(roles=[Role.ORG_VIEWER]) + assert engine.enforce(viewer, "/api/v1/models", "GET") + assert not engine.enforce(viewer, "/api/v1/models", "GETX") From 343909d63298311994ac4f41d5c083c30ce7a875 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 09:52:18 -0700 Subject: [PATCH 39/51] test(auth_v2): cover deep SCIM path under keyMatch and drop stale comment Add a platform_admin POST /scim/v2/Groups assertion alongside the existing /scim/v2/Users DELETE so the multi-segment grant is pinned on a second deep path, and correct the stale keyMatch2 comment to keyMatch. --- tests/test_litellm/proxy/auth_v2/test_rbac.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index 69f557762c4b..7f6ca77f4b1a 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -87,10 +87,13 @@ def test_has_role_false_without_roles(engine): def test_platform_admin_enforces_any_object_and_action(engine): assert engine.enforce(_principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "POST") - # keyMatch2: /scim/v2/* covers /scim/v2/Users + # keyMatch: "/*" / "/scim/v2/*" span path separators, so deep paths are covered assert engine.enforce( _principal(roles=[Role.PLATFORM_ADMIN]), "/scim/v2/Users", "DELETE" ) + assert engine.enforce( + _principal(roles=[Role.PLATFORM_ADMIN]), "/scim/v2/Groups", "POST" + ) def test_platform_viewer_is_read_only(engine): From c71291f291d2d6bf0cc50ed3b50fa15f3a468b88 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 16:27:52 -0700 Subject: [PATCH 40/51] refactor: authv2 --- backend/auth/routers/__init__.py | 5 + backend/auth/routers/dependencies.py | 49 ++ backend/auth/routers/oidc.py | 125 +++++ backend/auth/routers/saml.py | 121 +++++ backend/auth/routers/scim.py | 143 ++++++ backend/auth/services/__init__.py | 0 backend/auth/services/oidc.py | 49 ++ backend/auth/services/redirects.py | 15 + .../auth/services/saml.py | 126 +---- backend/auth/services/scim.py | 178 +++++++ litellm/proxy/auth_v2/__init__.py | 20 +- litellm/proxy/auth_v2/authenticators.py | 441 ------------------ .../proxy/auth_v2/authenticators/__init__.py | 23 + litellm/proxy/auth_v2/authenticators/base.py | 14 + .../proxy/auth_v2/authenticators/config.py | 29 ++ litellm/proxy/auth_v2/authenticators/http.py | 74 +++ litellm/proxy/auth_v2/authenticators/key.py | 29 ++ litellm/proxy/auth_v2/authenticators/mtls.py | 43 ++ litellm/proxy/auth_v2/authenticators/oauth.py | 93 ++++ litellm/proxy/auth_v2/authenticators/oidc.py | 24 + .../proxy/auth_v2/authenticators/session.py | 40 ++ litellm/proxy/auth_v2/authenticators/types.py | 30 ++ litellm/proxy/auth_v2/authenticators/utils.py | 150 ++++++ .../proxy/auth_v2/authorization/__init__.py | 12 + litellm/proxy/auth_v2/authorization/base.py | 26 ++ .../proxy/auth_v2/{ => authorization}/rbac.py | 46 +- litellm/proxy/auth_v2/authorization/roles.py | 26 ++ litellm/proxy/auth_v2/authorization/scopes.py | 12 + litellm/proxy/auth_v2/config.py | 85 +++- litellm/proxy/auth_v2/errors.py | 48 +- litellm/proxy/auth_v2/models.py | 2 +- litellm/proxy/auth_v2/network.py | 4 +- litellm/proxy/auth_v2/oidc/__init__.py | 4 - litellm/proxy/auth_v2/oidc/config.py | 32 -- litellm/proxy/auth_v2/oidc/router.py | 150 ------ litellm/proxy/auth_v2/resolvers/__init__.py | 17 + litellm/proxy/auth_v2/resolvers/base.py | 35 ++ litellm/proxy/auth_v2/resolvers/db.py | 228 +++++++++ .../{resolver.py => resolvers/memory.py} | 57 +-- litellm/proxy/auth_v2/resolvers/utils.py | 106 +++++ litellm/proxy/auth_v2/saml/__init__.py | 4 - litellm/proxy/auth_v2/saml/config.py | 40 -- litellm/proxy/auth_v2/scim/__init__.py | 3 - litellm/proxy/auth_v2/scim/router.py | 306 ------------ litellm/proxy/auth_v2/security.py | 55 ++- litellm/proxy/auth_v2/session.py | 99 ---- litellm/proxy/auth_v2/sessions/__init__.py | 15 + litellm/proxy/auth_v2/sessions/base.py | 31 ++ litellm/proxy/auth_v2/sessions/factory.py | 79 ++++ litellm/proxy/auth_v2/sessions/memory.py | 66 +++ litellm/proxy/auth_v2/sessions/redis.py | 42 ++ litellm/proxy/auth_v2/sessions/schemas.py | 23 + 52 files changed, 2141 insertions(+), 1333 deletions(-) create mode 100644 backend/auth/routers/__init__.py create mode 100644 backend/auth/routers/dependencies.py create mode 100644 backend/auth/routers/oidc.py create mode 100644 backend/auth/routers/saml.py create mode 100644 backend/auth/routers/scim.py create mode 100644 backend/auth/services/__init__.py create mode 100644 backend/auth/services/oidc.py create mode 100644 backend/auth/services/redirects.py rename litellm/proxy/auth_v2/saml/router.py => backend/auth/services/saml.py (51%) create mode 100644 backend/auth/services/scim.py delete mode 100644 litellm/proxy/auth_v2/authenticators.py create mode 100644 litellm/proxy/auth_v2/authenticators/__init__.py create mode 100644 litellm/proxy/auth_v2/authenticators/base.py create mode 100644 litellm/proxy/auth_v2/authenticators/config.py create mode 100644 litellm/proxy/auth_v2/authenticators/http.py create mode 100644 litellm/proxy/auth_v2/authenticators/key.py create mode 100644 litellm/proxy/auth_v2/authenticators/mtls.py create mode 100644 litellm/proxy/auth_v2/authenticators/oauth.py create mode 100644 litellm/proxy/auth_v2/authenticators/oidc.py create mode 100644 litellm/proxy/auth_v2/authenticators/session.py create mode 100644 litellm/proxy/auth_v2/authenticators/types.py create mode 100644 litellm/proxy/auth_v2/authenticators/utils.py create mode 100644 litellm/proxy/auth_v2/authorization/__init__.py create mode 100644 litellm/proxy/auth_v2/authorization/base.py rename litellm/proxy/auth_v2/{ => authorization}/rbac.py (61%) create mode 100644 litellm/proxy/auth_v2/authorization/roles.py create mode 100644 litellm/proxy/auth_v2/authorization/scopes.py delete mode 100644 litellm/proxy/auth_v2/oidc/__init__.py delete mode 100644 litellm/proxy/auth_v2/oidc/config.py delete mode 100644 litellm/proxy/auth_v2/oidc/router.py create mode 100644 litellm/proxy/auth_v2/resolvers/__init__.py create mode 100644 litellm/proxy/auth_v2/resolvers/base.py create mode 100644 litellm/proxy/auth_v2/resolvers/db.py rename litellm/proxy/auth_v2/{resolver.py => resolvers/memory.py} (76%) create mode 100644 litellm/proxy/auth_v2/resolvers/utils.py delete mode 100644 litellm/proxy/auth_v2/saml/__init__.py delete mode 100644 litellm/proxy/auth_v2/saml/config.py delete mode 100644 litellm/proxy/auth_v2/scim/__init__.py delete mode 100644 litellm/proxy/auth_v2/scim/router.py delete mode 100644 litellm/proxy/auth_v2/session.py create mode 100644 litellm/proxy/auth_v2/sessions/__init__.py create mode 100644 litellm/proxy/auth_v2/sessions/base.py create mode 100644 litellm/proxy/auth_v2/sessions/factory.py create mode 100644 litellm/proxy/auth_v2/sessions/memory.py create mode 100644 litellm/proxy/auth_v2/sessions/redis.py create mode 100644 litellm/proxy/auth_v2/sessions/schemas.py diff --git a/backend/auth/routers/__init__.py b/backend/auth/routers/__init__.py new file mode 100644 index 000000000000..8f2c12ef37f8 --- /dev/null +++ b/backend/auth/routers/__init__.py @@ -0,0 +1,5 @@ +from .oidc import router as oidc_router +from .saml import router as saml_router +from .scim import router as scim_router + +__all__ = ["oidc_router", "saml_router", "scim_router"] diff --git a/backend/auth/routers/dependencies.py b/backend/auth/routers/dependencies.py new file mode 100644 index 000000000000..46c47cc9963e --- /dev/null +++ b/backend/auth/routers/dependencies.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Tuple, cast + +from authlib.integrations.starlette_client import OAuth +from fastapi import Request +from fastapi.security import SecurityScopes +from saml2.client import Saml2Client + +from litellm.proxy.auth_v2.models import Principal +from litellm.proxy.auth_v2.resolvers import ProvisioningStore +from litellm.proxy.auth_v2.security import AuthSecurity + +from ..services.oidc import build_oauth_registry +from ..services.saml import SAMLProtocolStore, build_sp_client + + +def get_auth(request: Request) -> AuthSecurity: + return request.app.state.auth_v2 + + +def get_oauth_registry(request: Request) -> OAuth: + cached = getattr(request.app.state, "oidc_oauth", None) + if cached is None: + cached = build_oauth_registry(get_auth(request).config.oidc_providers) + request.app.state.oidc_oauth = cached + return cached + + +def get_saml_runtime(request: Request) -> Tuple[Saml2Client, SAMLProtocolStore]: + state = request.app.state + client = getattr(state, "saml_client", None) + if client is None: + auth = get_auth(request) + config = auth.config.saml + assert config is not None + client = build_sp_client(config) + state.saml_client = client + state.saml_protocol = SAMLProtocolStore(auth.config.session.ttl_seconds) + return client, state.saml_protocol + + +async def scim_principal(request: Request) -> Principal: + auth = get_auth(request) + return await auth.principal(SecurityScopes(scopes=["scim:write"]), request) + + +def scim_store(request: Request) -> ProvisioningStore: + return cast(ProvisioningStore, get_auth(request).resolver) diff --git a/backend/auth/routers/oidc.py b/backend/auth/routers/oidc.py new file mode 100644 index 000000000000..a489012ce4c4 --- /dev/null +++ b/backend/auth/routers/oidc.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import secrets +from typing import cast + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.authenticators import apply_role_policy +from litellm.proxy.auth_v2.models import AuthMethod +from ..services.redirects import safe_relay_state +from litellm.proxy.auth_v2.resolvers import ProvisioningStore +from litellm.proxy.auth_v2.security import AuthSecurity +from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState + +from ..services.oidc import mapped_claims, providers_by_key, user_from_userinfo +from .dependencies import get_auth, get_oauth_registry + +router = APIRouter(prefix="/auth/oidc", tags=["oidc"]) + + +@router.get("/{provider}/login") +async def login( + provider: str, + request: Request, + auth: AuthSecurity = Depends(get_auth), + oauth: OAuth = Depends(get_oauth_registry), +) -> RedirectResponse: + session = auth.config.session + client = oauth.create_client(provider) + if client is None: + raise errors.unknown_provider() + redirect_uri = str(request.url_for("oidc_callback", provider=provider)) + relay = safe_relay_state(request.query_params.get("next"), session.default_redirect_path) + authorization = await client.create_authorization_url(redirect_uri) + txn_id = secrets.token_urlsafe(32) + await auth.oauth_txn_store.set( + txn_id, + OAuthTransaction( + provider=provider, + state=authorization["state"], + redirect_uri=redirect_uri, + relay=relay, + nonce=authorization.get("nonce"), + code_verifier=authorization.get("code_verifier"), + ), + ) + response = RedirectResponse(authorization["url"], status_code=303) + response.set_cookie( + session.login_cookie, + txn_id, + httponly=True, + samesite="lax", + secure=session.secure, + max_age=session.login_state_ttl, + ) + return response + + +@router.get("/{provider}/callback", name="oidc_callback") +async def callback( + provider: str, + request: Request, + auth: AuthSecurity = Depends(get_auth), + oauth: OAuth = Depends(get_oauth_registry), +) -> RedirectResponse: + session = auth.config.session + client = oauth.create_client(provider) + if client is None: + raise errors.unknown_provider() + txn_id = request.cookies.get(session.login_cookie) + txn = await auth.oauth_txn_store.pop(txn_id) if txn_id else None + if txn is None or txn["provider"] != provider: + raise errors.invalid_login_state() + returned_state = request.query_params.get("state") + if not returned_state or returned_state != txn["state"]: + raise errors.state_mismatch() + error = request.query_params.get("error") + if error: + raise errors.oidc_provider_error(error) + code = request.query_params.get("code") + if not code: + raise errors.missing_authorization_code() + + token = await client.fetch_access_token( + redirect_uri=txn["redirect_uri"], + code=code, + code_verifier=txn["code_verifier"], + state=txn["state"], + ) + if token.get("id_token"): + userinfo = await client.parse_id_token(token, nonce=txn["nonce"]) + else: + userinfo = await client.userinfo(token=token) + + info = dict(userinfo) + provider_config = providers_by_key(auth.config.oidc_providers)[provider] + + store = cast(ProvisioningStore, auth.resolver) + await store.upsert_user(user_from_userinfo(info)) + + claims = mapped_claims(info) + apply_role_policy(claims, provider_config) + session_id = secrets.token_urlsafe(32) + await auth.session_store.set( + session_id, + SessionState( + method=AuthMethod.OIDC.value, + subject=info.get("sub", ""), + issuer=info.get("iss") or provider_config.issuer, + claims=claims, + ), + ) + target = safe_relay_state(txn["relay"], session.default_redirect_path) + response = RedirectResponse(target, status_code=303) + response.set_cookie( + session.cookie, + session_id, + httponly=True, + samesite="lax", + secure=session.secure, + ) + return response diff --git a/backend/auth/routers/saml.py b/backend/auth/routers/saml.py new file mode 100644 index 000000000000..a4b1911dfb1a --- /dev/null +++ b/backend/auth/routers/saml.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import secrets +from typing import Tuple, cast + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse, Response +from saml2 import BINDING_HTTP_POST +from saml2.client import Saml2Client +from saml2.metadata import entity_descriptor + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.models import AuthMethod +from litellm.proxy.auth_v2.authorization import filter_claim_roles +from ..services.redirects import safe_relay_state +from litellm.proxy.auth_v2.resolvers import ProvisioningStore +from litellm.proxy.auth_v2.security import AuthSecurity +from litellm.proxy.auth_v2.sessions.schemas import SessionState + +from ..services.saml import ( + SAMLProtocolStore, + claims_from_mapped, + map_attributes, + user_from_mapped, +) +from .dependencies import get_auth, get_saml_runtime + +router = APIRouter(prefix="/auth/saml", tags=["saml"]) + + +@router.get("/metadata") +async def metadata( + runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime), +) -> Response: + client, _ = runtime + return Response( + content=str(entity_descriptor(client.config)), + media_type="application/samlmetadata+xml", + ) + + +@router.get("/login") +async def login( + request: Request, + auth: AuthSecurity = Depends(get_auth), + runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime), +) -> RedirectResponse: + session = auth.config.session + client, protocol = runtime + relay_state = safe_relay_state(request.query_params.get("next"), session.default_redirect_path) + request_id, info = client.prepare_for_authenticate(relay_state=relay_state) + protocol.remember_request(request_id, relay_state) + location = dict(info["headers"]).get("Location") + if not location: + raise errors.saml_redirect_failed() + return RedirectResponse(location, status_code=303) + + +@router.post("/acs") +async def assertion_consumer_service( + request: Request, + auth: AuthSecurity = Depends(get_auth), + runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime), +) -> Response: + config = auth.config.saml + assert config is not None + session = auth.config.session + client, protocol = runtime + + form = await request.form() + saml_response = form.get("SAMLResponse") + if not isinstance(saml_response, str): + raise errors.missing_saml_response() + try: + authn_response = client.parse_authn_request_response( + saml_response, + BINDING_HTTP_POST, + outstanding=protocol.outstanding_relays() or None, + ) + except Exception as exc: + raise errors.invalid_saml_response() from exc + if authn_response is None: + raise errors.invalid_saml_response() + + in_response_to = getattr(authn_response, "in_response_to", None) + bound_relay = protocol.consume_request(in_response_to) if in_response_to else None + + assertion = getattr(authn_response, "assertion", None) + assertion_id = getattr(assertion, "id", None) + if assertion_id and not protocol.consume_assertion(assertion_id): + raise errors.saml_assertion_replay() + + name_id = authn_response.get_subject().text + ava = authn_response.get_identity() or {} + mapped = map_attributes(ava, config.attribute_map) + mapped["roles"] = filter_claim_roles(mapped.get("roles"), config.allowed_roles, config.allow_platform_roles) + user = user_from_mapped(name_id, mapped) + + store = cast(ProvisioningStore, auth.resolver) + await store.upsert_user(user) + + session_id = secrets.token_urlsafe(32) + await auth.session_store.set( + session_id, + SessionState( + method=AuthMethod.SAML.value, + subject=name_id, + issuer=authn_response.issuer(), + claims=claims_from_mapped(mapped), + ), + ) + target = safe_relay_state(bound_relay, session.default_redirect_path) + response = RedirectResponse(target, status_code=303) + response.set_cookie( + session.cookie, + session_id, + httponly=True, + samesite="lax", + secure=session.secure, + ) + return response diff --git a/backend/auth/routers/scim.py b/backend/auth/routers/scim.py new file mode 100644 index 000000000000..70e519dcfd19 --- /dev/null +++ b/backend/auth/routers/scim.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Request, Response, status +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from scim2_models import Group, User + +from ..services import scim +from .dependencies import scim_principal, scim_store + +router = APIRouter(prefix="/scim/v2", tags=["scim"], route_class=scim.ScimErrorRoute) +_protected = [Depends(scim_principal)] + + +@router.post("/Users", status_code=status.HTTP_201_CREATED, dependencies=_protected) +async def create_user(request: Request) -> Response: + try: + user = scim.parse_resource(await request.json(), User) + except ValidationError as exc: + return scim.scim_error(status.HTTP_400_BAD_REQUEST, str(exc)) + stored = await scim_store(request).upsert_user(user) + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content=scim.creation_response(stored), + ) + + +@router.get("/Users/{resource_id}", dependencies=_protected) +async def get_user(resource_id: str, request: Request) -> Response: + user = await scim_store(request).get_user(resource_id) + if user is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + return JSONResponse(content=scim.query_response(user)) + + +@router.patch("/Users/{resource_id}", dependencies=_protected) +async def patch_user(resource_id: str, request: Request) -> Response: + store = scim_store(request) + user = await store.get_user(resource_id) + if user is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + try: + patched = scim.apply_patch(user, await request.json()) + except (ValidationError, ValueError) as exc: + return scim.scim_error(status.HTTP_400_BAD_REQUEST, str(exc)) + updated = await store.upsert_user(patched) + return JSONResponse(content=scim.patch_response(updated)) + + +@router.delete( + "/Users/{resource_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=_protected, +) +async def deactivate_user(resource_id: str, request: Request) -> Response: + store = scim_store(request) + if await store.get_user(resource_id) is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") + await store.deactivate_user(resource_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/Users", dependencies=_protected) +async def list_users( + request: Request, + filter_expr: Optional[str] = Query(default=None, alias="filter"), +) -> Response: + users = await scim_store(request).list_users(filter_expr) + return JSONResponse(content=scim.list_response(User, users)) + + +@router.post("/Groups", status_code=status.HTTP_201_CREATED, dependencies=_protected) +async def create_group(request: Request) -> Response: + try: + group = scim.parse_resource(await request.json(), Group) + except ValidationError as exc: + return scim.scim_error(status.HTTP_400_BAD_REQUEST, str(exc)) + stored = await scim_store(request).upsert_group(group) + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content=scim.creation_response(stored), + ) + + +@router.get("/Groups/{resource_id}", dependencies=_protected) +async def get_group(resource_id: str, request: Request) -> Response: + group = await scim_store(request).get_group(resource_id) + if group is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + return JSONResponse(content=scim.query_response(group)) + + +@router.patch("/Groups/{resource_id}", dependencies=_protected) +async def patch_group(resource_id: str, request: Request) -> Response: + store = scim_store(request) + group = await store.get_group(resource_id) + if group is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + try: + patched = scim.apply_patch(group, await request.json()) + except (ValidationError, ValueError) as exc: + return scim.scim_error(status.HTTP_400_BAD_REQUEST, str(exc)) + updated = await store.upsert_group(patched) + return JSONResponse(content=scim.patch_response(updated)) + + +@router.delete( + "/Groups/{resource_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=_protected, +) +async def delete_group(resource_id: str, request: Request) -> Response: + store = scim_store(request) + if await store.get_group(resource_id) is None: + return scim.scim_error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") + await store.delete_group(resource_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/Groups", dependencies=_protected) +async def list_groups( + request: Request, + filter_expr: Optional[str] = Query(default=None, alias="filter"), +) -> Response: + groups = await scim_store(request).list_groups(filter_expr) + return JSONResponse(content=scim.list_response(Group, groups)) + + +@router.get("/ServiceProviderConfig") +async def service_provider_config() -> Response: + return JSONResponse(content=scim.service_provider_config()) + + +@router.get("/ResourceTypes") +async def resource_types() -> Response: + return JSONResponse(content=scim.resource_types()) + + +@router.get("/Schemas") +async def schemas() -> Response: + return JSONResponse(content=scim.schemas()) diff --git a/backend/auth/services/__init__.py b/backend/auth/services/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/backend/auth/services/oidc.py b/backend/auth/services/oidc.py new file mode 100644 index 000000000000..ae9403cec6a1 --- /dev/null +++ b/backend/auth/services/oidc.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, List + +from authlib.integrations.starlette_client import OAuth +from scim2_models import User as ScimUser + +from litellm.proxy.auth_v2.config import OIDCProviderConfig + +CLAIM_KEYS = ("email", "preferred_username", "name", "groups", "roles") + + +def provider_key(provider: OIDCProviderConfig) -> str: + return re.sub(r"[^a-z0-9]+", "-", provider.issuer.lower()).strip("-") + + +def providers_by_key( + providers: List[OIDCProviderConfig], +) -> Dict[str, OIDCProviderConfig]: + return {provider_key(provider): provider for provider in providers} + + +def user_from_userinfo(userinfo: Dict[str, Any]) -> ScimUser: + return ScimUser( + external_id=userinfo.get("sub"), + user_name=userinfo.get("preferred_username") or userinfo.get("email"), + display_name=userinfo.get("name"), + ) + + +def mapped_claims(userinfo: Dict[str, Any]) -> Dict[str, Any]: + return {key: userinfo[key] for key in CLAIM_KEYS if userinfo.get(key) is not None} + + +def build_oauth_registry(providers: List[OIDCProviderConfig]) -> OAuth: + oauth = OAuth() + for provider in providers: + oauth.register( + name=provider_key(provider), + server_metadata_url=f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration", + client_id=provider.client_id, + client_secret=(provider.client_secret.get_secret_value() if provider.client_secret else None), + client_kwargs={ + "scope": " ".join(provider.login_scopes), + "code_challenge_method": "S256", + }, + ) + return oauth diff --git a/backend/auth/services/redirects.py b/backend/auth/services/redirects.py new file mode 100644 index 000000000000..5fcaf25b3b3c --- /dev/null +++ b/backend/auth/services/redirects.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Optional + + +def safe_relay_state(target: Optional[str], default: str) -> str: + """Return ``target`` only if it's a safe same-site path, else ``default``. + + Guards the post-login redirect against open-redirect: the target must be a + relative path (single leading slash, no scheme, no protocol-relative ``//`` + or backslash tricks). + """ + if target and target.startswith("/") and not target.startswith("//") and "://" not in target and "\\" not in target: + return target + return default diff --git a/litellm/proxy/auth_v2/saml/router.py b/backend/auth/services/saml.py similarity index 51% rename from litellm/proxy/auth_v2/saml/router.py rename to backend/auth/services/saml.py index 3a260a983639..7ad632c2fbbd 100644 --- a/litellm/proxy/auth_v2/saml/router.py +++ b/backend/auth/services/saml.py @@ -1,24 +1,15 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast +from typing import Any, Dict, List, Optional, Tuple -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import RedirectResponse, Response from saml2 import BINDING_HTTP_POST from saml2.client import Saml2Client from saml2.config import SPConfig -from saml2.metadata import entity_descriptor from scim2_models import Email, Name from scim2_models import User as ScimUser -from .config import SAMLConfig -from ..rbac import filter_claim_roles -from ..resolver import ProvisioningStore -from ..session import safe_relay_state - -if TYPE_CHECKING: - from ..security import AuthSecurity +from litellm.proxy.auth_v2.config import SAMLConfig _SINGLE_VALUE_TARGETS = { "email", @@ -30,9 +21,7 @@ _MULTI_VALUE_TARGETS = ("groups", "roles") -def _map_attributes( - ava: Dict[str, Any], attribute_map: Dict[str, str] -) -> Dict[str, Any]: +def map_attributes(ava: Dict[str, Any], attribute_map: Dict[str, str]) -> Dict[str, Any]: mapped: Dict[str, Any] = {} for saml_attr, target in attribute_map.items(): if saml_attr not in ava: @@ -49,13 +38,11 @@ def _map_attributes( def _formatted_name(mapped: Dict[str, Any]) -> Optional[str]: if mapped.get("display_name"): return mapped["display_name"] - parts: List[str] = [ - part for part in (mapped.get("given_name"), mapped.get("family_name")) if part - ] + parts: List[str] = [part for part in (mapped.get("given_name"), mapped.get("family_name")) if part] return " ".join(parts) if parts else None -def _user_from_mapped(name_id: str, mapped: Dict[str, Any]) -> ScimUser: +def user_from_mapped(name_id: str, mapped: Dict[str, Any]) -> ScimUser: display = _formatted_name(mapped) user = ScimUser( external_id=name_id, @@ -73,7 +60,7 @@ def _user_from_mapped(name_id: str, mapped: Dict[str, Any]) -> ScimUser: return user -def _claims_from_mapped(mapped: Dict[str, Any]) -> Dict[str, Any]: +def claims_from_mapped(mapped: Dict[str, Any]) -> Dict[str, Any]: claims: Dict[str, Any] = {} if mapped.get("email"): claims["email"] = mapped["email"] @@ -102,9 +89,7 @@ def _sp_config_dict(config: SAMLConfig) -> Dict[str, Any]: "entityid": config.entity_id, "service": { "sp": { - "endpoints": { - "assertion_consumer_service": [(config.acs_url, BINDING_HTTP_POST)] - }, + "endpoints": {"assertion_consumer_service": [(config.acs_url, BINDING_HTTP_POST)]}, "allow_unsolicited": config.allow_unsolicited, "authn_requests_signed": False, "want_assertions_signed": True, @@ -149,9 +134,7 @@ def remember_request(self, request_id: str, relay_state: str) -> None: def outstanding_relays(self) -> Dict[str, str]: now = time.time() - return { - rid: relay for rid, (exp, relay) in self._outstanding.items() if exp >= now - } + return {rid: relay for rid, (exp, relay) in self._outstanding.items() if exp >= now} def consume_request(self, request_id: str) -> Optional[str]: entry = self._outstanding.pop(request_id, None) @@ -171,99 +154,8 @@ def _evict_outstanding(self, now: float) -> None: def consume_assertion(self, assertion_id: str) -> bool: now = time.time() - self._seen_assertions = { - aid: exp for aid, exp in self._seen_assertions.items() if exp >= now - } + self._seen_assertions = {aid: exp for aid, exp in self._seen_assertions.items() if exp >= now} if assertion_id in self._seen_assertions: return False self._seen_assertions[assertion_id] = now + self._replay_ttl return True - - -def build_saml_router(auth: "AuthSecurity") -> APIRouter: - config = auth.config.saml - assert config is not None - session = auth.config.session - client = build_sp_client(config) - protocol = SAMLProtocolStore(session.ttl_seconds) - router = APIRouter(prefix="/auth/saml", tags=["saml"]) - - @router.get("/metadata") - async def metadata() -> Response: - return Response( - content=str(entity_descriptor(client.config)), - media_type="application/samlmetadata+xml", - ) - - @router.get("/login") - async def login(request: Request) -> RedirectResponse: - relay_state = safe_relay_state( - request.query_params.get("next"), session.default_redirect_path - ) - request_id, info = client.prepare_for_authenticate(relay_state=relay_state) - protocol.remember_request(request_id, relay_state) - location = dict(info["headers"]).get("Location") - if not location: - raise HTTPException(status_code=500, detail="no SAML redirect produced") - return RedirectResponse(location, status_code=303) - - @router.post("/acs") - async def assertion_consumer_service(request: Request) -> Response: - form = await request.form() - saml_response = form.get("SAMLResponse") - if not isinstance(saml_response, str): - raise HTTPException(status_code=400, detail="missing SAMLResponse") - try: - authn_response = client.parse_authn_request_response( - saml_response, - BINDING_HTTP_POST, - outstanding=protocol.outstanding_relays() or None, - ) - except Exception as exc: - raise HTTPException( - status_code=401, detail="invalid SAML response" - ) from exc - if authn_response is None: - raise HTTPException(status_code=401, detail="invalid SAML response") - - in_response_to = getattr(authn_response, "in_response_to", None) - bound_relay = ( - protocol.consume_request(in_response_to) if in_response_to else None - ) - - assertion = getattr(authn_response, "assertion", None) - assertion_id = getattr(assertion, "id", None) - if assertion_id and not protocol.consume_assertion(assertion_id): - raise HTTPException(status_code=401, detail="SAML assertion replay") - - name_id = authn_response.get_subject().text - ava = authn_response.get_identity() or {} - mapped = _map_attributes(ava, config.attribute_map) - mapped["roles"] = filter_claim_roles( - mapped.get("roles"), config.allowed_roles, config.allow_platform_roles - ) - user = _user_from_mapped(name_id, mapped) - - store = cast(ProvisioningStore, auth.resolver) - await store.upsert_user(user) - - session_id = auth.session_store.create_session( - { - "method": "saml", - "subject": name_id, - "issuer": authn_response.issuer(), - "claims": _claims_from_mapped(mapped), - } - ) - target = safe_relay_state(bound_relay, session.default_redirect_path) - response = RedirectResponse(target, status_code=303) - response.set_cookie( - session.cookie, - session_id, - httponly=True, - samesite="lax", - secure=session.secure, - ) - return response - - return router diff --git a/backend/auth/services/scim.py b/backend/auth/services/scim.py new file mode 100644 index 000000000000..516d26bf9e6d --- /dev/null +++ b/backend/auth/services/scim.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar + +from fastapi import HTTPException, Request, Response, status +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from scim2_models import ( + Bulk, + ChangePassword, + Context, + Error, + Filter, + Group, + ListResponse, + Patch, + PatchOp, + Resource, + ResourceType, + Schema, + ServiceProviderConfig, + Sort, + User, +) + +R = TypeVar("R", bound=Resource) + + +def scim_error(status_code: int, detail: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content=Error(status=str(status_code), detail=detail).model_dump(), + ) + + +class ScimErrorRoute(APIRoute): + """Render authentication failures with the SCIM Error schema (RFC 7644).""" + + def get_route_handler( # type: ignore[override] + self, + ) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def scim_handler(request: Request) -> Response: + try: + return await handler(request) + except HTTPException as exc: + if exc.status_code not in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ): + raise + response = scim_error(exc.status_code, str(exc.detail)) + if exc.headers: + response.headers.update(exc.headers) + return response + + return scim_handler + + +def parse_resource(body: Any, model: Type[R]) -> R: + return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + + +def _set_path(data: Dict[str, Any], path: str, value: Any) -> None: + keys = path.split(".") + node = data + for key in keys[:-1]: + child = node.get(key) + if not isinstance(child, dict): + child = {} + node[key] = child + node = child + node[keys[-1]] = value + + +def _remove_path(data: Dict[str, Any], path: str) -> None: + keys = path.split(".") + node = data + for key in keys[:-1]: + child = node.get(key) + if not isinstance(child, dict): + return + node = child + node.pop(keys[-1], None) + + +def _targets_read_only_id(op: Any) -> bool: + if op.path is not None: + return op.path.split(".")[0].strip().lower() == "id" + return isinstance(op.value, dict) and any(str(k).lower() == "id" for k in op.value) + + +def apply_patch(resource: R, body: Any) -> R: + patch = PatchOp[type(resource)].model_validate(body) + data: Dict[str, Any] = resource.model_dump() + for op in patch.operations: + action = op.op.value if hasattr(op.op, "value") else str(op.op) + if op.path is not None and ("[" in op.path or "]" in op.path): + raise ValueError(f"unsupported SCIM patch path filter: {op.path}") + if _targets_read_only_id(op): + raise ValueError("the SCIM id attribute is read-only") + if action == "remove": + if op.path: + _remove_path(data, op.path) + continue + if op.path is None and isinstance(op.value, dict): + data.update(op.value) + elif op.path is not None: + _set_path(data, op.path, op.value) + return type(resource).model_validate(data) + + +def creation_response(resource: Resource) -> Dict[str, Any]: + return resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_RESPONSE) + + +def query_response(resource: Resource) -> Dict[str, Any]: + return resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +def patch_response(resource: Resource) -> Dict[str, Any]: + return resource.model_dump(scim_ctx=Context.RESOURCE_PATCH_RESPONSE) + + +def list_response(model: Type[R], items: List[R]) -> Dict[str, Any]: + listing = ListResponse[model]( + total_results=len(items), + start_index=1, + items_per_page=len(items), + resources=items or None, + ) + return listing.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +def service_provider_config() -> Dict[str, Any]: + return ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=False, max_results=0), + change_password=ChangePassword(supported=False), + sort=Sort(supported=False), + etag=None, + authentication_schemes=[], + ).model_dump() + + +def resource_types() -> Dict[str, Any]: + types = [ + ResourceType( + id="User", + name="User", + endpoint="/Users", + schema="urn:ietf:params:scim:schemas:core:2.0:User", + ), + ResourceType( + id="Group", + name="Group", + endpoint="/Groups", + schema="urn:ietf:params:scim:schemas:core:2.0:Group", + ), + ] + return ListResponse[ResourceType]( + total_results=len(types), + start_index=1, + items_per_page=len(types), + resources=types, + ).model_dump() + + +def schemas() -> Dict[str, Any]: + resources = [User.to_schema(), Group.to_schema()] + return ListResponse[Schema]( + total_results=len(resources), + start_index=1, + items_per_page=len(resources), + resources=resources, + ).model_dump() diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 9c31dbce9ed6..5a744d71210c 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -1,19 +1,18 @@ -from .config import ( +from litellm.proxy.auth_v2.config import ( ApiKeySchemeConfig, AuthConfig, HttpBasicConfig, MutualTLSConfig, OAuth2IntrospectionConfig, + OIDCProviderConfig, + SAMLConfig, + SessionConfig, TrustedProxyConfig, ) -from .models import Principal -from .oidc import OIDCProviderConfig, build_oidc_router -from .rbac import Role -from .resolver import IdentityResolver, InMemoryIdentityStore, ProvisioningStore -from .saml import SAMLConfig, build_saml_router -from .scim import build_scim_router -from .security import AuthSecurity -from .session import SessionConfig +from litellm.proxy.auth_v2.models import Principal +from litellm.proxy.auth_v2.authorization import Role +from litellm.proxy.auth_v2.resolvers import IdentityResolver, InMemoryIdentityStore, ProvisioningStore +from litellm.proxy.auth_v2.security import AuthSecurity __all__ = [ "AuthSecurity", @@ -31,7 +30,4 @@ "TrustedProxyConfig", "SessionConfig", "SAMLConfig", - "build_saml_router", - "build_scim_router", - "build_oidc_router", ] diff --git a/litellm/proxy/auth_v2/authenticators.py b/litellm/proxy/auth_v2/authenticators.py deleted file mode 100644 index cf5715decb7b..000000000000 --- a/litellm/proxy/auth_v2/authenticators.py +++ /dev/null @@ -1,441 +0,0 @@ -from __future__ import annotations - -import base64 -import binascii -import functools -import hashlib -import hmac -import secrets -from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable - -import jwt -from fastapi import Request -from jwt import PyJWKClient -from jwt import decode as jwt_decode -from starlette.concurrency import run_in_threadpool - -from . import errors -from .config import ( - ApiKeySchemeConfig, - AuthConfig, - HttpBasicConfig, - MutualTLSConfig, - OAuth2IntrospectionConfig, - TrustedProxyConfig, -) -from .models import ( - AuthMethod, - ClientCertificate, - Credential, - CredentialRef, - SecuritySchemeType, -) -from .network import ip_in_trusted_proxies -from .oidc.config import OIDCProviderConfig -from .rbac import filter_claim_roles - -AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} - - -def _apply_role_policy(claims: Dict[str, Any], provider: OIDCProviderConfig) -> None: - claims["roles"] = filter_claim_roles( - claims.get("roles"), provider.allowed_roles, provider.allow_platform_roles - ) - - -@runtime_checkable -class Authenticator(Protocol): - async def authenticate(self, request: Request) -> Optional[Credential]: ... - - def challenge(self) -> str: ... - - -@runtime_checkable -class BasicAuthVerifier(Protocol): - def verify(self, username: str, password: str) -> bool: ... - - -_PBKDF2_ITERATIONS = 600_000 - - -def hash_basic_password(password: str, salt: Optional[str] = None) -> str: - salt = salt or secrets.token_hex(16) - digest = hashlib.pbkdf2_hmac( - "sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS - ).hex() - return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest}" - - -class InMemoryBasicAuthStore: - def __init__(self, credentials: Dict[str, str]) -> None: - self._credentials = credentials - - def verify(self, username: str, password: str) -> bool: - stored = self._credentials.get(username) - if stored is None: - return False - try: - _algorithm, iterations, salt, expected = stored.split("$") - candidate = hashlib.pbkdf2_hmac( - "sha256", password.encode(), bytes.fromhex(salt), int(iterations) - ).hex() - except ValueError: - return False - return hmac.compare_digest(candidate, expected) - - -def _extract_bearer(request: Request) -> Optional[str]: - header = request.headers.get("authorization") - if not header: - return None - scheme, _, value = header.partition(" ") - if scheme.lower() != "bearer" or not value: - return None - return value - - -def _looks_like_jwt(token: str) -> bool: - return token.count(".") == 2 - - -def _normalize_audience(value: Any) -> List[str]: - if isinstance(value, str): - return [value] - if isinstance(value, list): - return [str(item) for item in value] - return [] - - -def _split_scope(value: Any) -> List[str]: - return value.split() if isinstance(value, str) else [] - - -def _credential_from_claims( - scheme: SecuritySchemeType, - method: AuthMethod, - token: str, - claims: Dict[str, Any], -) -> Credential: - header = jwt.get_unverified_header(token) - return Credential( - scheme=scheme, - method=method, - subject=str(claims.get("sub", "")), - issuer=claims.get("iss"), - audience=_normalize_audience(claims.get("aud")), - scopes=_split_scope(claims.get("scope")), - claims=claims, - credential_ref=CredentialRef( - key_id=header.get("kid"), token_id=claims.get("jti") - ), - ) - - -class JWTVerifier: - def __init__( - self, - provider: OIDCProviderConfig, - jwks_client: Optional[PyJWKClient] = None, - ) -> None: - self.provider = provider - if jwks_client is not None: - self._jwks_client = jwks_client - return - jwks_uri = ( - str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() - ) - self._jwks_client = PyJWKClient( - jwks_uri, - cache_keys=True, - cache_jwk_set=True, - lifespan=300, - timeout=10, - ) - - def _discover_jwks(self) -> str: - import httpx - - url = f"{self.provider.issuer.rstrip('/')}/.well-known/openid-configuration" - response = httpx.get(url, timeout=10.0) - response.raise_for_status() - jwks_uri = response.json().get("jwks_uri") - if not jwks_uri: - raise ValueError(f"discovery document missing jwks_uri: {url}") - return str(jwks_uri) - - def verify( - self, token: str, *, require_at_jwt: Optional[bool] = None - ) -> Dict[str, Any]: - enforce = ( - self.provider.require_at_jwt if require_at_jwt is None else require_at_jwt - ) - if enforce: - header = jwt.get_unverified_header(token) - if str(header.get("typ", "")).lower() not in AT_JWT_TYPES: - raise errors.invalid_token("token typ must be at+jwt") - try: - signing_key = self._jwks_client.get_signing_key_from_jwt(token) - return jwt_decode( - token, - signing_key.key, - algorithms=self.provider.algorithms, - audience=self.provider.audience, - issuer=self.provider.issuer, - options={"verify_exp": True, "require": ["exp", "iss", "aud"]}, - ) - except jwt.PyJWTError as exc: - raise errors.invalid_token("token verification failed") from exc - - -async def _verify_jwt_off_loop( - verifier: JWTVerifier, token: str, *, require_at_jwt: Optional[bool] = None -) -> Dict[str, Any]: - return await run_in_threadpool( - functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt) - ) - - -def _select_verifier(token: str, verifiers: List[JWTVerifier]) -> Optional[JWTVerifier]: - if not verifiers: - return None - try: - issuer = jwt.decode(token, options={"verify_signature": False}).get("iss") - except jwt.PyJWTError: - return None - for verifier in verifiers: - if verifier.provider.issuer == issuer: - return verifier - return None - - -async def _authenticate_bearer_jwt( - token: str, - verifiers: List[JWTVerifier], - scheme: SecuritySchemeType, - method: AuthMethod, - *, - require_at_jwt: bool = False, -) -> Credential: - verifier = _select_verifier(token, verifiers) - if verifier is None: - raise errors.invalid_token("no issuer match") - claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=require_at_jwt) - _apply_role_policy(claims, verifier.provider) - return _credential_from_claims(scheme, method, token, claims) - - -class APIKeyAuthenticator: - def __init__(self, config: ApiKeySchemeConfig) -> None: - self._header_name = config.header_name - - async def authenticate(self, request: Request) -> Optional[Credential]: - raw = request.headers.get(self._header_name) - if not raw: - return None - return Credential( - scheme=SecuritySchemeType.API_KEY, - method=AuthMethod.API_KEY, - subject=raw, - credential_ref=CredentialRef(key_id=raw[:10]), - claims={"_raw_api_key": raw}, - ) - - def challenge(self) -> str: - return "" - - -class HttpAuthenticator: - def __init__( - self, - basic: HttpBasicConfig, - jwt_verifiers: List[JWTVerifier], - basic_verifier: Optional[BasicAuthVerifier] = None, - ) -> None: - self._basic = basic - self._verifiers = jwt_verifiers - self._basic_verifier = basic_verifier - - async def authenticate(self, request: Request) -> Optional[Credential]: - header = request.headers.get("authorization") - if not header: - return None - scheme, _, value = header.partition(" ") - scheme_lower = scheme.lower() - if scheme_lower == "bearer" and value: - return await _authenticate_bearer_jwt( - value, self._verifiers, SecuritySchemeType.HTTP, AuthMethod.BEARER_JWT - ) - if scheme_lower == "basic" and self._basic.enabled and value: - return self._verify_basic(value) - return None - - def _verify_basic(self, value: str) -> Credential: - challenge = errors.basic_challenge(self._basic.realm) - try: - decoded = base64.b64decode(value).decode("utf-8") - except (binascii.Error, UnicodeDecodeError) as exc: - raise errors.unauthenticated(challenge) from exc - username, separator, password = decoded.partition(":") - if ( - not username - or separator != ":" - or self._basic_verifier is None - or not self._basic_verifier.verify(username, password) - ): - raise errors.unauthenticated(challenge) - return Credential( - scheme=SecuritySchemeType.HTTP, - method=AuthMethod.HTTP_BASIC, - subject=username, - ) - - def challenge(self) -> str: - bearer = errors.bearer_challenge() - if self._basic.enabled: - return f"{bearer}, {errors.basic_challenge(self._basic.realm)}" - return bearer - - -def _default_introspection_client() -> Any: - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client - from litellm.types.llms.custom_http import httpxSpecialProvider - - return get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) - - -class OAuth2Authenticator: - def __init__( - self, - jwt_verifiers: List[JWTVerifier], - introspection: Optional[OAuth2IntrospectionConfig], - client_factory: Optional[Callable[[], Any]] = None, - ) -> None: - self._verifiers = jwt_verifiers - self._introspection = introspection - self._client_factory = client_factory or _default_introspection_client - - async def authenticate(self, request: Request) -> Optional[Credential]: - token = _extract_bearer(request) - if token is None: - return None - if _looks_like_jwt(token): - return await _authenticate_bearer_jwt( - token, - self._verifiers, - SecuritySchemeType.OAUTH2, - AuthMethod.BEARER_JWT, - require_at_jwt=True, - ) - if self._introspection is not None: - return await self._introspect(token) - raise errors.invalid_token() - - async def _introspect(self, token: str) -> Credential: - config = self._introspection - assert config is not None - basic = base64.b64encode( - f"{config.client_id}:{config.client_secret.get_secret_value()}".encode() - ).decode() - client = self._client_factory() - response = await client.post( - str(config.introspection_endpoint), - data={"token": token}, - headers={"Authorization": f"Basic {basic}"}, - timeout=10.0, - ) - if response.status_code != 200: - raise errors.invalid_token("introspection failed") - try: - body = response.json() - except ValueError as exc: - raise errors.invalid_token("introspection failed") from exc - if not isinstance(body, dict) or body.get("active") is not True: - raise errors.invalid_token("token inactive") - token_audience = _normalize_audience(body.get("aud")) - if config.audience and not set(token_audience) & set(config.audience): - raise errors.invalid_token("audience mismatch") - if config.issuer is not None and body.get("iss") != config.issuer: - raise errors.invalid_token("issuer mismatch") - claims = {key: value for key, value in body.items() if key != "roles"} - return Credential( - scheme=SecuritySchemeType.OAUTH2, - method=AuthMethod.OAUTH2_INTROSPECTION, - subject=str(body.get(config.subject_field, "")), - issuer=body.get("iss"), - audience=token_audience, - scopes=_split_scope(body.get("scope")), - claims=claims, - ) - - def challenge(self) -> str: - return errors.bearer_challenge() - - -class OIDCAuthenticator: - def __init__(self, jwt_verifiers: List[JWTVerifier]) -> None: - self._verifiers = jwt_verifiers - - async def authenticate(self, request: Request) -> Optional[Credential]: - token = _extract_bearer(request) - if token is None: - return None - return await _authenticate_bearer_jwt( - token, self._verifiers, SecuritySchemeType.OPENID_CONNECT, AuthMethod.OIDC - ) - - def challenge(self) -> str: - return errors.bearer_challenge() - - -class MutualTLSAuthenticator: - def __init__(self, config: MutualTLSConfig, network: TrustedProxyConfig) -> None: - self._config = config - self._network = network - - async def authenticate(self, request: Request) -> Optional[Credential]: - cert = self._read_client_cert(request) - if cert is None: - return None - return Credential( - scheme=SecuritySchemeType.MUTUAL_TLS, - method=AuthMethod.MUTUAL_TLS, - subject=cert.subject_dn, - client_certificate=cert, - ) - - def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: - tls = request.scope.get("extensions", {}).get("tls", {}) - verified_dn = tls.get("client_cert_name") - if verified_dn: - return ClientCertificate(subject_dn=verified_dn) - if self._config.forwarded_subject_header: - peer = request.client.host if request.client else None - if not ip_in_trusted_proxies(peer, self._network): - return None - dn = request.headers.get(self._config.forwarded_subject_header) - return ClientCertificate(subject_dn=dn) if dn else None - return None - - def challenge(self) -> str: - return "" - - -def build_authenticators( - config: AuthConfig, *, basic_verifier: Optional[BasicAuthVerifier] = None -) -> List[Authenticator]: - verifiers = [JWTVerifier(provider) for provider in config.oidc_providers] - by_scheme: Dict[SecuritySchemeType, Authenticator] = {} - if config.api_key is not None: - by_scheme[SecuritySchemeType.API_KEY] = APIKeyAuthenticator(config.api_key) - by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator( - config.http_basic, verifiers, basic_verifier - ) - by_scheme[SecuritySchemeType.OPENID_CONNECT] = OIDCAuthenticator(verifiers) - by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator( - verifiers, config.oauth2_introspection - ) - if config.mutual_tls.enabled: - by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTLSAuthenticator( - config.mutual_tls, config.network - ) - return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/proxy/auth_v2/authenticators/__init__.py b/litellm/proxy/auth_v2/authenticators/__init__.py new file mode 100644 index 000000000000..2944e20757e0 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/__init__.py @@ -0,0 +1,23 @@ +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.config import build_authenticators +from litellm.proxy.auth_v2.authenticators.http import HttpAuthenticator, hash_basic_password +from litellm.proxy.auth_v2.authenticators.key import APIKeyAuthenticator +from litellm.proxy.auth_v2.authenticators.mtls import MutualTLSAuthenticator +from litellm.proxy.auth_v2.authenticators.oauth import OAuth2Authenticator +from litellm.proxy.auth_v2.authenticators.oidc import OIDCAuthenticator +from litellm.proxy.auth_v2.authenticators.types import BasicAuthVerifier +from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, apply_role_policy + +__all__ = [ + "Authenticator", + "BasicAuthVerifier", + "JWTVerifier", + "APIKeyAuthenticator", + "HttpAuthenticator", + "OAuth2Authenticator", + "OIDCAuthenticator", + "MutualTLSAuthenticator", + "hash_basic_password", + "apply_role_policy", + "build_authenticators", +] diff --git a/litellm/proxy/auth_v2/authenticators/base.py b/litellm/proxy/auth_v2/authenticators/base.py new file mode 100644 index 000000000000..a4e2377e070b --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/base.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Optional, Protocol, runtime_checkable + +from fastapi import Request + +from litellm.proxy.auth_v2.models import Credential + + +@runtime_checkable +class Authenticator(Protocol): + async def authenticate(self, request: Request) -> Optional[Credential]: ... + + def challenge(self) -> str: ... diff --git a/litellm/proxy/auth_v2/authenticators/config.py b/litellm/proxy/auth_v2/authenticators/config.py new file mode 100644 index 000000000000..9002cb128f56 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/config.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from litellm.proxy.auth_v2.config import AuthConfig +from litellm.proxy.auth_v2.models import SecuritySchemeType +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.http import HttpAuthenticator +from litellm.proxy.auth_v2.authenticators.key import APIKeyAuthenticator +from litellm.proxy.auth_v2.authenticators.mtls import MutualTLSAuthenticator +from litellm.proxy.auth_v2.authenticators.oauth import OAuth2Authenticator +from litellm.proxy.auth_v2.authenticators.oidc import OIDCAuthenticator +from litellm.proxy.auth_v2.authenticators.types import BasicAuthVerifier +from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier + + +def build_authenticators( + config: AuthConfig, *, basic_verifier: Optional[BasicAuthVerifier] = None +) -> List[Authenticator]: + verifiers = [JWTVerifier(provider) for provider in config.oidc_providers] + by_scheme: Dict[SecuritySchemeType, Authenticator] = {} + if config.api_key is not None: + by_scheme[SecuritySchemeType.API_KEY] = APIKeyAuthenticator(config.api_key) + by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(config.http_basic, verifiers, basic_verifier) + by_scheme[SecuritySchemeType.OPENID_CONNECT] = OIDCAuthenticator(verifiers) + by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator(verifiers, config.oauth2_introspection) + if config.mutual_tls.enabled: + by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTLSAuthenticator(config.mutual_tls, config.network) + return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/proxy/auth_v2/authenticators/http.py b/litellm/proxy/auth_v2/authenticators/http.py new file mode 100644 index 000000000000..5bdb8dbf0c2d --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/http.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import secrets +from typing import List, Optional + +from fastapi import Request + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.config import HttpBasicConfig +from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.types import BasicAuthVerifier +from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, authenticate_bearer_jwt + +_PBKDF2_ITERATIONS = 600_000 + + +def hash_basic_password(password: str, salt: Optional[str] = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS).hex() + return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest}" + + +class HttpAuthenticator(Authenticator): + def __init__( + self, + basic: HttpBasicConfig, + jwt_verifiers: List[JWTVerifier], + basic_verifier: Optional[BasicAuthVerifier] = None, + ) -> None: + self._basic = basic + self._verifiers = jwt_verifiers + self._basic_verifier = basic_verifier + + async def authenticate(self, request: Request) -> Optional[Credential]: + header = request.headers.get("authorization") + if not header: + return None + scheme, _, value = header.partition(" ") + scheme_lower = scheme.lower() + if scheme_lower == "bearer" and value: + return await authenticate_bearer_jwt(value, self._verifiers, SecuritySchemeType.HTTP, AuthMethod.BEARER_JWT) + if scheme_lower == "basic" and self._basic.enabled and value: + return self._verify_basic(value) + return None + + def _verify_basic(self, value: str) -> Credential: + challenge = errors.basic_challenge(self._basic.realm) + try: + decoded = base64.b64decode(value).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise errors.unauthenticated(challenge) from exc + username, separator, password = decoded.partition(":") + if ( + not username + or separator != ":" + or self._basic_verifier is None + or not self._basic_verifier.verify(username, password) + ): + raise errors.unauthenticated(challenge) + return Credential( + scheme=SecuritySchemeType.HTTP, + method=AuthMethod.HTTP_BASIC, + subject=username, + ) + + def challenge(self) -> str: + bearer = errors.bearer_challenge() + if self._basic.enabled: + return f"{bearer}, {errors.basic_challenge(self._basic.realm)}" + return bearer diff --git a/litellm/proxy/auth_v2/authenticators/key.py b/litellm/proxy/auth_v2/authenticators/key.py new file mode 100644 index 000000000000..02859eb72bd1 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/key.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import Request + +from litellm.proxy.auth_v2.config import ApiKeySchemeConfig +from litellm.proxy.auth_v2.models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +from litellm.proxy.auth_v2.authenticators.base import Authenticator + + +class APIKeyAuthenticator(Authenticator): + def __init__(self, config: ApiKeySchemeConfig) -> None: + self._header_name = config.header_name + + async def authenticate(self, request: Request) -> Optional[Credential]: + raw = request.headers.get(self._header_name) + if not raw: + return None + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod.API_KEY, + subject=raw, + credential_ref=CredentialRef(key_id=raw[:10]), + claims={"_raw_api_key": raw}, + ) + + def challenge(self) -> str: + return "" diff --git a/litellm/proxy/auth_v2/authenticators/mtls.py b/litellm/proxy/auth_v2/authenticators/mtls.py new file mode 100644 index 000000000000..c460d40bbe45 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/mtls.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import Request + +from litellm.proxy.auth_v2.config import MutualTLSConfig, TrustedProxyConfig +from litellm.proxy.auth_v2.models import AuthMethod, ClientCertificate, Credential, SecuritySchemeType +from litellm.proxy.auth_v2.network import ip_in_trusted_proxies +from litellm.proxy.auth_v2.authenticators.base import Authenticator + + +class MutualTLSAuthenticator(Authenticator): + def __init__(self, config: MutualTLSConfig, network: TrustedProxyConfig) -> None: + self._config = config + self._network = network + + async def authenticate(self, request: Request) -> Optional[Credential]: + cert = self._read_client_cert(request) + if cert is None: + return None + return Credential( + scheme=SecuritySchemeType.MUTUAL_TLS, + method=AuthMethod.MUTUAL_TLS, + subject=cert.subject_dn, + client_certificate=cert, + ) + + def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: + tls = request.scope.get("extensions", {}).get("tls", {}) + verified_dn = tls.get("client_cert_name") + if verified_dn: + return ClientCertificate(subject_dn=verified_dn) + if self._config.forwarded_subject_header: + peer = request.client.host if request.client else None + if not ip_in_trusted_proxies(peer, self._network): + return None + dn = request.headers.get(self._config.forwarded_subject_header) + return ClientCertificate(subject_dn=dn) if dn else None + return None + + def challenge(self) -> str: + return "" diff --git a/litellm/proxy/auth_v2/authenticators/oauth.py b/litellm/proxy/auth_v2/authenticators/oauth.py new file mode 100644 index 000000000000..d2e0ea0035dd --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/oauth.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import base64 +from typing import List, Optional + +from fastapi import Request + +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.types.llms.custom_http import httpxSpecialProvider + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.config import OAuth2IntrospectionConfig +from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.types import IntrospectionClient, IntrospectionClientFactory +from litellm.proxy.auth_v2.authenticators.utils import ( + JWTVerifier, + authenticate_bearer_jwt, + extract_bearer, + looks_like_jwt, + normalize_audience, + split_scope, +) + + +def _default_introspection_client() -> IntrospectionClient: + return get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + + +class OAuth2Authenticator(Authenticator): + def __init__( + self, + jwt_verifiers: List[JWTVerifier], + introspection: Optional[OAuth2IntrospectionConfig], + client_factory: Optional[IntrospectionClientFactory] = None, + ) -> None: + self._verifiers = jwt_verifiers + self._introspection = introspection + self._client_factory = client_factory or _default_introspection_client + + async def authenticate(self, request: Request) -> Optional[Credential]: + token = extract_bearer(request) + if token is None: + return None + if looks_like_jwt(token): + return await authenticate_bearer_jwt( + token, + self._verifiers, + SecuritySchemeType.OAUTH2, + AuthMethod.BEARER_JWT, + require_at_jwt=True, + ) + if self._introspection is not None: + return await self._introspect(token) + raise errors.invalid_token() + + async def _introspect(self, token: str) -> Credential: + config = self._introspection + assert config is not None + basic = base64.b64encode(f"{config.client_id}:{config.client_secret.get_secret_value()}".encode()).decode() + client = self._client_factory() + response = await client.post( + str(config.introspection_endpoint), + data={"token": token}, + headers={"Authorization": f"Basic {basic}"}, + timeout=10.0, + ) + if response.status_code != 200: + raise errors.invalid_token("introspection failed") + try: + body = response.json() + except ValueError as exc: + raise errors.invalid_token("introspection failed") from exc + if not isinstance(body, dict) or body.get("active") is not True: + raise errors.invalid_token("token inactive") + token_audience = normalize_audience(body.get("aud")) + if config.audience and not set(token_audience) & set(config.audience): + raise errors.invalid_token("audience mismatch") + if config.issuer is not None and body.get("iss") != config.issuer: + raise errors.invalid_token("issuer mismatch") + claims = {key: value for key, value in body.items() if key != "roles"} + return Credential( + scheme=SecuritySchemeType.OAUTH2, + method=AuthMethod.OAUTH2_INTROSPECTION, + subject=str(body.get(config.subject_field, "")), + issuer=body.get("iss"), + audience=token_audience, + scopes=split_scope(body.get("scope")), + claims=claims, + ) + + def challenge(self) -> str: + return errors.bearer_challenge() diff --git a/litellm/proxy/auth_v2/authenticators/oidc.py b/litellm/proxy/auth_v2/authenticators/oidc.py new file mode 100644 index 000000000000..53751eb0dedf --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/oidc.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import List, Optional + +from fastapi import Request + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, authenticate_bearer_jwt, extract_bearer + + +class OIDCAuthenticator(Authenticator): + def __init__(self, jwt_verifiers: List[JWTVerifier]) -> None: + self._verifiers = jwt_verifiers + + async def authenticate(self, request: Request) -> Optional[Credential]: + token = extract_bearer(request) + if token is None: + return None + return await authenticate_bearer_jwt(token, self._verifiers, SecuritySchemeType.OPENID_CONNECT, AuthMethod.OIDC) + + def challenge(self) -> str: + return errors.bearer_challenge() diff --git a/litellm/proxy/auth_v2/authenticators/session.py b/litellm/proxy/auth_v2/authenticators/session.py new file mode 100644 index 000000000000..1d9aa7e3c140 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/session.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import Request + +from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Credential, + CredentialRef, + SecuritySchemeType, +) +from litellm.proxy.auth_v2.sessions import StateStore +from litellm.proxy.auth_v2.sessions.schemas import SessionState + + +class SessionAuthenticator(Authenticator): + def __init__(self, cookie_name: str, store: "StateStore[SessionState]") -> None: + self._cookie_name = cookie_name + self._store = store + + async def authenticate(self, request: Request) -> Optional[Credential]: + session_id = request.cookies.get(self._cookie_name) + if not session_id: + return None + identity = await self._store.get(session_id) + if identity is None: + return None + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod(identity["method"]), + subject=identity["subject"], + issuer=identity.get("issuer"), + claims=identity.get("claims", {}), + credential_ref=CredentialRef(token_id=session_id), + ) + + def challenge(self) -> str: + return "" diff --git a/litellm/proxy/auth_v2/authenticators/types.py b/litellm/proxy/auth_v2/authenticators/types.py new file mode 100644 index 000000000000..988ad410d77e --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/types.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Callable, Dict, Mapping, Protocol, runtime_checkable + +Claims = Dict[str, object] + + +@runtime_checkable +class BasicAuthVerifier(Protocol): + def verify(self, username: str, password: str) -> bool: ... + + +class IntrospectionResponse(Protocol): + status_code: int + + def json(self) -> object: ... + + +class IntrospectionClient(Protocol): + async def post( + self, + url: str, + *, + data: Mapping[str, str], + headers: Mapping[str, str], + timeout: float, + ) -> IntrospectionResponse: ... + + +IntrospectionClientFactory = Callable[[], IntrospectionClient] diff --git a/litellm/proxy/auth_v2/authenticators/utils.py b/litellm/proxy/auth_v2/authenticators/utils.py new file mode 100644 index 000000000000..be20515dcc20 --- /dev/null +++ b/litellm/proxy/auth_v2/authenticators/utils.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import functools +from typing import List, Optional + +import httpx +import jwt +from fastapi import Request +from jwt import PyJWKClient +from jwt import decode as jwt_decode +from starlette.concurrency import run_in_threadpool + +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +from litellm.proxy.auth_v2.config import OIDCProviderConfig +from litellm.proxy.auth_v2.authorization import filter_claim_roles +from litellm.proxy.auth_v2.authenticators.types import Claims + +AT_JWT_TYPES = {"at+jwt", "application/at+jwt"} + + +def apply_role_policy(claims: Claims, provider: OIDCProviderConfig) -> None: + claims["roles"] = filter_claim_roles(claims.get("roles"), provider.allowed_roles, provider.allow_platform_roles) + + +def extract_bearer(request: Request) -> Optional[str]: + header = request.headers.get("authorization") + if not header: + return None + scheme, _, value = header.partition(" ") + if scheme.lower() != "bearer" or not value: + return None + return value + + +def looks_like_jwt(token: str) -> bool: + return token.count(".") == 2 + + +def normalize_audience(value: object) -> List[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(item) for item in value] + return [] + + +def split_scope(value: object) -> List[str]: + return value.split() if isinstance(value, str) else [] + + +def credential_from_claims( + scheme: SecuritySchemeType, + method: AuthMethod, + token: str, + claims: Claims, +) -> Credential: + header = jwt.get_unverified_header(token) + issuer = claims.get("iss") + return Credential( + scheme=scheme, + method=method, + subject=str(claims.get("sub", "")), + issuer=issuer if isinstance(issuer, str) else None, + audience=normalize_audience(claims.get("aud")), + scopes=split_scope(claims.get("scope")), + claims=claims, + credential_ref=CredentialRef(key_id=header.get("kid"), token_id=claims.get("jti")), + ) + + +class JWTVerifier: + def __init__( + self, + provider: OIDCProviderConfig, + jwks_client: Optional[PyJWKClient] = None, + ) -> None: + self.provider = provider + if jwks_client is not None: + self._jwks_client = jwks_client + return + jwks_uri = str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() + self._jwks_client = PyJWKClient( + jwks_uri, + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=10, + ) + + def _discover_jwks(self) -> str: + url = f"{self.provider.issuer.rstrip('/')}/.well-known/openid-configuration" + response = httpx.get(url, timeout=10.0) + response.raise_for_status() + jwks_uri = response.json().get("jwks_uri") + if not jwks_uri: + raise ValueError(f"discovery document missing jwks_uri: {url}") + return str(jwks_uri) + + def verify(self, token: str, *, require_at_jwt: Optional[bool] = None) -> Claims: + enforce = self.provider.require_at_jwt if require_at_jwt is None else require_at_jwt + if enforce: + header = jwt.get_unverified_header(token) + if str(header.get("typ", "")).lower() not in AT_JWT_TYPES: + raise errors.invalid_token("token typ must be at+jwt") + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + return jwt_decode( + token, + signing_key.key, + algorithms=self.provider.algorithms, + audience=self.provider.audience, + issuer=self.provider.issuer, + options={"verify_exp": True, "require": ["exp", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + raise errors.invalid_token("token verification failed") from exc + + +async def _verify_jwt_off_loop(verifier: JWTVerifier, token: str, *, require_at_jwt: Optional[bool] = None) -> Claims: + return await run_in_threadpool(functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt)) + + +def _select_verifier(token: str, verifiers: List[JWTVerifier]) -> Optional[JWTVerifier]: + if not verifiers: + return None + try: + issuer = jwt.decode(token, options={"verify_signature": False}).get("iss") + except jwt.PyJWTError: + return None + for verifier in verifiers: + if verifier.provider.issuer == issuer: + return verifier + return None + + +async def authenticate_bearer_jwt( + token: str, + verifiers: List[JWTVerifier], + scheme: SecuritySchemeType, + method: AuthMethod, + *, + require_at_jwt: bool = False, +) -> Credential: + verifier = _select_verifier(token, verifiers) + if verifier is None: + raise errors.invalid_token("no issuer match") + claims = await _verify_jwt_off_loop(verifier, token, require_at_jwt=require_at_jwt) + apply_role_policy(claims, verifier.provider) + return credential_from_claims(scheme, method, token, claims) diff --git a/litellm/proxy/auth_v2/authorization/__init__.py b/litellm/proxy/auth_v2/authorization/__init__.py new file mode 100644 index 000000000000..e1bce928222a --- /dev/null +++ b/litellm/proxy/auth_v2/authorization/__init__.py @@ -0,0 +1,12 @@ +from litellm.proxy.auth_v2.authorization.base import Authorizer +from litellm.proxy.auth_v2.authorization.rbac import RBACEngine +from litellm.proxy.auth_v2.authorization.roles import Role, filter_claim_roles +from litellm.proxy.auth_v2.authorization.scopes import has_required_scopes + +__all__ = [ + "Authorizer", + "RBACEngine", + "Role", + "filter_claim_roles", + "has_required_scopes", +] diff --git a/litellm/proxy/auth_v2/authorization/base.py b/litellm/proxy/auth_v2/authorization/base.py new file mode 100644 index 000000000000..e5e9b8836e2b --- /dev/null +++ b/litellm/proxy/auth_v2/authorization/base.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, Tuple, runtime_checkable + +if TYPE_CHECKING: + from litellm.proxy.auth_v2.authorization.roles import Role + from litellm.proxy.auth_v2.models import Principal + + +@runtime_checkable +class Authorizer(Protocol): + """Decides what an authenticated principal is allowed to do. + + This is the extension point for authorization methods. RBAC is the only + implementation today; add others (ABAC, ReBAC, an external PDP, ...) by + implementing this protocol and passing the instance to + ``AuthSecurity(..., authorizer=...)``. + """ + + def enforce(self, principal: "Principal", obj: str, act: str) -> bool: + """Return True if ``principal`` may perform ``act`` on resource ``obj``.""" + ... + + def has_any_role(self, principal: "Principal", allowed: "Tuple[Role, ...]") -> bool: + """Return True if ``principal`` holds (or inherits) any of ``allowed``.""" + ... diff --git a/litellm/proxy/auth_v2/rbac.py b/litellm/proxy/auth_v2/authorization/rbac.py similarity index 61% rename from litellm/proxy/auth_v2/rbac.py rename to litellm/proxy/auth_v2/authorization/rbac.py index f679e1c20c31..5ff34e006521 100644 --- a/litellm/proxy/auth_v2/rbac.py +++ b/litellm/proxy/auth_v2/authorization/rbac.py @@ -1,44 +1,14 @@ from __future__ import annotations -from enum import Enum -from typing import TYPE_CHECKING, Any, List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple import casbin -from fastapi.security import SecurityScopes -if TYPE_CHECKING: - from .models import Principal - - -class Role(str, Enum): - PLATFORM_ADMIN = "platform_admin" - PLATFORM_VIEWER = "platform_viewer" - ORG_ADMIN = "org_admin" - ORG_VIEWER = "org_viewer" - TEAM_ADMIN = "team_admin" - TEAM_MEMBER = "team_member" - - -_PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} - - -def filter_claim_roles( - roles: Any, allowed_roles: List[str], allow_platform_roles: bool -) -> List[str]: - if not isinstance(roles, list): - return [] - allowed = set(allowed_roles) - filtered = [role for role in roles if role in allowed] - if not allow_platform_roles: - filtered = [role for role in filtered if role not in _PLATFORM_ROLE_VALUES] - return filtered - - -def has_required_scopes( - security_scopes: SecurityScopes, principal: "Principal" -) -> bool: - return set(security_scopes.scopes).issubset(set(principal.scopes)) +from litellm.proxy.auth_v2.authorization.base import Authorizer +from litellm.proxy.auth_v2.authorization.roles import Role +if TYPE_CHECKING: + from litellm.proxy.auth_v2.models import Principal _MODEL_TEXT = """ [request_definition] @@ -72,7 +42,7 @@ def has_required_scopes( ] -class RBACEngine: +class RBACEngine(Authorizer): def __init__(self, policy_path: Optional[str] = None) -> None: model = casbin.Model() model.load_model_from_text(_MODEL_TEXT) @@ -86,9 +56,7 @@ def __init__(self, policy_path: Optional[str] = None) -> None: self._enforcer.add_policy(*rule) def enforce(self, principal: "Principal", obj: str, act: str) -> bool: - return any( - self._enforcer.enforce(role.value, obj, act) for role in principal.roles - ) + return any(self._enforcer.enforce(role.value, obj, act) for role in principal.roles) def has_any_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: allowed_values = {role.value for role in allowed} diff --git a/litellm/proxy/auth_v2/authorization/roles.py b/litellm/proxy/auth_v2/authorization/roles.py new file mode 100644 index 000000000000..c05b61be0106 --- /dev/null +++ b/litellm/proxy/auth_v2/authorization/roles.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, List + + +class Role(str, Enum): + PLATFORM_ADMIN = "platform_admin" + PLATFORM_VIEWER = "platform_viewer" + ORG_ADMIN = "org_admin" + ORG_VIEWER = "org_viewer" + TEAM_ADMIN = "team_admin" + TEAM_MEMBER = "team_member" + + +_PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} + + +def filter_claim_roles(roles: Any, allowed_roles: List[str], allow_platform_roles: bool) -> List[str]: + if not isinstance(roles, list): + return [] + allowed = set(allowed_roles) + filtered = [role for role in roles if role in allowed] + if not allow_platform_roles: + filtered = [role for role in filtered if role not in _PLATFORM_ROLE_VALUES] + return filtered diff --git a/litellm/proxy/auth_v2/authorization/scopes.py b/litellm/proxy/auth_v2/authorization/scopes.py new file mode 100644 index 000000000000..f761b337d3a5 --- /dev/null +++ b/litellm/proxy/auth_v2/authorization/scopes.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi.security import SecurityScopes + +if TYPE_CHECKING: + from litellm.proxy.auth_v2.models import Principal + + +def has_required_scopes(security_scopes: SecurityScopes, principal: "Principal") -> bool: + return set(security_scopes.scopes).issubset(set(principal.scopes)) diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index 9c172b472631..d036f77730e5 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -1,11 +1,84 @@ -from typing import List, Optional +from typing import Dict, List, Optional -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, field_validator +from pydantic import ( + AnyHttpUrl, + BaseModel, + Field, + SecretStr, + field_validator, + model_validator, +) -from .models import SecuritySchemeType, require_secure_url -from .oidc.config import OIDCProviderConfig -from .saml.config import SAMLConfig -from .session import SessionConfig +from litellm.proxy.auth_v2.models import SecuritySchemeType, require_secure_url + + +class SessionConfig(BaseModel): + cookie: str = "litellm_session" + secure: bool = True + ttl_seconds: int = 3600 + max_size: int = 10000 + default_redirect_path: str = "/" + login_cookie: str = "litellm_oidc_txn" + login_state_ttl: int = 300 + + +DEFAULT_SAML_ATTRIBUTE_MAP = { + "email": "email", + "mail": "email", + "givenName": "given_name", + "surname": "family_name", + "sn": "family_name", + "displayName": "display_name", + "userName": "user_name", + "uid": "user_name", + "groups": "groups", + "roles": "roles", +} + + +class OIDCProviderConfig(BaseModel): + issuer: str + audience: List[str] + jwks_uri: Optional[AnyHttpUrl] = None + algorithms: List[str] = Field(default_factory=lambda: ["RS256"]) + require_at_jwt: bool = False + client_id: Optional[str] = None + client_secret: Optional[SecretStr] = None + login_scopes: List[str] = Field(default_factory=lambda: ["openid", "email", "profile"]) + allowed_roles: List[str] = Field(default_factory=list) + allow_platform_roles: bool = False + + @field_validator("issuer") + @classmethod + def _issuer_https(cls, value: str) -> str: + return require_secure_url(value) + + @field_validator("jwks_uri") + @classmethod + def _jwks_https(cls, value: Optional[AnyHttpUrl]) -> Optional[AnyHttpUrl]: + if value is not None: + require_secure_url(str(value)) + return value + + +class SAMLConfig(BaseModel): + enabled: bool = False + entity_id: str + acs_url: str + idp_metadata: str = "" + sp_key_file: Optional[str] = None + sp_cert_file: Optional[str] = None + allow_unsolicited: bool = False + xmlsec_binary: Optional[str] = None + attribute_map: Dict[str, str] = Field(default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP)) + allowed_roles: List[str] = Field(default_factory=list) + allow_platform_roles: bool = False + + @model_validator(mode="after") + def _require_idp_metadata(self) -> "SAMLConfig": + if self.enabled and not self.idp_metadata.strip(): + raise ValueError("SAML enabled but idp_metadata is empty (inline XML, local path, or URL)") + return self class ApiKeySchemeConfig(BaseModel): diff --git a/litellm/proxy/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py index a7c5950790fb..6e1b37617725 100644 --- a/litellm/proxy/auth_v2/errors.py +++ b/litellm/proxy/auth_v2/errors.py @@ -6,16 +6,12 @@ class AuthError(HTTPException): - def __init__( - self, status_code: int, detail: str, challenge: Optional[str] = None - ) -> None: + def __init__(self, status_code: int, detail: str, challenge: Optional[str] = None) -> None: headers = {"WWW-Authenticate": challenge} if challenge else None super().__init__(status_code=status_code, detail=detail, headers=headers) -def bearer_challenge( - error: Optional[str] = None, description: Optional[str] = None -) -> str: +def bearer_challenge(error: Optional[str] = None, description: Optional[str] = None) -> str: parts = ['Bearer realm="litellm"'] if error: parts.append(f'error="{error}"') @@ -33,9 +29,7 @@ def unauthenticated(challenge: str) -> AuthError: def invalid_token(description: Optional[str] = None) -> AuthError: - return AuthError( - 401, "Invalid token", bearer_challenge("invalid_token", description) - ) + return AuthError(401, "Invalid token", bearer_challenge("invalid_token", description)) def insufficient_scope() -> AuthError: @@ -52,3 +46,39 @@ def forbidden_permission() -> AuthError: def account_disabled() -> AuthError: return AuthError(403, "Account disabled") + + +def unknown_provider() -> HTTPException: + return HTTPException(status_code=404, detail="unknown provider") + + +def invalid_login_state() -> HTTPException: + return HTTPException(status_code=400, detail="invalid or expired login state") + + +def state_mismatch() -> HTTPException: + return HTTPException(status_code=400, detail="state mismatch") + + +def oidc_provider_error(error: str) -> HTTPException: + return HTTPException(status_code=400, detail=error) + + +def missing_authorization_code() -> HTTPException: + return HTTPException(status_code=400, detail="missing authorization code") + + +def saml_redirect_failed() -> HTTPException: + return HTTPException(status_code=500, detail="no SAML redirect produced") + + +def missing_saml_response() -> HTTPException: + return HTTPException(status_code=400, detail="missing SAMLResponse") + + +def invalid_saml_response() -> HTTPException: + return HTTPException(status_code=401, detail="invalid SAML response") + + +def saml_assertion_replay() -> HTTPException: + return HTTPException(status_code=401, detail="SAML assertion replay") diff --git a/litellm/proxy/auth_v2/models.py b/litellm/proxy/auth_v2/models.py index 0f58939cb823..7f5df99e8364 100644 --- a/litellm/proxy/auth_v2/models.py +++ b/litellm/proxy/auth_v2/models.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field -from .rbac import Role +from litellm.proxy.auth_v2.authorization import Role _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} diff --git a/litellm/proxy/auth_v2/network.py b/litellm/proxy/auth_v2/network.py index 4852cf3ddbef..e92ce7941743 100644 --- a/litellm/proxy/auth_v2/network.py +++ b/litellm/proxy/auth_v2/network.py @@ -5,8 +5,8 @@ from fastapi import Request -from .config import TrustedProxyConfig -from .models import NetworkContext +from litellm.proxy.auth_v2.config import TrustedProxyConfig +from litellm.proxy.auth_v2.models import NetworkContext def _is_valid_ip(value: str) -> bool: diff --git a/litellm/proxy/auth_v2/oidc/__init__.py b/litellm/proxy/auth_v2/oidc/__init__.py deleted file mode 100644 index 0e9c3e12a15b..000000000000 --- a/litellm/proxy/auth_v2/oidc/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .config import OIDCProviderConfig -from .router import build_oidc_router - -__all__ = ["OIDCProviderConfig", "build_oidc_router"] diff --git a/litellm/proxy/auth_v2/oidc/config.py b/litellm/proxy/auth_v2/oidc/config.py deleted file mode 100644 index 8e5138e2cec5..000000000000 --- a/litellm/proxy/auth_v2/oidc/config.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import List, Optional - -from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, field_validator - -from ..models import require_secure_url - - -class OIDCProviderConfig(BaseModel): - issuer: str - audience: List[str] - jwks_uri: Optional[AnyHttpUrl] = None - algorithms: List[str] = Field(default_factory=lambda: ["RS256"]) - require_at_jwt: bool = False - client_id: Optional[str] = None - client_secret: Optional[SecretStr] = None - login_scopes: List[str] = Field( - default_factory=lambda: ["openid", "email", "profile"] - ) - allowed_roles: List[str] = Field(default_factory=list) - allow_platform_roles: bool = False - - @field_validator("issuer") - @classmethod - def _issuer_https(cls, value: str) -> str: - return require_secure_url(value) - - @field_validator("jwks_uri") - @classmethod - def _jwks_https(cls, value: Optional[AnyHttpUrl]) -> Optional[AnyHttpUrl]: - if value is not None: - require_secure_url(str(value)) - return value diff --git a/litellm/proxy/auth_v2/oidc/router.py b/litellm/proxy/auth_v2/oidc/router.py deleted file mode 100644 index df8c8d6d9818..000000000000 --- a/litellm/proxy/auth_v2/oidc/router.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -import re -from typing import TYPE_CHECKING, Any, Dict, cast - -from authlib.integrations.starlette_client import OAuth -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import RedirectResponse -from scim2_models import User as ScimUser - -from .config import OIDCProviderConfig -from ..resolver import ProvisioningStore -from ..session import safe_relay_state - -if TYPE_CHECKING: - from ..security import AuthSecurity - -_CLAIM_KEYS = ("email", "preferred_username", "name", "groups", "roles") - - -def _provider_key(provider: OIDCProviderConfig) -> str: - return re.sub(r"[^a-z0-9]+", "-", provider.issuer.lower()).strip("-") - - -def _user_from_userinfo(userinfo: Dict[str, Any]) -> ScimUser: - return ScimUser( - external_id=userinfo.get("sub"), - user_name=userinfo.get("preferred_username") or userinfo.get("email"), - display_name=userinfo.get("name"), - ) - - -def _mapped_claims(userinfo: Dict[str, Any]) -> Dict[str, Any]: - return {key: userinfo[key] for key in _CLAIM_KEYS if userinfo.get(key) is not None} - - -def build_oidc_router(auth: AuthSecurity) -> APIRouter: - session = auth.config.session - providers = {_provider_key(p): p for p in auth.config.oidc_providers} - oauth = OAuth() - for provider in auth.config.oidc_providers: - oauth.register( - name=_provider_key(provider), - server_metadata_url=f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration", - client_id=provider.client_id, - client_secret=( - provider.client_secret.get_secret_value() - if provider.client_secret - else None - ), - client_kwargs={ - "scope": " ".join(provider.login_scopes), - "code_challenge_method": "S256", - }, - ) - - router = APIRouter(prefix="/auth/oidc", tags=["oidc"]) - - @router.get("/{provider}/login") - async def login(provider: str, request: Request) -> RedirectResponse: - client = oauth.create_client(provider) - if client is None: - raise HTTPException(status_code=404, detail="unknown provider") - redirect_uri = str(request.url_for("oidc_callback", provider=provider)) - relay = safe_relay_state( - request.query_params.get("next"), session.default_redirect_path - ) - authorization = await client.create_authorization_url(redirect_uri) - txn_id = auth.oauth_txn_store.create_session( - { - "provider": provider, - "state": authorization["state"], - "nonce": authorization.get("nonce"), - "code_verifier": authorization.get("code_verifier"), - "redirect_uri": redirect_uri, - "relay": relay, - } - ) - response = RedirectResponse(authorization["url"], status_code=303) - response.set_cookie( - session.login_cookie, - txn_id, - httponly=True, - samesite="lax", - secure=session.secure, - max_age=session.login_state_ttl, - ) - return response - - @router.get("/{provider}/callback", name="oidc_callback") - async def callback(provider: str, request: Request) -> RedirectResponse: - client = oauth.create_client(provider) - if client is None: - raise HTTPException(status_code=404, detail="unknown provider") - txn_id = request.cookies.get(session.login_cookie) - txn = auth.oauth_txn_store.pop(txn_id) if txn_id else None - if txn is None or txn.get("provider") != provider: - raise HTTPException( - status_code=400, detail="invalid or expired login state" - ) - returned_state = request.query_params.get("state") - if not returned_state or returned_state != txn["state"]: - raise HTTPException(status_code=400, detail="state mismatch") - error = request.query_params.get("error") - if error: - raise HTTPException(status_code=400, detail=error) - code = request.query_params.get("code") - if not code: - raise HTTPException(status_code=400, detail="missing authorization code") - - token = await client.fetch_access_token( - redirect_uri=txn["redirect_uri"], - code=code, - code_verifier=txn.get("code_verifier"), - state=txn["state"], - ) - if token.get("id_token"): - userinfo = await client.parse_id_token(token, nonce=txn.get("nonce")) - else: - userinfo = await client.userinfo(token=token) - from ..authenticators import _apply_role_policy - - info = dict(userinfo) - provider_config = providers[provider] - - store = cast(ProvisioningStore, auth.resolver) - await store.upsert_user(_user_from_userinfo(info)) - - claims = _mapped_claims(info) - _apply_role_policy(claims, provider_config) - session_id = auth.session_store.create_session( - { - "method": "oidc", - "subject": info.get("sub"), - "issuer": info.get("iss") or provider_config.issuer, - "claims": claims, - } - ) - target = safe_relay_state(txn.get("relay"), session.default_redirect_path) - response = RedirectResponse(target, status_code=303) - response.set_cookie( - session.cookie, - session_id, - httponly=True, - samesite="lax", - secure=session.secure, - ) - return response - - return router diff --git a/litellm/proxy/auth_v2/resolvers/__init__.py b/litellm/proxy/auth_v2/resolvers/__init__.py new file mode 100644 index 000000000000..09959cca19d9 --- /dev/null +++ b/litellm/proxy/auth_v2/resolvers/__init__.py @@ -0,0 +1,17 @@ +from litellm.proxy.auth_v2.resolvers.base import ( + IdentityResolver, + IdentityStore, + ProvisioningStore, +) +from litellm.proxy.auth_v2.resolvers.memory import InMemoryIdentityStore + +# DbIdentityStore is intentionally not re-exported here: it pulls in the v1 +# proxy DB machinery (auth_checks, repositories). Import it directly from +# litellm.proxy.auth_v2.resolvers.db when wiring a database-backed store. + +__all__ = [ + "IdentityResolver", + "ProvisioningStore", + "IdentityStore", + "InMemoryIdentityStore", +] diff --git a/litellm/proxy/auth_v2/resolvers/base.py b/litellm/proxy/auth_v2/resolvers/base.py new file mode 100644 index 000000000000..6227d38b1694 --- /dev/null +++ b/litellm/proxy/auth_v2/resolvers/base.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import List, Optional, Protocol, runtime_checkable + +from scim2_models import Group as ScimGroup +from scim2_models import User as ScimUser + +from litellm.proxy.auth_v2.models import Credential, Principal + + +@runtime_checkable +class IdentityResolver(Protocol): + async def resolve(self, credential: Credential) -> Principal: ... + + +@runtime_checkable +class ProvisioningStore(Protocol): + async def upsert_user(self, user: ScimUser) -> ScimUser: ... + async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... + async def deactivate_user(self, resource_id: str) -> None: ... + async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: ... + async def upsert_group(self, group: ScimGroup) -> ScimGroup: ... + async def get_group(self, resource_id: str) -> Optional[ScimGroup]: ... + async def delete_group(self, resource_id: str) -> None: ... + async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... + + +@runtime_checkable +class IdentityStore(IdentityResolver, ProvisioningStore, Protocol): + """An identity backend: resolves credentials and provisions SCIM users/groups. + + This is the single interface every implementation satisfies (in-memory, + database, ...). Resolution and provisioning live behind one store so a + provisioned user is immediately resolvable. + """ diff --git a/litellm/proxy/auth_v2/resolvers/db.py b/litellm/proxy/auth_v2/resolvers/db.py new file mode 100644 index 000000000000..7f7957d60234 --- /dev/null +++ b/litellm/proxy/auth_v2/resolvers/db.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, List, Optional + +from scim2_models import Group as ScimGroup +from scim2_models import User as ScimUser + +from litellm.proxy._types import UserAPIKeyAuth, hash_token +from litellm.proxy.auth.auth_checks import ( + get_key_object, + get_org_object, + get_team_object, + get_user_object, +) +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Credential, + OrganizationIdentity, + Principal, + PrincipalType, + TeamIdentity, + TeamRole, + UserIdentity, +) +from litellm.proxy.auth_v2.resolvers.base import IdentityStore +from litellm.proxy.auth_v2.resolvers.utils import ( + db_team_to_scim, + db_user_to_scim, + map_role, + member_role, + scim_group_to_db, + scim_user_to_db, + team_role, +) +from litellm.repositories.team_repository import TeamRepository +from litellm.repositories.user_repository import UserRepository + +if TYPE_CHECKING: + from litellm.caching.caching import DualCache + from litellm.models.user import LiteLLM_UserTable + from litellm.proxy.utils import PrismaClient + + +class DbIdentityStore(IdentityStore): + """Resolves credentials against the proxy's Prisma tables and provisions + SCIM users/groups into ``LiteLLM_UserTable`` / ``LiteLLM_TeamTable``. + + The Prisma client and key cache are injected so this stays a plain object + the composition root can build once the proxy DB is connected. + """ + + def __init__(self, prisma_client: "PrismaClient", cache: "DualCache") -> None: + self._prisma = prisma_client + self._cache = cache + + # ------------------------------------------------------------------ # + # IdentityResolver + # ------------------------------------------------------------------ # + async def resolve(self, credential: Credential) -> Principal: + if credential.method == AuthMethod.API_KEY: + return await self._resolve_api_key(credential) + if credential.method == AuthMethod.MUTUAL_TLS: + return self._service_account(credential) + return await self._resolve_subject(credential) + + async def _resolve_api_key(self, credential: Credential) -> Principal: + raw = credential.claims.get("_raw_api_key") + if not isinstance(raw, str): + raise errors.invalid_token() + try: + key = await get_key_object(hash_token(raw), self._prisma, self._cache) + except Exception as exc: + raise errors.invalid_token() from exc + if key.blocked: + raise errors.account_disabled() + return self._principal_from_key(credential, key) + + async def _resolve_subject(self, credential: Credential) -> Principal: + email = credential.claims.get("email") + try: + user = await get_user_object( + user_id=credential.subject, + prisma_client=self._prisma, + user_api_key_cache=self._cache, + user_id_upsert=False, + sso_user_id=credential.subject, + user_email=email if isinstance(email, str) else None, + ) + except Exception as exc: + raise errors.invalid_token() from exc + if user is None: + raise errors.invalid_token() + return await self._principal_from_user(credential, user) + + def _service_account(self, credential: Credential) -> Principal: + return Principal( + principal_type=PrincipalType.SERVICE_ACCOUNT, + subject=credential.subject, + issuer=credential.issuer, + audience=list(credential.audience), + scopes=list(credential.scopes), + auth_method=credential.method, + credential_ref=credential.credential_ref, + ) + + def _principal_from_key(self, credential: Credential, key: UserAPIKeyAuth) -> Principal: + teams: List[TeamIdentity] = [] + if key.team_id is not None: + role = team_role(key.team_member.role) if key.team_member else TeamRole.MEMBER + teams.append(TeamIdentity(id=key.team_id, name=key.team_alias, role=role)) + organization = ( + OrganizationIdentity(id=key.org_id, name=key.organization_alias) if key.org_id is not None else None + ) + user = UserIdentity(id=key.user_id, email=key.user_email) if key.user_id is not None else None + mapped = map_role(key.user_role) + return Principal( + principal_type=(PrincipalType.HUMAN if key.user_id else PrincipalType.SERVICE_ACCOUNT), + subject=key.user_id or key.key_alias or credential.subject, + issuer=credential.issuer, + user=user, + organization=organization, + teams=teams, + roles=[mapped] if mapped else [], + scopes=list(credential.scopes), + auth_method=credential.method, + credential_ref=credential.credential_ref, + ) + + async def _principal_from_user(self, credential: Credential, user: "LiteLLM_UserTable") -> Principal: + teams: List[TeamIdentity] = [] + for team_id in user.teams or []: + try: + team = await get_team_object(team_id, self._prisma, self._cache) + except Exception: + continue + teams.append( + TeamIdentity( + id=team_id, + name=team.team_alias, + role=member_role(team.members_with_roles, user.user_id), + ) + ) + + organization = await self._organization(user) + roles = [role for role in (map_role(user.user_role),) if role is not None] + return Principal( + principal_type=PrincipalType.HUMAN, + subject=credential.subject, + issuer=credential.issuer, + audience=list(credential.audience), + user=UserIdentity( + id=user.user_id, + external_id=user.sso_user_id, + email=user.user_email, + display_name=user.user_alias, + ), + organization=organization, + teams=teams, + roles=roles, + scopes=list(credential.scopes), + auth_method=credential.method, + credential_ref=credential.credential_ref, + ) + + async def _organization(self, user: "LiteLLM_UserTable") -> Optional[OrganizationIdentity]: + if user.organization_id is None: + return None + try: + org = await get_org_object(user.organization_id, self._prisma, self._cache) + except Exception: + org = None + name = org.organization_alias if org is not None else None + return OrganizationIdentity(id=user.organization_id, name=name) + + # ------------------------------------------------------------------ # + # ProvisioningStore + # ------------------------------------------------------------------ # + async def upsert_user(self, user: ScimUser) -> ScimUser: + repo = UserRepository(self._prisma) + data = scim_user_to_db(user) + existing = await repo.table.find_unique(where={"user_id": user.id}) if user.id else None + if existing is None: + data["user_id"] = user.id or str(uuid.uuid4()) + stored = await repo.table.create(data=data) + else: + stored = await repo.table.update(where={"user_id": user.id}, data=data) + return db_user_to_scim(stored) + + async def get_user(self, resource_id: str) -> Optional[ScimUser]: + stored = await UserRepository(self._prisma).table.find_unique(where={"user_id": resource_id}) + return db_user_to_scim(stored) if stored is not None else None + + async def deactivate_user(self, resource_id: str) -> None: + repo = UserRepository(self._prisma) + stored = await repo.table.find_unique(where={"user_id": resource_id}) + if stored is None: + return + metadata = dict(getattr(stored, "metadata", None) or {}) + metadata["scim_active"] = False + await repo.table.update(where={"user_id": resource_id}, data={"metadata": metadata}) + + async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: + rows = await UserRepository(self._prisma).table.find_many() + return [db_user_to_scim(row) for row in rows] + + async def upsert_group(self, group: ScimGroup) -> ScimGroup: + repo = TeamRepository(self._prisma) + data = scim_group_to_db(group) + existing = await repo.table.find_unique(where={"team_id": group.id}) if group.id else None + if existing is None: + data["team_id"] = group.id or str(uuid.uuid4()) + stored = await repo.table.create(data=data) + else: + stored = await repo.table.update(where={"team_id": group.id}, data=data) + return db_team_to_scim(stored) + + async def get_group(self, resource_id: str) -> Optional[ScimGroup]: + stored = await TeamRepository(self._prisma).table.find_unique(where={"team_id": resource_id}) + return db_team_to_scim(stored) if stored is not None else None + + async def delete_group(self, resource_id: str) -> None: + await TeamRepository(self._prisma).table.delete(where={"team_id": resource_id}) + + async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: + rows = await TeamRepository(self._prisma).table.find_many() + return [db_team_to_scim(row) for row in rows] diff --git a/litellm/proxy/auth_v2/resolver.py b/litellm/proxy/auth_v2/resolvers/memory.py similarity index 76% rename from litellm/proxy/auth_v2/resolver.py rename to litellm/proxy/auth_v2/resolvers/memory.py index 66ddf5a70c48..ccc37094044a 100644 --- a/litellm/proxy/auth_v2/resolver.py +++ b/litellm/proxy/auth_v2/resolvers/memory.py @@ -1,14 +1,13 @@ from __future__ import annotations -import hashlib import uuid -from typing import Any, Dict, List, Optional, Protocol, runtime_checkable +from typing import Any, Dict, List, Optional from scim2_models import Group as ScimGroup from scim2_models import User as ScimUser -from . import errors -from .models import ( +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.models import ( AuthMethod, Credential, Principal, @@ -16,43 +15,15 @@ TeamIdentity, UserIdentity, ) -from .rbac import Role - - -@runtime_checkable -class IdentityResolver(Protocol): - async def resolve(self, credential: Credential) -> Principal: ... - - -@runtime_checkable -class ProvisioningStore(Protocol): - async def upsert_user(self, user: ScimUser) -> ScimUser: ... - async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... - async def deactivate_user(self, resource_id: str) -> None: ... - async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: ... - async def upsert_group(self, group: ScimGroup) -> ScimGroup: ... - async def get_group(self, resource_id: str) -> Optional[ScimGroup]: ... - async def delete_group(self, resource_id: str) -> None: ... - async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... - - -def _hash_api_key(raw: str) -> str: - return hashlib.sha256(raw.encode("utf-8")).hexdigest() - - -def _roles_from_claims(claims: Dict[str, Any]) -> List[Role]: - raw = claims.get("roles", []) - if not isinstance(raw, list): - return [] - valid = {role.value for role in Role} - return [Role(value) for value in raw if value in valid] - - -def _public_claims(claims: Dict[str, Any]) -> Dict[str, Any]: - return {key: value for key, value in claims.items() if not key.startswith("_")} +from litellm.proxy.auth_v2.resolvers.base import IdentityStore +from litellm.proxy.auth_v2.resolvers.utils import ( + hash_api_key, + public_claims, + roles_from_claims, +) -class InMemoryIdentityStore(IdentityResolver, ProvisioningStore): +class InMemoryIdentityStore(IdentityStore): def __init__( self, api_keys: Optional[Dict[str, Principal]] = None, @@ -117,7 +88,7 @@ def _resolve_api_key(self, credential: Credential) -> Principal: raw = credential.claims.get("_raw_api_key") if not isinstance(raw, str): raise errors.invalid_token() - principal = self._api_keys.get(_hash_api_key(raw)) + principal = self._api_keys.get(hash_api_key(raw)) if principal is None: raise errors.invalid_token() return principal @@ -139,7 +110,7 @@ def _principal_from_claims(self, credential: Credential) -> Principal: scopes=list(credential.scopes), auth_method=credential.method, credential_ref=credential.credential_ref, - claims=_public_claims(claims), + claims=public_claims(claims), ) return Principal( principal_type=PrincipalType.HUMAN, @@ -154,11 +125,11 @@ def _principal_from_claims(self, credential: Credential) -> Principal: display_name=claims.get("name"), ), teams=self._resolve_teams(claims), - roles=_roles_from_claims(claims), + roles=roles_from_claims(claims), scopes=list(credential.scopes), auth_method=credential.method, credential_ref=credential.credential_ref, - claims=_public_claims(claims), + claims=public_claims(claims), ) async def upsert_user(self, user: ScimUser) -> ScimUser: diff --git a/litellm/proxy/auth_v2/resolvers/utils.py b/litellm/proxy/auth_v2/resolvers/utils.py new file mode 100644 index 000000000000..9a85ced7a105 --- /dev/null +++ b/litellm/proxy/auth_v2/resolvers/utils.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from scim2_models import Email, Name +from scim2_models import Group as ScimGroup +from scim2_models import User as ScimUser + +from litellm.proxy.auth_v2.authorization import Role +from litellm.proxy.auth_v2.models import TeamRole + +if TYPE_CHECKING: + from litellm.models.team import LiteLLM_TeamTable, Member + from litellm.models.user import LiteLLM_UserTable + + +def hash_api_key(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def roles_from_claims(claims: Dict[str, Any]) -> List[Role]: + raw = claims.get("roles", []) + if not isinstance(raw, list): + return [] + valid = {role.value for role in Role} + return [Role(value) for value in raw if value in valid] + + +def public_claims(claims: Dict[str, Any]) -> Dict[str, Any]: + return {key: value for key, value in claims.items() if not key.startswith("_")} + + +_ROLE_MAP: Dict[str, Role] = { + "proxy_admin": Role.PLATFORM_ADMIN, + "proxy_admin_viewer": Role.PLATFORM_VIEWER, + "org_admin": Role.ORG_ADMIN, +} + + +def map_role(value: Optional[object]) -> Optional[Role]: + """Map a LiteLLM ``user_role`` (string or LitellmUserRoles) to a platform Role.""" + if isinstance(value, str): + return _ROLE_MAP.get(value) + return None + + +def team_role(role: Optional[str]) -> TeamRole: + return TeamRole.ADMIN if role == "admin" else TeamRole.MEMBER + + +def member_role(members: "List[Member]", user_id: Optional[str]) -> TeamRole: + if user_id is not None: + for member in members: + if member.user_id == user_id: + return team_role(member.role) + return TeamRole.MEMBER + + +def scim_user_to_db(user: ScimUser) -> Dict[str, object]: + email = user.emails[0].value if user.emails else None + metadata: Dict[str, object] = {"scim_active": user.active} + if user.name is not None: + metadata["scim_metadata"] = { + "givenName": user.name.given_name, + "familyName": user.name.family_name, + } + data: Dict[str, object] = {"metadata": metadata} + if email is not None: + data["user_email"] = email + if user.external_id is not None: + data["sso_user_id"] = user.external_id + if user.display_name is not None: + data["user_alias"] = user.display_name + return data + + +def db_user_to_scim(user: "LiteLLM_UserTable") -> ScimUser: + metadata = getattr(user, "metadata", None) or {} + scim_name = metadata.get("scim_metadata") or {} + result = ScimUser( + external_id=user.sso_user_id or user.user_id, + user_name=user.user_email or user.user_id, + display_name=user.user_alias, + active=metadata.get("scim_active", True), + ) + result.id = user.user_id + if user.user_email: + result.emails = [Email(value=user.user_email, primary=True)] + if scim_name.get("givenName") or scim_name.get("familyName"): + result.name = Name( + given_name=scim_name.get("givenName"), + family_name=scim_name.get("familyName"), + ) + return result + + +def scim_group_to_db(group: ScimGroup) -> Dict[str, object]: + members = [{"user_id": member.value, "role": "user"} for member in (group.members or [])] + return {"team_alias": group.display_name, "members_with_roles": members} + + +def db_team_to_scim(team: "LiteLLM_TeamTable") -> ScimGroup: + result = ScimGroup(display_name=team.team_alias or team.team_id) + result.id = team.team_id + return result diff --git a/litellm/proxy/auth_v2/saml/__init__.py b/litellm/proxy/auth_v2/saml/__init__.py deleted file mode 100644 index c6eb35a995e1..000000000000 --- a/litellm/proxy/auth_v2/saml/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .config import SAMLConfig -from .router import build_saml_router - -__all__ = ["SAMLConfig", "build_saml_router"] diff --git a/litellm/proxy/auth_v2/saml/config.py b/litellm/proxy/auth_v2/saml/config.py deleted file mode 100644 index c2910400245f..000000000000 --- a/litellm/proxy/auth_v2/saml/config.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Dict, List, Optional - -from pydantic import BaseModel, Field, model_validator - -DEFAULT_SAML_ATTRIBUTE_MAP = { - "email": "email", - "mail": "email", - "givenName": "given_name", - "surname": "family_name", - "sn": "family_name", - "displayName": "display_name", - "userName": "user_name", - "uid": "user_name", - "groups": "groups", - "roles": "roles", -} - - -class SAMLConfig(BaseModel): - enabled: bool = False - entity_id: str - acs_url: str - idp_metadata: str = "" - sp_key_file: Optional[str] = None - sp_cert_file: Optional[str] = None - allow_unsolicited: bool = False - xmlsec_binary: Optional[str] = None - attribute_map: Dict[str, str] = Field( - default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) - ) - allowed_roles: List[str] = Field(default_factory=list) - allow_platform_roles: bool = False - - @model_validator(mode="after") - def _require_idp_metadata(self) -> "SAMLConfig": - if self.enabled and not self.idp_metadata.strip(): - raise ValueError( - "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" - ) - return self diff --git a/litellm/proxy/auth_v2/scim/__init__.py b/litellm/proxy/auth_v2/scim/__init__.py deleted file mode 100644 index bb6917c367bb..000000000000 --- a/litellm/proxy/auth_v2/scim/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .router import build_scim_router - -__all__ = ["build_scim_router"] diff --git a/litellm/proxy/auth_v2/scim/router.py b/litellm/proxy/auth_v2/scim/router.py deleted file mode 100644 index a106446f3677..000000000000 --- a/litellm/proxy/auth_v2/scim/router.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - Optional, - Type, - TypeVar, - cast, -) - -from fastapi import APIRouter, HTTPException, Query, Request, Response, Security, status -from fastapi.responses import JSONResponse -from fastapi.routing import APIRoute -from pydantic import ValidationError -from scim2_models import ( - Bulk, - ChangePassword, - Context, - Error, - Filter, - Group, - ListResponse, - Patch, - PatchOp, - Resource, - ResourceType, - Schema, - ServiceProviderConfig, - Sort, - User, -) - -from ..resolver import ProvisioningStore - -if TYPE_CHECKING: - from ..security import AuthSecurity - -R = TypeVar("R", bound=Resource) - - -def _error(status_code: int, detail: str) -> JSONResponse: - return JSONResponse( - status_code=status_code, - content=Error(status=str(status_code), detail=detail).model_dump(), - ) - - -class _ScimRoute(APIRoute): - """Render authentication failures with the SCIM Error schema (RFC 7644).""" - - def get_route_handler( # type: ignore[override] - self, - ) -> Callable[[Request], Coroutine[Any, Any, Response]]: - handler = super().get_route_handler() - - async def scim_handler(request: Request) -> Response: - try: - return await handler(request) - except HTTPException as exc: - if exc.status_code not in ( - status.HTTP_401_UNAUTHORIZED, - status.HTTP_403_FORBIDDEN, - ): - raise - response = _error(exc.status_code, str(exc.detail)) - if exc.headers: - response.headers.update(exc.headers) - return response - - return scim_handler - - -async def _parse(request: Request, model: Type[R]) -> R: - body = await request.json() - return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST) - - -def _set_path(data: Dict[str, Any], path: str, value: Any) -> None: - keys = path.split(".") - node = data - for key in keys[:-1]: - child = node.get(key) - if not isinstance(child, dict): - child = {} - node[key] = child - node = child - node[keys[-1]] = value - - -def _remove_path(data: Dict[str, Any], path: str) -> None: - keys = path.split(".") - node = data - for key in keys[:-1]: - child = node.get(key) - if not isinstance(child, dict): - return - node = child - node.pop(keys[-1], None) - - -def _targets_read_only_id(op: Any) -> bool: - if op.path is not None: - return op.path.split(".")[0].strip().lower() == "id" - return isinstance(op.value, dict) and any(str(k).lower() == "id" for k in op.value) - - -def _apply_patch(resource: R, patch: PatchOp) -> R: - data: Dict[str, Any] = resource.model_dump() - for op in patch.operations: - action = op.op.value if hasattr(op.op, "value") else str(op.op) - if op.path is not None and ("[" in op.path or "]" in op.path): - raise ValueError(f"unsupported SCIM patch path filter: {op.path}") - if _targets_read_only_id(op): - raise ValueError("the SCIM id attribute is read-only") - if action == "remove": - if op.path: - _remove_path(data, op.path) - continue - if op.path is None and isinstance(op.value, dict): - data.update(op.value) - elif op.path is not None: - _set_path(data, op.path, op.value) - return type(resource).model_validate(data) - - -def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]: - return resource.model_dump(scim_ctx=ctx) - - -def _build_protected_router(auth: AuthSecurity) -> APIRouter: - store = cast(ProvisioningStore, auth.resolver) - protected = APIRouter( - route_class=_ScimRoute, - dependencies=[Security(auth.principal, scopes=["scim:write"])], - ) - - @protected.post("/Users", status_code=status.HTTP_201_CREATED) - async def create_user(request: Request) -> Response: - try: - user = await _parse(request, User) - except ValidationError as exc: - return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - stored = await store.upsert_user(user) - return JSONResponse( - status_code=status.HTTP_201_CREATED, - content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), - ) - - @protected.get("/Users/{resource_id}") - async def get_user(resource_id: str) -> Response: - user = await store.get_user(resource_id) - if user is None: - return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") - return JSONResponse(content=_dump(user, Context.RESOURCE_QUERY_RESPONSE)) - - @protected.patch("/Users/{resource_id}") - async def patch_user(resource_id: str, request: Request) -> Response: - user = await store.get_user(resource_id) - if user is None: - return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") - try: - patch = PatchOp[User].model_validate(await request.json()) - patched = _apply_patch(user, patch) - except (ValidationError, ValueError) as exc: - return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - updated = await store.upsert_user(patched) - return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) - - @protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) - async def deactivate_user(resource_id: str) -> Response: - if await store.get_user(resource_id) is None: - return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found") - await store.deactivate_user(resource_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - @protected.get("/Users") - async def list_users( - filter_expr: Optional[str] = Query(default=None, alias="filter"), - ) -> Response: - users = await store.list_users(filter_expr) - listing: ListResponse[User] = ListResponse[User]( - total_results=len(users), - start_index=1, - items_per_page=len(users), - resources=users or None, - ) - return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) - - @protected.post("/Groups", status_code=status.HTTP_201_CREATED) - async def create_group(request: Request) -> Response: - try: - group = await _parse(request, Group) - except ValidationError as exc: - return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - stored = await store.upsert_group(group) - return JSONResponse( - status_code=status.HTTP_201_CREATED, - content=_dump(stored, Context.RESOURCE_CREATION_RESPONSE), - ) - - @protected.get("/Groups/{resource_id}") - async def get_group(resource_id: str) -> Response: - group = await store.get_group(resource_id) - if group is None: - return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") - return JSONResponse(content=_dump(group, Context.RESOURCE_QUERY_RESPONSE)) - - @protected.patch("/Groups/{resource_id}") - async def patch_group(resource_id: str, request: Request) -> Response: - group = await store.get_group(resource_id) - if group is None: - return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") - try: - patch = PatchOp[Group].model_validate(await request.json()) - patched = _apply_patch(group, patch) - except (ValidationError, ValueError) as exc: - return _error(status.HTTP_400_BAD_REQUEST, str(exc)) - updated = await store.upsert_group(patched) - return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE)) - - @protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT) - async def delete_group(resource_id: str) -> Response: - if await store.get_group(resource_id) is None: - return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found") - await store.delete_group(resource_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - @protected.get("/Groups") - async def list_groups( - filter_expr: Optional[str] = Query(default=None, alias="filter"), - ) -> Response: - groups = await store.list_groups(filter_expr) - listing: ListResponse[Group] = ListResponse[Group]( - total_results=len(groups), - start_index=1, - items_per_page=len(groups), - resources=groups or None, - ) - return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE)) - - return protected - - -def _build_discovery_router() -> APIRouter: - router = APIRouter() - - @router.get("/ServiceProviderConfig") - async def service_provider_config() -> Response: - config = ServiceProviderConfig( - patch=Patch(supported=True), - bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), - filter=Filter(supported=False, max_results=0), - change_password=ChangePassword(supported=False), - sort=Sort(supported=False), - etag=None, - authentication_schemes=[], - ) - return JSONResponse(content=config.model_dump()) - - @router.get("/ResourceTypes") - async def resource_types() -> Response: - types = [ - ResourceType( - id="User", - name="User", - endpoint="/Users", - schema="urn:ietf:params:scim:schemas:core:2.0:User", - ), - ResourceType( - id="Group", - name="Group", - endpoint="/Groups", - schema="urn:ietf:params:scim:schemas:core:2.0:Group", - ), - ] - listing: ListResponse[ResourceType] = ListResponse[ResourceType]( - total_results=len(types), - start_index=1, - items_per_page=len(types), - resources=types, - ) - return JSONResponse(content=listing.model_dump()) - - @router.get("/Schemas") - async def schemas() -> Response: - resources = [User.to_schema(), Group.to_schema()] - listing: ListResponse[Schema] = ListResponse[Schema]( - total_results=len(resources), - start_index=1, - items_per_page=len(resources), - resources=resources, - ) - return JSONResponse(content=listing.model_dump()) - - return router - - -def build_scim_router(auth: AuthSecurity) -> APIRouter: - router = APIRouter(prefix="/scim/v2", tags=["scim"]) - router.include_router(_build_protected_router(auth)) - router.include_router(_build_discovery_router()) - return router diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index d311019bec2e..be667a9f3985 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -3,18 +3,25 @@ from fastapi import Request, Security from fastapi.security import SecurityScopes -from . import errors -from .authenticators import ( +from litellm.proxy.auth_v2 import errors +from litellm.proxy.auth_v2.authenticators import ( Authenticator, BasicAuthVerifier, build_authenticators, ) -from .config import AuthConfig -from .models import Principal -from .network import resolve_network_context -from .rbac import RBACEngine, Role, has_required_scopes -from .resolver import IdentityResolver -from .session import SessionAuthenticator, SessionStore +from litellm.proxy.auth_v2.config import AuthConfig +from litellm.proxy.auth_v2.models import Principal +from litellm.proxy.auth_v2.network import resolve_network_context +from litellm.proxy.auth_v2.authorization import ( + Authorizer, + RBACEngine, + Role, + has_required_scopes, +) +from litellm.proxy.auth_v2.authenticators.session import SessionAuthenticator +from litellm.proxy.auth_v2.resolvers import IdentityResolver +from litellm.proxy.auth_v2.sessions import StateBackend, StateStore +from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState def _combined_challenge(authenticators: List[Authenticator]) -> str: @@ -30,9 +37,9 @@ class AuthSecurity: """Enforcement layer consumed purely through FastAPI ``Security()``. Construct once at the composition root and pass the bound methods - (``principal``, ``require_roles``, ``require_permission``) to ``Security()``; - routers receive the instance explicitly via ``build_*_router(auth)``. There is - no app mutation and no ``app.state``. + (``principal``, ``require_roles``, ``require_permission``) to ``Security()``. + Routers reach this instance at request time via ``request.app.state.auth_v2`` + (see ``routers/dependencies.py``), so assign it there when wiring the app. Deployment note for trusted-proxy IP resolution: uvicorn's ``--proxy-headers`` (on by default) overwrites ``request.client`` from ``X-Forwarded-For`` before @@ -46,18 +53,20 @@ def __init__( self, config: AuthConfig, resolver: IdentityResolver, - rbac: Optional[RBACEngine] = None, + authorizer: Optional[Authorizer] = None, authenticators: Optional[List[Authenticator]] = None, basic_verifier: Optional[BasicAuthVerifier] = None, + state_backend: Optional[StateBackend] = None, ) -> None: self.config = config self.resolver = resolver - self.rbac = rbac or RBACEngine(config.casbin_policy_path) - self.session_store = SessionStore( - config.session.ttl_seconds, config.session.max_size + self.authorizer = authorizer or RBACEngine(config.casbin_policy_path) + self._state = state_backend or StateBackend(None) + self.session_store: StateStore[SessionState] = self._state.store( + "sessions", default_ttl=config.session.ttl_seconds ) - self.oauth_txn_store = SessionStore( - config.session.login_state_ttl, config.session.max_size + self.oauth_txn_store: StateStore[OAuthTransaction] = self._state.store( + "oauth_txn", default_ttl=config.session.login_state_ttl ) chain = ( list(authenticators) @@ -67,9 +76,7 @@ def __init__( chain.append(SessionAuthenticator(config.session.cookie, self.session_store)) self.authenticators = chain - async def principal( - self, security_scopes: SecurityScopes, request: Request - ) -> Principal: + async def principal(self, security_scopes: SecurityScopes, request: Request) -> Principal: """Resolve the caller to a Principal, enforcing scheme OR and required scopes.""" credential = None for authenticator in self.authenticators: @@ -80,9 +87,7 @@ async def principal( raise errors.unauthenticated(_combined_challenge(self.authenticators)) resolved = await self.resolver.resolve(credential) - principal = resolved.model_copy( - update={"network": resolve_network_context(request, self.config.network)} - ) + principal = resolved.model_copy(update={"network": resolve_network_context(request, self.config.network)}) if not has_required_scopes(security_scopes, principal): raise errors.insufficient_scope() return principal @@ -93,7 +98,7 @@ def require_roles(self, *allowed: Role) -> Callable[..., object]: async def dependency( principal: Annotated[Principal, Security(self.principal)], ) -> Principal: - if not self.rbac.has_any_role(principal, allowed): + if not self.authorizer.has_any_role(principal, allowed): raise errors.forbidden_role() return principal @@ -105,7 +110,7 @@ def require_permission(self, obj: str, act: str) -> Callable[..., object]: async def dependency( principal: Annotated[Principal, Security(self.principal)], ) -> Principal: - if not self.rbac.enforce(principal, obj, act): + if not self.authorizer.enforce(principal, obj, act): raise errors.forbidden_permission() return principal diff --git a/litellm/proxy/auth_v2/session.py b/litellm/proxy/auth_v2/session.py deleted file mode 100644 index 2636e9a5c8ea..000000000000 --- a/litellm/proxy/auth_v2/session.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import secrets -import time -from typing import Any, Dict, Optional, Tuple - -from fastapi import Request -from pydantic import BaseModel - -from .models import AuthMethod, Credential, CredentialRef, SecuritySchemeType - - -class SessionConfig(BaseModel): - cookie: str = "litellm_session" - secure: bool = True - ttl_seconds: int = 3600 - max_size: int = 10000 - default_redirect_path: str = "/" - login_cookie: str = "litellm_oidc_txn" - login_state_ttl: int = 300 - - -def safe_relay_state(target: Optional[str], default: str) -> str: - if ( - target - and target.startswith("/") - and not target.startswith("//") - and "://" not in target - and "\\" not in target - ): - return target - return default - - -class SessionStore: - def __init__(self, ttl_seconds: int = 3600, max_size: int = 10000) -> None: - self._sessions: Dict[str, Tuple[float, Dict[str, Any]]] = {} - self._ttl = ttl_seconds - self._max_size = max_size - - def create_session(self, identity: Dict[str, Any]) -> str: - now = time.time() - self._evict(now) - session_id = secrets.token_urlsafe(32) - self._sessions[session_id] = (now + self._ttl, identity) - return session_id - - def get(self, session_id: str) -> Optional[Dict[str, Any]]: - entry = self._sessions.get(session_id) - if entry is None: - return None - expires_at, identity = entry - if expires_at < time.time(): - self._sessions.pop(session_id, None) - return None - return identity - - def pop(self, session_id: str) -> Optional[Dict[str, Any]]: - entry = self._sessions.pop(session_id, None) - if entry is None: - return None - expires_at, identity = entry - if expires_at < time.time(): - return None - return identity - - def _evict(self, now: float) -> None: - for key in [k for k, (exp, _) in self._sessions.items() if exp < now]: - self._sessions.pop(key, None) - overflow = len(self._sessions) - self._max_size + 1 - if overflow > 0: - oldest = sorted(self._sessions, key=lambda k: self._sessions[k][0]) - for key in oldest[:overflow]: - self._sessions.pop(key, None) - - -class SessionAuthenticator: - def __init__(self, cookie_name: str, store: SessionStore) -> None: - self._cookie_name = cookie_name - self._store = store - - async def authenticate(self, request: Request) -> Optional[Credential]: - session_id = request.cookies.get(self._cookie_name) - if not session_id: - return None - identity = self._store.get(session_id) - if identity is None: - return None - return Credential( - scheme=SecuritySchemeType.API_KEY, - method=AuthMethod(identity["method"]), - subject=identity["subject"], - issuer=identity.get("issuer"), - claims=identity.get("claims", {}), - credential_ref=CredentialRef(token_id=session_id), - ) - - def challenge(self) -> str: - return "" diff --git a/litellm/proxy/auth_v2/sessions/__init__.py b/litellm/proxy/auth_v2/sessions/__init__.py new file mode 100644 index 000000000000..7228c9ee3dd6 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/__init__.py @@ -0,0 +1,15 @@ +from litellm.proxy.auth_v2.sessions.factory import StateBackend +from litellm.proxy.auth_v2.sessions.base import StateStore, StateValue +from litellm.proxy.auth_v2.sessions.memory import InMemoryStateStore +from litellm.proxy.auth_v2.sessions.redis import RedisStateStore +from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState + +__all__ = [ + "StateBackend", + "StateStore", + "StateValue", + "InMemoryStateStore", + "RedisStateStore", + "SessionState", + "OAuthTransaction", +] diff --git a/litellm/proxy/auth_v2/sessions/base.py b/litellm/proxy/auth_v2/sessions/base.py new file mode 100644 index 000000000000..a78919970762 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/base.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any, Mapping, Optional, Protocol, TypeVar, runtime_checkable + +StateValue = TypeVar("StateValue", bound=Mapping[str, Any]) + + +@runtime_checkable +class StateStore(Protocol[StateValue]): + """Async key/value store with per-key TTL, generic over its value schema. + + Backs short-lived auth state. Each store is parameterized by the typed + payload it holds (see ``schemas``) and namespaced by the backend that hands + it out, so several stores can share one Redis instance without colliding. + """ + + async def get(self, key: str) -> Optional[StateValue]: ... + + async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: ... + + async def pop(self, key: str) -> Optional[StateValue]: ... + + async def delete(self, key: str) -> None: ... + + async def add_if_absent(self, key: str, ttl_seconds: Optional[int] = None) -> bool: + """Set a marker only if the key is absent; return True iff newly set. + + Atomic and value-free. Use for one-time guards like SAML assertion + replay detection, where only key presence matters. + """ + ... diff --git a/litellm/proxy/auth_v2/sessions/factory.py b/litellm/proxy/auth_v2/sessions/factory.py new file mode 100644 index 000000000000..6b8450f79899 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/factory.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Optional, cast + +from litellm._redis import get_redis_async_client + +from litellm.proxy.auth_v2.sessions.base import StateStore, StateValue +from litellm.proxy.auth_v2.sessions.memory import InMemoryStateStore +from litellm.proxy.auth_v2.sessions.redis import RedisStateStore + +if TYPE_CHECKING: + from redis.asyncio import Redis + +logger = logging.getLogger("litellm.proxy.auth_v2.sessions") + +_REDIS_ENV_SIGNALS = ( + "REDIS_URL", + "REDIS_HOST", + "REDIS_CLUSTER_NODES", + "REDIS_SENTINEL_NODES", +) + + +async def _reachable(client: "Redis") -> bool: + try: + return bool(await client.ping()) + except Exception: + return False + + +def _default_redis_client() -> Optional["Redis"]: + if not any(os.getenv(signal) for signal in _REDIS_ENV_SIGNALS): + return None + try: + return cast("Redis", get_redis_async_client()) + except Exception: + logger.warning("auth_v2 state layer could not build a Redis client", exc_info=True) + return None + + +class StateBackend: + """Hands out namespaced state stores backed by Redis when reachable, else memory. + + The Redis-vs-memory choice is made once, at ``connect`` time, and held for the + backend's lifetime. We deliberately do not fail over per operation: silently + moving a live session from Redis to a local dict would strand it on one worker + and lose it the moment another worker serves the next request. + + Inject the client for tests or to share the proxy's existing connection; the + default builder only fires when Redis is configured via the environment. + """ + + def __init__(self, redis_client: Optional["Redis"]) -> None: + self._redis = redis_client + + @classmethod + async def connect(cls, redis_client: Optional["Redis"] = None) -> "StateBackend": + client = redis_client if redis_client is not None else _default_redis_client() + if client is not None and await _reachable(client): + logger.info("auth_v2 state layer using Redis backend") + return cls(client) + logger.info("auth_v2 state layer using in-memory backend") + return cls(None) + + @property + def using_redis(self) -> bool: + return self._redis is not None + + def store(self, namespace: str, *, default_ttl: int) -> StateStore[StateValue]: + """Return a typed store for ``namespace``. + + The value schema is taken from the call site's annotation, e.g. + ``sessions: StateStore[SessionState] = backend.store("sessions", default_ttl=3600)``. + """ + if self._redis is not None: + return RedisStateStore(self._redis, namespace, default_ttl) + return InMemoryStateStore(namespace, default_ttl) diff --git a/litellm/proxy/auth_v2/sessions/memory.py b/litellm/proxy/auth_v2/sessions/memory.py new file mode 100644 index 000000000000..ccccfefab5e7 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/memory.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import time +from typing import Dict, Generic, Optional, Tuple + +from litellm.proxy.auth_v2.sessions.base import StateValue + + +class InMemoryStateStore(Generic[StateValue]): + """Process-local fallback when Redis is unavailable. Single-process only.""" + + def __init__(self, namespace: str, default_ttl: int, max_size: int = 10000) -> None: + self._namespace = namespace + self._default_ttl = default_ttl + self._max_size = max_size + self._entries: Dict[str, Tuple[float, Optional[StateValue]]] = {} + + def _key(self, key: str) -> str: + return f"{self._namespace}:{key}" + + def _expiry(self, ttl_seconds: Optional[int]) -> float: + return time.time() + (self._default_ttl if ttl_seconds is None else ttl_seconds) + + def _live(self, key: str, now: float) -> Optional[Tuple[float, Optional[StateValue]]]: + entry = self._entries.get(key) + if entry is None: + return None + if entry[0] < now: + self._entries.pop(key, None) + return None + return entry + + async def get(self, key: str) -> Optional[StateValue]: + entry = self._live(self._key(key), time.time()) + return entry[1] if entry is not None else None + + async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: + self._evict(time.time()) + self._entries[self._key(key)] = (self._expiry(ttl_seconds), value) + + async def pop(self, key: str) -> Optional[StateValue]: + entry = self._entries.pop(self._key(key), None) + if entry is None or entry[0] < time.time(): + return None + return entry[1] + + async def delete(self, key: str) -> None: + self._entries.pop(self._key(key), None) + + async def add_if_absent(self, key: str, ttl_seconds: Optional[int] = None) -> bool: + now = time.time() + self._evict(now) + namespaced = self._key(key) + if self._live(namespaced, now) is not None: + return False + self._entries[namespaced] = (self._expiry(ttl_seconds), None) + return True + + def _evict(self, now: float) -> None: + for key in [k for k, (exp, _) in self._entries.items() if exp < now]: + self._entries.pop(key, None) + overflow = len(self._entries) - self._max_size + 1 + if overflow > 0: + oldest = sorted(self._entries, key=lambda k: self._entries[k][0]) + for key in oldest[:overflow]: + self._entries.pop(key, None) diff --git a/litellm/proxy/auth_v2/sessions/redis.py b/litellm/proxy/auth_v2/sessions/redis.py new file mode 100644 index 000000000000..489060904b97 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/redis.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Generic, Optional, cast + +from litellm.proxy.auth_v2.sessions.base import StateValue + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +class RedisStateStore(Generic[StateValue]): + """Redis-backed store. Shared across workers; Redis enforces the TTL.""" + + def __init__(self, client: "Redis", namespace: str, default_ttl: int) -> None: + self._client = client + self._namespace = namespace + self._default_ttl = default_ttl + + def _key(self, key: str) -> str: + return f"{self._namespace}:{key}" + + def _ttl(self, ttl_seconds: Optional[int]) -> int: + return self._default_ttl if ttl_seconds is None else ttl_seconds + + async def get(self, key: str) -> Optional[StateValue]: + raw = await self._client.get(self._key(key)) + return cast(StateValue, json.loads(raw)) if raw is not None else None + + async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: + await self._client.set(self._key(key), json.dumps(value), ex=self._ttl(ttl_seconds)) + + async def pop(self, key: str) -> Optional[StateValue]: + raw = await self._client.getdel(self._key(key)) + return cast(StateValue, json.loads(raw)) if raw is not None else None + + async def delete(self, key: str) -> None: + await self._client.delete(self._key(key)) + + async def add_if_absent(self, key: str, ttl_seconds: Optional[int] = None) -> bool: + added = await self._client.set(self._key(key), "1", nx=True, ex=self._ttl(ttl_seconds)) + return bool(added) diff --git a/litellm/proxy/auth_v2/sessions/schemas.py b/litellm/proxy/auth_v2/sessions/schemas.py new file mode 100644 index 000000000000..2b7f64ce29c0 --- /dev/null +++ b/litellm/proxy/auth_v2/sessions/schemas.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional, TypedDict + + +class SessionState(TypedDict): + """A logged-in session, keyed by the session cookie's opaque id.""" + + method: str + subject: str + issuer: Optional[str] + claims: Dict[str, Any] + + +class OAuthTransaction(TypedDict): + """In-flight OIDC authorization-code login, keyed by the login cookie's id.""" + + provider: str + state: str + nonce: Optional[str] + code_verifier: Optional[str] + redirect_uri: str + relay: str From 4ebb7bb8907273560fca9db40adc066faa8e4409 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 16:50:10 -0700 Subject: [PATCH 41/51] fix: refactor --- backend/auth/routers/oidc.py | 2 +- backend/auth/routers/saml.py | 2 +- .../proxy/auth_v2/authenticators/session.py | 6 +- litellm/proxy/auth_v2/network.py | 12 +-- litellm/proxy/auth_v2/security.py | 38 +++++++-- litellm/proxy/auth_v2/sessions/__init__.py | 18 ++--- litellm/proxy/auth_v2/sessions/base.py | 10 +-- litellm/proxy/auth_v2/sessions/factory.py | 79 ------------------- litellm/proxy/auth_v2/sessions/memory.py | 14 ++-- litellm/proxy/auth_v2/sessions/redis.py | 14 ++-- .../auth_v2/sessions/{schemas.py => types.py} | 0 11 files changed, 67 insertions(+), 128 deletions(-) delete mode 100644 litellm/proxy/auth_v2/sessions/factory.py rename litellm/proxy/auth_v2/sessions/{schemas.py => types.py} (100%) diff --git a/backend/auth/routers/oidc.py b/backend/auth/routers/oidc.py index a489012ce4c4..f7a51fee2058 100644 --- a/backend/auth/routers/oidc.py +++ b/backend/auth/routers/oidc.py @@ -13,7 +13,7 @@ from ..services.redirects import safe_relay_state from litellm.proxy.auth_v2.resolvers import ProvisioningStore from litellm.proxy.auth_v2.security import AuthSecurity -from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState +from litellm.proxy.auth_v2.sessions.types import OAuthTransaction, SessionState from ..services.oidc import mapped_claims, providers_by_key, user_from_userinfo from .dependencies import get_auth, get_oauth_registry diff --git a/backend/auth/routers/saml.py b/backend/auth/routers/saml.py index a4b1911dfb1a..f04b6dcb7bb5 100644 --- a/backend/auth/routers/saml.py +++ b/backend/auth/routers/saml.py @@ -15,7 +15,7 @@ from ..services.redirects import safe_relay_state from litellm.proxy.auth_v2.resolvers import ProvisioningStore from litellm.proxy.auth_v2.security import AuthSecurity -from litellm.proxy.auth_v2.sessions.schemas import SessionState +from litellm.proxy.auth_v2.sessions.types import SessionState from ..services.saml import ( SAMLProtocolStore, diff --git a/litellm/proxy/auth_v2/authenticators/session.py b/litellm/proxy/auth_v2/authenticators/session.py index 1d9aa7e3c140..907e826409be 100644 --- a/litellm/proxy/auth_v2/authenticators/session.py +++ b/litellm/proxy/auth_v2/authenticators/session.py @@ -11,12 +11,12 @@ CredentialRef, SecuritySchemeType, ) -from litellm.proxy.auth_v2.sessions import StateStore -from litellm.proxy.auth_v2.sessions.schemas import SessionState +from litellm.proxy.auth_v2.sessions import SessionStore +from litellm.proxy.auth_v2.sessions.types import SessionState class SessionAuthenticator(Authenticator): - def __init__(self, cookie_name: str, store: "StateStore[SessionState]") -> None: + def __init__(self, cookie_name: str, store: "SessionStore[SessionState]") -> None: self._cookie_name = cookie_name self._store = store diff --git a/litellm/proxy/auth_v2/network.py b/litellm/proxy/auth_v2/network.py index e92ce7941743..2ae43dd22922 100644 --- a/litellm/proxy/auth_v2/network.py +++ b/litellm/proxy/auth_v2/network.py @@ -34,13 +34,9 @@ def ip_in_trusted_proxies(ip: Optional[str], config: TrustedProxyConfig) -> bool return _ip_in_cidrs(ip, config.trusted_proxy_cidrs) -def resolve_client_ip( - request: Request, config: TrustedProxyConfig -) -> Tuple[Optional[str], bool]: +def resolve_client_ip(request: Request, config: TrustedProxyConfig) -> Tuple[Optional[str], bool]: peer = request.client.host if request.client else None - if not config.use_forwarded_for or not _ip_in_cidrs( - peer, config.trusted_proxy_cidrs - ): + if not config.use_forwarded_for or not _ip_in_cidrs(peer, config.trusted_proxy_cidrs): return peer, False forwarded = request.headers.get("x-forwarded-for", "") hops = [h.strip() for h in forwarded.split(",") if h.strip()] @@ -50,9 +46,7 @@ def resolve_client_ip( return peer, True -def resolve_network_context( - request: Request, config: TrustedProxyConfig -) -> NetworkContext: +def resolve_network_context(request: Request, config: TrustedProxyConfig) -> NetworkContext: ip, via_proxy = resolve_client_ip(request, config) return NetworkContext( client_ip=ip, diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index be667a9f3985..87f15da42511 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -1,8 +1,10 @@ +import os from typing import Annotated, Callable, List, Optional from fastapi import Request, Security from fastapi.security import SecurityScopes +from litellm._redis import get_redis_async_client from litellm.proxy.auth_v2 import errors from litellm.proxy.auth_v2.authenticators import ( Authenticator, @@ -20,8 +22,34 @@ ) from litellm.proxy.auth_v2.authenticators.session import SessionAuthenticator from litellm.proxy.auth_v2.resolvers import IdentityResolver -from litellm.proxy.auth_v2.sessions import StateBackend, StateStore -from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState +from litellm.proxy.auth_v2.sessions import ( + InMemorySessionStore, + RedisSessionStore, + SessionStore, + SessionValue, +) +from litellm.proxy.auth_v2.sessions.types import OAuthTransaction, SessionState + +_REDIS_ENV_SIGNALS = ( + "REDIS_URL", + "REDIS_HOST", + "REDIS_CLUSTER_NODES", + "REDIS_SENTINEL_NODES", +) + + +def _open_session_store(namespace: str, *, default_ttl: int) -> SessionStore[SessionValue]: + """Build the session/login-state store for ``namespace``. + + Uses Redis when configured via the environment (required so state is shared + across pods in a multi-pod deployment); otherwise a process-local in-memory + store for single-process/dev. Chosen from the environment, not by probing + Redis, so a configured-but-unreachable Redis fails loudly on use rather than + silently stranding state on one pod. + """ + if any(os.getenv(signal) for signal in _REDIS_ENV_SIGNALS): + return RedisSessionStore(get_redis_async_client(), namespace, default_ttl) + return InMemorySessionStore(namespace, default_ttl) def _combined_challenge(authenticators: List[Authenticator]) -> str: @@ -56,16 +84,14 @@ def __init__( authorizer: Optional[Authorizer] = None, authenticators: Optional[List[Authenticator]] = None, basic_verifier: Optional[BasicAuthVerifier] = None, - state_backend: Optional[StateBackend] = None, ) -> None: self.config = config self.resolver = resolver self.authorizer = authorizer or RBACEngine(config.casbin_policy_path) - self._state = state_backend or StateBackend(None) - self.session_store: StateStore[SessionState] = self._state.store( + self.session_store: SessionStore[SessionState] = _open_session_store( "sessions", default_ttl=config.session.ttl_seconds ) - self.oauth_txn_store: StateStore[OAuthTransaction] = self._state.store( + self.oauth_txn_store: SessionStore[OAuthTransaction] = _open_session_store( "oauth_txn", default_ttl=config.session.login_state_ttl ) chain = ( diff --git a/litellm/proxy/auth_v2/sessions/__init__.py b/litellm/proxy/auth_v2/sessions/__init__.py index 7228c9ee3dd6..296134964164 100644 --- a/litellm/proxy/auth_v2/sessions/__init__.py +++ b/litellm/proxy/auth_v2/sessions/__init__.py @@ -1,15 +1,13 @@ -from litellm.proxy.auth_v2.sessions.factory import StateBackend -from litellm.proxy.auth_v2.sessions.base import StateStore, StateValue -from litellm.proxy.auth_v2.sessions.memory import InMemoryStateStore -from litellm.proxy.auth_v2.sessions.redis import RedisStateStore -from litellm.proxy.auth_v2.sessions.schemas import OAuthTransaction, SessionState +from litellm.proxy.auth_v2.sessions.base import SessionStore, SessionValue +from litellm.proxy.auth_v2.sessions.memory import InMemorySessionStore +from litellm.proxy.auth_v2.sessions.redis import RedisSessionStore +from litellm.proxy.auth_v2.sessions.types import OAuthTransaction, SessionState __all__ = [ - "StateBackend", - "StateStore", - "StateValue", - "InMemoryStateStore", - "RedisStateStore", + "SessionStore", + "SessionValue", + "InMemorySessionStore", + "RedisSessionStore", "SessionState", "OAuthTransaction", ] diff --git a/litellm/proxy/auth_v2/sessions/base.py b/litellm/proxy/auth_v2/sessions/base.py index a78919970762..e82353dc5a7a 100644 --- a/litellm/proxy/auth_v2/sessions/base.py +++ b/litellm/proxy/auth_v2/sessions/base.py @@ -2,11 +2,11 @@ from typing import Any, Mapping, Optional, Protocol, TypeVar, runtime_checkable -StateValue = TypeVar("StateValue", bound=Mapping[str, Any]) +SessionValue = TypeVar("SessionValue", bound=Mapping[str, Any]) @runtime_checkable -class StateStore(Protocol[StateValue]): +class SessionStore(Protocol[SessionValue]): """Async key/value store with per-key TTL, generic over its value schema. Backs short-lived auth state. Each store is parameterized by the typed @@ -14,11 +14,11 @@ class StateStore(Protocol[StateValue]): it out, so several stores can share one Redis instance without colliding. """ - async def get(self, key: str) -> Optional[StateValue]: ... + async def get(self, key: str) -> Optional[SessionValue]: ... - async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: ... + async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: ... - async def pop(self, key: str) -> Optional[StateValue]: ... + async def pop(self, key: str) -> Optional[SessionValue]: ... async def delete(self, key: str) -> None: ... diff --git a/litellm/proxy/auth_v2/sessions/factory.py b/litellm/proxy/auth_v2/sessions/factory.py deleted file mode 100644 index 6b8450f79899..000000000000 --- a/litellm/proxy/auth_v2/sessions/factory.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import TYPE_CHECKING, Optional, cast - -from litellm._redis import get_redis_async_client - -from litellm.proxy.auth_v2.sessions.base import StateStore, StateValue -from litellm.proxy.auth_v2.sessions.memory import InMemoryStateStore -from litellm.proxy.auth_v2.sessions.redis import RedisStateStore - -if TYPE_CHECKING: - from redis.asyncio import Redis - -logger = logging.getLogger("litellm.proxy.auth_v2.sessions") - -_REDIS_ENV_SIGNALS = ( - "REDIS_URL", - "REDIS_HOST", - "REDIS_CLUSTER_NODES", - "REDIS_SENTINEL_NODES", -) - - -async def _reachable(client: "Redis") -> bool: - try: - return bool(await client.ping()) - except Exception: - return False - - -def _default_redis_client() -> Optional["Redis"]: - if not any(os.getenv(signal) for signal in _REDIS_ENV_SIGNALS): - return None - try: - return cast("Redis", get_redis_async_client()) - except Exception: - logger.warning("auth_v2 state layer could not build a Redis client", exc_info=True) - return None - - -class StateBackend: - """Hands out namespaced state stores backed by Redis when reachable, else memory. - - The Redis-vs-memory choice is made once, at ``connect`` time, and held for the - backend's lifetime. We deliberately do not fail over per operation: silently - moving a live session from Redis to a local dict would strand it on one worker - and lose it the moment another worker serves the next request. - - Inject the client for tests or to share the proxy's existing connection; the - default builder only fires when Redis is configured via the environment. - """ - - def __init__(self, redis_client: Optional["Redis"]) -> None: - self._redis = redis_client - - @classmethod - async def connect(cls, redis_client: Optional["Redis"] = None) -> "StateBackend": - client = redis_client if redis_client is not None else _default_redis_client() - if client is not None and await _reachable(client): - logger.info("auth_v2 state layer using Redis backend") - return cls(client) - logger.info("auth_v2 state layer using in-memory backend") - return cls(None) - - @property - def using_redis(self) -> bool: - return self._redis is not None - - def store(self, namespace: str, *, default_ttl: int) -> StateStore[StateValue]: - """Return a typed store for ``namespace``. - - The value schema is taken from the call site's annotation, e.g. - ``sessions: StateStore[SessionState] = backend.store("sessions", default_ttl=3600)``. - """ - if self._redis is not None: - return RedisStateStore(self._redis, namespace, default_ttl) - return InMemoryStateStore(namespace, default_ttl) diff --git a/litellm/proxy/auth_v2/sessions/memory.py b/litellm/proxy/auth_v2/sessions/memory.py index ccccfefab5e7..ce19b07c22e9 100644 --- a/litellm/proxy/auth_v2/sessions/memory.py +++ b/litellm/proxy/auth_v2/sessions/memory.py @@ -3,17 +3,17 @@ import time from typing import Dict, Generic, Optional, Tuple -from litellm.proxy.auth_v2.sessions.base import StateValue +from litellm.proxy.auth_v2.sessions.base import SessionValue -class InMemoryStateStore(Generic[StateValue]): +class InMemorySessionStore(Generic[SessionValue]): """Process-local fallback when Redis is unavailable. Single-process only.""" def __init__(self, namespace: str, default_ttl: int, max_size: int = 10000) -> None: self._namespace = namespace self._default_ttl = default_ttl self._max_size = max_size - self._entries: Dict[str, Tuple[float, Optional[StateValue]]] = {} + self._entries: Dict[str, Tuple[float, Optional[SessionValue]]] = {} def _key(self, key: str) -> str: return f"{self._namespace}:{key}" @@ -21,7 +21,7 @@ def _key(self, key: str) -> str: def _expiry(self, ttl_seconds: Optional[int]) -> float: return time.time() + (self._default_ttl if ttl_seconds is None else ttl_seconds) - def _live(self, key: str, now: float) -> Optional[Tuple[float, Optional[StateValue]]]: + def _live(self, key: str, now: float) -> Optional[Tuple[float, Optional[SessionValue]]]: entry = self._entries.get(key) if entry is None: return None @@ -30,15 +30,15 @@ def _live(self, key: str, now: float) -> Optional[Tuple[float, Optional[StateVal return None return entry - async def get(self, key: str) -> Optional[StateValue]: + async def get(self, key: str) -> Optional[SessionValue]: entry = self._live(self._key(key), time.time()) return entry[1] if entry is not None else None - async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: + async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: self._evict(time.time()) self._entries[self._key(key)] = (self._expiry(ttl_seconds), value) - async def pop(self, key: str) -> Optional[StateValue]: + async def pop(self, key: str) -> Optional[SessionValue]: entry = self._entries.pop(self._key(key), None) if entry is None or entry[0] < time.time(): return None diff --git a/litellm/proxy/auth_v2/sessions/redis.py b/litellm/proxy/auth_v2/sessions/redis.py index 489060904b97..7242c89ebdab 100644 --- a/litellm/proxy/auth_v2/sessions/redis.py +++ b/litellm/proxy/auth_v2/sessions/redis.py @@ -3,13 +3,13 @@ import json from typing import TYPE_CHECKING, Generic, Optional, cast -from litellm.proxy.auth_v2.sessions.base import StateValue +from litellm.proxy.auth_v2.sessions.base import SessionValue if TYPE_CHECKING: from redis.asyncio import Redis -class RedisStateStore(Generic[StateValue]): +class RedisSessionStore(Generic[SessionValue]): """Redis-backed store. Shared across workers; Redis enforces the TTL.""" def __init__(self, client: "Redis", namespace: str, default_ttl: int) -> None: @@ -23,16 +23,16 @@ def _key(self, key: str) -> str: def _ttl(self, ttl_seconds: Optional[int]) -> int: return self._default_ttl if ttl_seconds is None else ttl_seconds - async def get(self, key: str) -> Optional[StateValue]: + async def get(self, key: str) -> Optional[SessionValue]: raw = await self._client.get(self._key(key)) - return cast(StateValue, json.loads(raw)) if raw is not None else None + return cast(SessionValue, json.loads(raw)) if raw is not None else None - async def set(self, key: str, value: StateValue, ttl_seconds: Optional[int] = None) -> None: + async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: await self._client.set(self._key(key), json.dumps(value), ex=self._ttl(ttl_seconds)) - async def pop(self, key: str) -> Optional[StateValue]: + async def pop(self, key: str) -> Optional[SessionValue]: raw = await self._client.getdel(self._key(key)) - return cast(StateValue, json.loads(raw)) if raw is not None else None + return cast(SessionValue, json.loads(raw)) if raw is not None else None async def delete(self, key: str) -> None: await self._client.delete(self._key(key)) diff --git a/litellm/proxy/auth_v2/sessions/schemas.py b/litellm/proxy/auth_v2/sessions/types.py similarity index 100% rename from litellm/proxy/auth_v2/sessions/schemas.py rename to litellm/proxy/auth_v2/sessions/types.py From 8caa4da357d601524a5e884673845222735e1200 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 17:23:10 -0700 Subject: [PATCH 42/51] fix: token exchange support --- litellm/proxy/auth_v2/authenticators/oauth.py | 1 + litellm/proxy/auth_v2/authenticators/utils.py | 1 + litellm/proxy/auth_v2/models.py | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/litellm/proxy/auth_v2/authenticators/oauth.py b/litellm/proxy/auth_v2/authenticators/oauth.py index d2e0ea0035dd..275471dd45c1 100644 --- a/litellm/proxy/auth_v2/authenticators/oauth.py +++ b/litellm/proxy/auth_v2/authenticators/oauth.py @@ -87,6 +87,7 @@ async def _introspect(self, token: str) -> Credential: audience=token_audience, scopes=split_scope(body.get("scope")), claims=claims, + subject_token=token, ) def challenge(self) -> str: diff --git a/litellm/proxy/auth_v2/authenticators/utils.py b/litellm/proxy/auth_v2/authenticators/utils.py index be20515dcc20..eab8311dc350 100644 --- a/litellm/proxy/auth_v2/authenticators/utils.py +++ b/litellm/proxy/auth_v2/authenticators/utils.py @@ -66,6 +66,7 @@ def credential_from_claims( scopes=split_scope(claims.get("scope")), claims=claims, credential_ref=CredentialRef(key_id=header.get("kid"), token_id=claims.get("jti")), + subject_token=token, ) diff --git a/litellm/proxy/auth_v2/models.py b/litellm/proxy/auth_v2/models.py index 7f5df99e8364..046442b0ec91 100644 --- a/litellm/proxy/auth_v2/models.py +++ b/litellm/proxy/auth_v2/models.py @@ -97,6 +97,12 @@ class Credential(BaseModel): credential_ref: CredentialRef = Field(default_factory=CredentialRef) client_certificate: Optional[ClientCertificate] = None + # Raw bearer/access token as presented by the caller, retained so it can be + # used as the subject_token for downstream token exchange (RFC 8693) when + # calling LLM providers or MCP servers on the caller's behalf. None for + # schemes without an exchangeable token (API key, HTTP basic, mTLS). + subject_token: Optional[str] = None + class Principal(BaseModel): """Normalized caller identity. Identity only, no policy/budget state.""" From c883abfc56915ea11a7794b3b9009c696372320f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 18:26:29 -0700 Subject: [PATCH 43/51] feat(auth_v2): add Casbin ABAC authorizer Add ABACEngine alongside RBACEngine for attribute-based decisions over subject attributes (roles, teams, org, scopes, claims) and resource attributes (endpoint, method, model, mcp_server, mcp_tool). Policies are operator-supplied YAML loaded via add_policy. The Casbin CSV FileAdapter is avoided on purpose: it retains the quotes around a comma-bearing expression, turning an eval'd rule into a truthy string literal and silently allowing inputs that should deny. Claims access yields None for missing keys so a single policy row referencing an absent claim cannot poison the whole decision, and rule-evaluation errors fail closed. Engine only; not yet wired into the live request path --- .../proxy/auth_v2/authorization/__init__.py | 5 +- litellm/proxy/auth_v2/authorization/abac.py | 136 ++++++++++ litellm/proxy/auth_v2/config.py | 13 +- tests/test_litellm/proxy/auth_v2/test_abac.py | 237 ++++++++++++++++++ 4 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 litellm/proxy/auth_v2/authorization/abac.py create mode 100644 tests/test_litellm/proxy/auth_v2/test_abac.py diff --git a/litellm/proxy/auth_v2/authorization/__init__.py b/litellm/proxy/auth_v2/authorization/__init__.py index e1bce928222a..840198f8f53f 100644 --- a/litellm/proxy/auth_v2/authorization/__init__.py +++ b/litellm/proxy/auth_v2/authorization/__init__.py @@ -1,12 +1,13 @@ +from litellm.proxy.auth_v2.authorization.abac import ABACEngine, ProtectedResource from litellm.proxy.auth_v2.authorization.base import Authorizer from litellm.proxy.auth_v2.authorization.rbac import RBACEngine from litellm.proxy.auth_v2.authorization.roles import Role, filter_claim_roles -from litellm.proxy.auth_v2.authorization.scopes import has_required_scopes __all__ = [ + "ABACEngine", "Authorizer", + "ProtectedResource", "RBACEngine", "Role", "filter_claim_roles", - "has_required_scopes", ] diff --git a/litellm/proxy/auth_v2/authorization/abac.py b/litellm/proxy/auth_v2/authorization/abac.py new file mode 100644 index 000000000000..6abd27c10654 --- /dev/null +++ b/litellm/proxy/auth_v2/authorization/abac.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +import casbin +import yaml +from pydantic import BaseModel + +from litellm.proxy.auth_v2.authorization.base import Authorizer +from litellm.proxy.auth_v2.authorization.roles import Role + +if TYPE_CHECKING: + from litellm.proxy.auth_v2.models import Principal + +_MODEL_TEXT = """ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub_rule, obj_rule, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = eval(p_sub_rule) && eval(p_obj_rule) && regexMatch(r_act, "^(" + p_act + ")$") +""" + + +class ProtectedResource(BaseModel): + """The resource a principal is acting on, exposed to policy as ``r_obj``.""" + + endpoint: Optional[str] = None + method: Optional[str] = None + model: Optional[str] = None + mcp_server: Optional[str] = None + mcp_tool: Optional[str] = None + + +class _SafeClaims(dict): + """Claims map that yields None for absent keys. + + Casbin evaluates every policy row in a single matcher, so a row referencing + ``r_sub.claims['x']`` would raise KeyError for any principal lacking that + claim and abort the whole decision. Returning None keeps such a row simply + non-matching instead of poisoning unrelated rows. + """ + + def __missing__(self, key: str) -> None: + return None + + +class _Subject: + """Principal attributes exposed to policy as ``r_sub``.""" + + def __init__( + self, + roles: List[str], + teams: List[str], + org: Optional[str], + scopes: List[str], + claims: Dict[str, Any], + user_id: Optional[str], + email: Optional[str], + ) -> None: + self.roles = roles + self.teams = teams + self.org = org + self.scopes = scopes + self.claims = _SafeClaims(claims) + self.user_id = user_id + self.email = email + + +def _subject_view(principal: "Principal") -> _Subject: + roles = [role.value for role in principal.roles] + claim_roles = principal.claims.get("roles", []) + if isinstance(claim_roles, list): + roles += [r for r in claim_roles if isinstance(r, str) and r not in roles] + return _Subject( + roles=roles, + teams=[team.name for team in principal.teams if team.name], + org=principal.organization.id if principal.organization else None, + scopes=list(principal.scopes), + claims=principal.claims, + user_id=principal.user.id if principal.user else None, + email=principal.user.email if principal.user else None, + ) + + +def _load_policies(policy_path: str) -> List[Tuple[str, str, str]]: + with open(policy_path, "r") as handle: + document = yaml.safe_load(handle) or {} + entries = document.get("policies") or [] + rules: List[Tuple[str, str, str]] = [] + for index, entry in enumerate(entries): + try: + rules.append((entry["sub_rule"], entry["obj_rule"], entry["act"])) + except (TypeError, KeyError) as exc: + raise ValueError( + f"abac policy entry {index} must define sub_rule, obj_rule and act" + ) from exc + return rules + + +class ABACEngine(Authorizer): + """Attribute-based authorizer: subject x resource policies via Casbin. + + Policies are operator-supplied YAML (``{policies: [{sub_rule, obj_rule, + act}]}``) loaded into an in-memory enforcer. The CSV FileAdapter is avoided + on purpose: it retains the quotes around comma-bearing expressions, turning + an eval'd condition into a truthy string literal and silently allowing. + """ + + def __init__(self, policy_path: Optional[str] = None) -> None: + model = casbin.Model() + model.load_model_from_text(_MODEL_TEXT) + self._enforcer = casbin.Enforcer(model) + if policy_path: + for rule in _load_policies(policy_path): + self._enforcer.add_policy(*rule) + + def decide(self, principal: "Principal", resource: ProtectedResource) -> bool: + try: + return self._enforcer.enforce( + _subject_view(principal), resource, resource.method or "" + ) + except Exception: + return False + + def enforce(self, principal: "Principal", obj: str, act: str) -> bool: + return self.decide(principal, ProtectedResource(endpoint=obj, method=act)) + + def has_any_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: + allowed_values = {role.value for role in allowed} + return any(role.value in allowed_values for role in principal.roles) diff --git a/litellm/proxy/auth_v2/config.py b/litellm/proxy/auth_v2/config.py index d036f77730e5..6b3d02c63342 100644 --- a/litellm/proxy/auth_v2/config.py +++ b/litellm/proxy/auth_v2/config.py @@ -44,7 +44,9 @@ class OIDCProviderConfig(BaseModel): require_at_jwt: bool = False client_id: Optional[str] = None client_secret: Optional[SecretStr] = None - login_scopes: List[str] = Field(default_factory=lambda: ["openid", "email", "profile"]) + login_scopes: List[str] = Field( + default_factory=lambda: ["openid", "email", "profile"] + ) allowed_roles: List[str] = Field(default_factory=list) allow_platform_roles: bool = False @@ -70,14 +72,18 @@ class SAMLConfig(BaseModel): sp_cert_file: Optional[str] = None allow_unsolicited: bool = False xmlsec_binary: Optional[str] = None - attribute_map: Dict[str, str] = Field(default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP)) + attribute_map: Dict[str, str] = Field( + default_factory=lambda: dict(DEFAULT_SAML_ATTRIBUTE_MAP) + ) allowed_roles: List[str] = Field(default_factory=list) allow_platform_roles: bool = False @model_validator(mode="after") def _require_idp_metadata(self) -> "SAMLConfig": if self.enabled and not self.idp_metadata.strip(): - raise ValueError("SAML enabled but idp_metadata is empty (inline XML, local path, or URL)") + raise ValueError( + "SAML enabled but idp_metadata is empty (inline XML, local path, or URL)" + ) return self @@ -134,3 +140,4 @@ class AuthConfig(BaseModel): session: SessionConfig = Field(default_factory=SessionConfig) saml: Optional[SAMLConfig] = None casbin_policy_path: Optional[str] = None + abac_policy_path: Optional[str] = None diff --git a/tests/test_litellm/proxy/auth_v2/test_abac.py b/tests/test_litellm/proxy/auth_v2/test_abac.py new file mode 100644 index 000000000000..37b4647e88ad --- /dev/null +++ b/tests/test_litellm/proxy/auth_v2/test_abac.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import pytest + +from litellm.proxy.auth_v2.authorization import ABACEngine, ProtectedResource, Role +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Principal, + PrincipalType, + TeamIdentity, +) + +# The two policies from the design: a role-restricted model allowlist on +# /v1/messages, and a team-restricted MCP tool allowlist on the github server. +POLICIES = """ +policies: + - sub_rule: "'manager' in r_sub.roles" + obj_rule: "r_obj.endpoint == '/v1/messages' and r_obj.model in ['claude-sonnet-4-6','gpt-4o']" + act: "POST" + - sub_rule: "'eng' in r_sub.teams" + obj_rule: "r_obj.mcp_server == 'github' and r_obj.mcp_tool in ['search','read_file']" + act: "POST|GET" +""" + + +def _principal(*, roles=None, teams=None, claims=None, scopes=None) -> Principal: + return Principal( + principal_type=PrincipalType.HUMAN, + subject="u1", + auth_method=AuthMethod.OIDC, + roles=roles or [], + teams=teams or [], + claims=claims or {}, + scopes=scopes or [], + ) + + +def _manager() -> Principal: + return _principal(claims={"roles": ["manager"]}) + + +def _eng() -> Principal: + return _principal(teams=[TeamIdentity(id="t1", name="eng")]) + + +def _engine_from(tmp_path, policy_text: str) -> ABACEngine: + path = tmp_path / "abac.yaml" + path.write_text(policy_text) + return ABACEngine(policy_path=str(path)) + + +@pytest.fixture +def engine(tmp_path) -> ABACEngine: + return _engine_from(tmp_path, POLICIES) + + +# --------------------------------------------------------------------------- # +# Model allowlist policy: each attribute is an independent gate +# --------------------------------------------------------------------------- # + + +def test_manager_allowed_model_is_permitted(engine): + assert engine.decide( + _manager(), + ProtectedResource(endpoint="/v1/messages", model="gpt-4o", method="POST"), + ) + + +def test_manager_unlisted_model_is_denied(engine): + # set membership, not just "manager is allowed"; also the exact case that the + # CSV FileAdapter silently *allowed* (quotes leaking into the eval'd rule) + assert not engine.decide( + _manager(), + ProtectedResource( + endpoint="/v1/messages", model="claude-opus-4-8", method="POST" + ), + ) + + +def test_non_manager_is_denied_allowed_model(engine): + assert not engine.decide( + _principal(), + ProtectedResource(endpoint="/v1/messages", model="gpt-4o", method="POST"), + ) + + +def test_manager_wrong_action_is_denied(engine): + assert not engine.decide( + _manager(), + ProtectedResource(endpoint="/v1/messages", model="gpt-4o", method="GET"), + ) + + +def test_manager_wrong_endpoint_is_denied(engine): + assert not engine.decide( + _manager(), + ProtectedResource( + endpoint="/v1/chat/completions", model="gpt-4o", method="POST" + ), + ) + + +def test_manager_without_model_is_denied(engine): + # a model-restricted rule must not match a request that carries no model + assert not engine.decide( + _manager(), ProtectedResource(endpoint="/v1/messages", method="POST") + ) + + +# --------------------------------------------------------------------------- # +# MCP tool allowlist policy: team membership x server x tool x action +# --------------------------------------------------------------------------- # + + +def test_eng_listed_tool_is_permitted(engine): + assert engine.decide( + _eng(), + ProtectedResource(mcp_server="github", mcp_tool="search", method="POST"), + ) + assert engine.decide( + _eng(), + ProtectedResource(mcp_server="github", mcp_tool="read_file", method="GET"), + ) + + +def test_eng_unlisted_tool_is_denied(engine): + assert not engine.decide( + _eng(), + ProtectedResource(mcp_server="github", mcp_tool="delete_repo", method="POST"), + ) + + +def test_eng_wrong_server_is_denied(engine): + assert not engine.decide( + _eng(), + ProtectedResource(mcp_server="gitlab", mcp_tool="search", method="POST"), + ) + + +def test_non_eng_team_is_denied(engine): + sales = _principal(teams=[TeamIdentity(id="t2", name="sales")]) + assert not engine.decide( + sales, + ProtectedResource(mcp_server="github", mcp_tool="search", method="POST"), + ) + + +def test_unrelated_policy_row_does_not_poison_decision(engine): + # the manager request must be allowed even though the eng (team) policy row + # is also evaluated against a principal with no teams + assert engine.decide( + _manager(), + ProtectedResource(endpoint="/v1/messages", model="gpt-4o", method="POST"), + ) + + +# --------------------------------------------------------------------------- # +# Default deny and claim-access safety +# --------------------------------------------------------------------------- # + + +def test_no_policy_denies_everything(): + engine = ABACEngine() + assert not engine.decide( + _manager(), + ProtectedResource(endpoint="/v1/messages", model="gpt-4o", method="POST"), + ) + + +def test_missing_claim_does_not_raise_and_denies(tmp_path): + policy = """ +policies: + - sub_rule: "r_sub.claims['department'] == 'eng'" + obj_rule: "r_obj.endpoint == '/reports'" + act: "GET" +""" + engine = _engine_from(tmp_path, policy) + assert engine.decide( + _principal(claims={"department": "eng"}), + ProtectedResource(endpoint="/reports", method="GET"), + ) + # principal lacking the claim: must deny, not raise + assert not engine.decide( + _principal(), ProtectedResource(endpoint="/reports", method="GET") + ) + + +def test_malformed_policy_entry_fails_fast(tmp_path): + bad = tmp_path / "abac.yaml" + bad.write_text('policies:\n - sub_rule: "1 == 1"\n act: GET\n') + with pytest.raises(ValueError): + ABACEngine(policy_path=str(bad)) + + +def test_rule_evaluation_error_fails_closed(tmp_path): + # an operator referencing an attribute the resource does not carry must deny, + # not surface the exception + policy = """ +policies: + - sub_rule: "True" + obj_rule: "r_obj.nonexistent == 1" + act: "GET" +""" + engine = _engine_from(tmp_path, policy) + assert not engine.decide( + _principal(), ProtectedResource(endpoint="/x", method="GET") + ) + + +# --------------------------------------------------------------------------- # +# Authorizer protocol compatibility (path/method enforce, flat has_any_role) +# --------------------------------------------------------------------------- # + + +def test_enforce_path_method_policy(tmp_path): + policy = """ +policies: + - sub_rule: "'admin' in r_sub.roles" + obj_rule: "r_obj.endpoint == '/health'" + act: "GET" +""" + engine = _engine_from(tmp_path, policy) + admin = _principal(claims={"roles": ["admin"]}) + assert engine.enforce(admin, "/health", "GET") + assert not engine.enforce(admin, "/health", "POST") + assert not engine.enforce(admin, "/secrets", "GET") + assert not engine.enforce(_principal(), "/health", "GET") + + +def test_has_any_role_is_flat_membership(tmp_path): + engine = _engine_from(tmp_path, POLICIES) + assert engine.has_any_role(_principal(roles=[Role.ORG_ADMIN]), (Role.ORG_ADMIN,)) + # no hierarchy: platform_admin does not imply org_admin here (use RBACEngine) + assert not engine.has_any_role( + _principal(roles=[Role.PLATFORM_ADMIN]), (Role.ORG_ADMIN,) + ) + assert not engine.has_any_role(_principal(), (Role.TEAM_MEMBER,)) From fad066cd302967d41773c158ae819693c8501751 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 18:35:10 -0700 Subject: [PATCH 44/51] refactor(auth_v2): move has_required_scopes onto Principal Scope checking is identity state, so it belongs on the Principal rather than a standalone authorization/scopes.py helper. Callers now use principal.has_required_scopes(security_scopes). --- litellm/proxy/auth_v2/authorization/scopes.py | 12 ------------ litellm/proxy/auth_v2/models.py | 8 +++++++- litellm/proxy/auth_v2/security.py | 15 ++++++++++----- tests/test_litellm/proxy/auth_v2/test_rbac.py | 12 ++++++------ 4 files changed, 23 insertions(+), 24 deletions(-) delete mode 100644 litellm/proxy/auth_v2/authorization/scopes.py diff --git a/litellm/proxy/auth_v2/authorization/scopes.py b/litellm/proxy/auth_v2/authorization/scopes.py deleted file mode 100644 index f761b337d3a5..000000000000 --- a/litellm/proxy/auth_v2/authorization/scopes.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from fastapi.security import SecurityScopes - -if TYPE_CHECKING: - from litellm.proxy.auth_v2.models import Principal - - -def has_required_scopes(security_scopes: SecurityScopes, principal: "Principal") -> bool: - return set(security_scopes.scopes).issubset(set(principal.scopes)) diff --git a/litellm/proxy/auth_v2/models.py b/litellm/proxy/auth_v2/models.py index 046442b0ec91..c94417489a50 100644 --- a/litellm/proxy/auth_v2/models.py +++ b/litellm/proxy/auth_v2/models.py @@ -1,13 +1,16 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, Field from litellm.proxy.auth_v2.authorization import Role +if TYPE_CHECKING: + from fastapi.security import SecurityScopes + _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} @@ -123,3 +126,6 @@ class Principal(BaseModel): credential_ref: CredentialRef = Field(default_factory=CredentialRef) network: NetworkContext = Field(default_factory=NetworkContext) claims: Dict[str, Any] = Field(default_factory=dict) + + def has_required_scopes(self, security_scopes: SecurityScopes) -> bool: + return set(security_scopes.scopes).issubset(self.scopes) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index 87f15da42511..f713372eabf1 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -18,7 +18,6 @@ Authorizer, RBACEngine, Role, - has_required_scopes, ) from litellm.proxy.auth_v2.authenticators.session import SessionAuthenticator from litellm.proxy.auth_v2.resolvers import IdentityResolver @@ -38,7 +37,9 @@ ) -def _open_session_store(namespace: str, *, default_ttl: int) -> SessionStore[SessionValue]: +def _open_session_store( + namespace: str, *, default_ttl: int +) -> SessionStore[SessionValue]: """Build the session/login-state store for ``namespace``. Uses Redis when configured via the environment (required so state is shared @@ -102,7 +103,9 @@ def __init__( chain.append(SessionAuthenticator(config.session.cookie, self.session_store)) self.authenticators = chain - async def principal(self, security_scopes: SecurityScopes, request: Request) -> Principal: + async def principal( + self, security_scopes: SecurityScopes, request: Request + ) -> Principal: """Resolve the caller to a Principal, enforcing scheme OR and required scopes.""" credential = None for authenticator in self.authenticators: @@ -113,8 +116,10 @@ async def principal(self, security_scopes: SecurityScopes, request: Request) -> raise errors.unauthenticated(_combined_challenge(self.authenticators)) resolved = await self.resolver.resolve(credential) - principal = resolved.model_copy(update={"network": resolve_network_context(request, self.config.network)}) - if not has_required_scopes(security_scopes, principal): + principal = resolved.model_copy( + update={"network": resolve_network_context(request, self.config.network)} + ) + if not principal.has_required_scopes(security_scopes): raise errors.insufficient_scope() return principal diff --git a/tests/test_litellm/proxy/auth_v2/test_rbac.py b/tests/test_litellm/proxy/auth_v2/test_rbac.py index 7f6ca77f4b1a..f6ddbbb67d73 100644 --- a/tests/test_litellm/proxy/auth_v2/test_rbac.py +++ b/tests/test_litellm/proxy/auth_v2/test_rbac.py @@ -3,8 +3,8 @@ import pytest from fastapi.security import SecurityScopes +from litellm.proxy.auth_v2.authorization import RBACEngine, Role from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.proxy.auth_v2.rbac import RBACEngine, Role, has_required_scopes def _principal(*, scopes=None, roles=None) -> Principal: @@ -24,17 +24,17 @@ def _principal(*, scopes=None, roles=None) -> Principal: def test_required_scopes_is_subset_check(): principal = _principal(scopes=["models:read", "chat:write", "scim:write"]) - assert has_required_scopes(SecurityScopes(["models:read"]), principal) - assert has_required_scopes(SecurityScopes(["models:read", "chat:write"]), principal) + assert principal.has_required_scopes(SecurityScopes(["models:read"])) + assert principal.has_required_scopes(SecurityScopes(["models:read", "chat:write"])) def test_missing_required_scope_fails(): principal = _principal(scopes=["models:read"]) - assert not has_required_scopes(SecurityScopes(["chat:write"]), principal) + assert not principal.has_required_scopes(SecurityScopes(["chat:write"])) def test_empty_required_scopes_always_passes(): - assert has_required_scopes(SecurityScopes([]), _principal()) + assert _principal().has_required_scopes(SecurityScopes([])) # --------------------------------------------------------------------------- # @@ -159,7 +159,7 @@ def test_act_matcher_is_anchored(tmp_path): ], ) def test_filter_claim_roles(roles, allowed, allow_platform, expected): - from litellm.proxy.auth_v2.rbac import filter_claim_roles + from litellm.proxy.auth_v2.authorization import filter_claim_roles assert filter_claim_roles(roles, allowed, allow_platform) == expected From 12d08df374c5aef2fc1eb90ab878cfb1a164fc61 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 11 Jun 2026 21:27:59 -0700 Subject: [PATCH 45/51] docs(auth_v2): add README explaining the request auth flow --- litellm/proxy/auth_v2/README.md | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 litellm/proxy/auth_v2/README.md diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md new file mode 100644 index 000000000000..4a98eadd83b0 --- /dev/null +++ b/litellm/proxy/auth_v2/README.md @@ -0,0 +1,112 @@ +# auth_v2 + +The proxy's authentication and authorization layer. A request arrives with some +credential (API key, JWT, basic auth, mTLS cert, session cookie), and this module +turns it into a `Principal` (a normalized caller identity) and decides whether that +principal is allowed to reach the route. + +Everything is consumed through FastAPI `Security()` dependencies, so routes opt in +declaratively and never call into this module by hand. + +## The two core types + +`Credential` is what an authenticator produces: a verified but un-resolved fact about +the caller ("this bearer token is valid and its subject is `alice@corp`"). It carries +the scheme, the subject, scopes/claims from the token, and, for exchangeable bearer +tokens, the raw token for downstream RFC 8693 token exchange. + +`Principal` is what the route handler receives: a normalized identity with the user, +organization, teams, roles and scopes filled in. It holds identity only, no budget or +policy state. Scope checking lives here as `Principal.has_required_scopes`. + +The split matters: authentication proves the credential, resolution turns the proven +credential into a known identity, and only then can authorization run. + +## How a request flows + +A route declares one of three dependencies, all rooted at the same `AuthSecurity` +instance. The intended integration is to build that instance at startup and expose it as +`request.app.state.auth_v2` so routes reach it at request time: + +``` +Security(auth.principal) -> authenticated caller, scopes enforced +Security(auth.require_roles(...)) -> the above, plus a role gate +Security(auth.require_permission(obj,act))-> the above, plus a Casbin permission gate +``` + +`require_roles` and `require_permission` both depend on `principal`, so the steps below +always run first. + +1. Authenticate. `AuthSecurity.principal` walks the authenticator chain in + `config.scheme_order` and takes the first one that returns a `Credential` (scheme OR, + not AND). The chain always ends with the session-cookie authenticator. If every + authenticator declines, it raises `401` with a combined `WWW-Authenticate` challenge + built from each scheme. + +2. Resolve identity. The winning `Credential` is handed to the configured + `IdentityResolver`. The DB resolver looks the subject up in the proxy's Prisma tables + (key object, user, teams, org) and builds the `Principal`. A blocked key or unknown + subject raises `401`/`403` here, before any route logic runs. + +3. Attach network context. The client IP and host are resolved (trusted-proxy aware, see + `network.py`) and copied onto the principal. + +4. Enforce scopes. The scopes declared on the `Security()` dependency must be a subset of + the principal's scopes (`principal.has_required_scopes`). A miss raises `403` + insufficient_scope. An empty scope requirement always passes. + +5. Authorize (only for the role/permission variants). + - `require_roles(*roles)` calls `authorizer.has_any_role`, which is hierarchy-aware + (a `platform_admin` satisfies an `org_admin` gate via the Casbin role graph). A miss + raises `403` forbidden_role. + - `require_permission(obj, act)` calls `authorizer.enforce`, matching the principal's + roles against the Casbin policy (`keyMatch` on the path, anchored `regexMatch` on the + method). A miss raises `403` forbidden_permission. + +The resolved `Principal` is then injected into the route handler. + +``` +request + -> authenticators (scheme_order, first match wins) [401 if none] + -> resolver.resolve(credential) -> Principal [401/403 on bad/blocked identity] + -> resolve_network_context + -> principal.has_required_scopes(scopes) [403 insufficient_scope] + -> require_roles / require_permission (optional) [403 forbidden_*] + -> route handler(principal) +``` + +## Where each piece lives + +`security.py` is the orchestrator described above; it is the only stateful object and is +built once at the composition root. + +`authenticators/` holds one authenticator per scheme behind the `Authenticator` protocol +(`authenticate -> Optional[Credential]`, plus a `challenge`). `build_authenticators` +constructs and orders them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared +via `JWTVerifier`. + +`resolvers/` holds the `IdentityResolver` / `IdentityStore` protocols and their +implementations (`DbIdentityStore` against Prisma, an in-memory store for tests). The +store also handles SCIM user/group provisioning so a provisioned user is immediately +resolvable. + +`authorization/` holds the `Authorizer` protocol and its implementations: `RBACEngine` +(Casbin role hierarchy and policy) and `ABACEngine`. `Role` and the JWT/OIDC/SAML role +allowlist gate `filter_claim_roles` live here too. Scope checking does not, it is a +method on `Principal`. + +`sessions/` holds the session and OAuth-transaction stores (in-memory or Redis, chosen +from the environment), used by the cookie authenticator and the OIDC/SAML login flows. + +`config.py` defines `AuthConfig` and the per-scheme config models. `models.py` defines +`Credential`, `Principal` and the identity sub-models. `network.py` resolves the client +IP with trusted-proxy handling. `errors.py` maps every failure to an `HTTPException` with +the right status and challenge header. + +## Adding things + +A new credential scheme is a new `Authenticator` plus a branch in `build_authenticators`. +A new identity backend is a new `IdentityStore`. A new authorization method (ReBAC, an +external PDP) is a new `Authorizer` passed as `AuthSecurity(..., authorizer=...)`. +Dependencies are injected at construction, so each of these is unit-testable with a fake +in place of the real backend. From 97186ba754b6e58eaa6b97ddef155f476bdac841 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 12 Jun 2026 21:02:37 -0700 Subject: [PATCH 46/51] fix: cleanup --- litellm/proxy/auth_v2/README.md | 39 +- litellm/proxy/auth_v2/__init__.py | 3 +- .../proxy/auth_v2/authenticators/__init__.py | 13 +- litellm/proxy/auth_v2/authenticators/base.py | 52 +- litellm/proxy/auth_v2/authenticators/http.py | 27 +- litellm/proxy/auth_v2/authenticators/key.py | 18 +- litellm/proxy/auth_v2/authenticators/mtls.py | 32 +- litellm/proxy/auth_v2/authenticators/oauth.py | 20 +- litellm/proxy/auth_v2/authenticators/oidc.py | 21 +- .../proxy/auth_v2/authenticators/session.py | 11 +- litellm/proxy/auth_v2/network.py | 22 +- litellm/proxy/auth_v2/resolvers/__init__.py | 2 - litellm/proxy/auth_v2/resolvers/base.py | 12 +- litellm/proxy/auth_v2/resolvers/memory.py | 165 ------ litellm/proxy/auth_v2/security.py | 37 +- .../proxy/auth_v2/test_authenticators.py | 80 +-- .../test_litellm/proxy/auth_v2/test_models.py | 2 +- tests/test_litellm/proxy/auth_v2/test_oidc.py | 95 ---- .../proxy/auth_v2/test_resolver.py | 222 +++----- tests/test_litellm/proxy/auth_v2/test_saml.py | 517 ------------------ tests/test_litellm/proxy/auth_v2/test_scim.py | 310 ----------- .../proxy/auth_v2/test_security.py | 230 +++++++- 22 files changed, 580 insertions(+), 1350 deletions(-) delete mode 100644 litellm/proxy/auth_v2/resolvers/memory.py delete mode 100644 tests/test_litellm/proxy/auth_v2/test_oidc.py delete mode 100644 tests/test_litellm/proxy/auth_v2/test_saml.py delete mode 100644 tests/test_litellm/proxy/auth_v2/test_scim.py diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index 4a98eadd83b0..2c3c4818d143 100644 --- a/litellm/proxy/auth_v2/README.md +++ b/litellm/proxy/auth_v2/README.md @@ -37,19 +37,20 @@ Security(auth.require_permission(obj,act))-> the above, plus a Casbin permission `require_roles` and `require_permission` both depend on `principal`, so the steps below always run first. -1. Authenticate. `AuthSecurity.principal` walks the authenticator chain in - `config.scheme_order` and takes the first one that returns a `Credential` (scheme OR, - not AND). The chain always ends with the session-cookie authenticator. If every - authenticator declines, it raises `401` with a combined `WWW-Authenticate` challenge - built from each scheme. - -2. Resolve identity. The winning `Credential` is handed to the configured - `IdentityResolver`. The DB resolver looks the subject up in the proxy's Prisma tables - (key object, user, teams, org) and builds the `Principal`. A blocked key or unknown - subject raises `401`/`403` here, before any route logic runs. +1. Authenticate. Each authenticator advertises the carrier it reads (an `Authorization` + scheme, a header, the session cookie, or a client certificate), and + `AuthSecurity.principal` routes the request to the single authenticator whose carrier + is present, breaking ties on a shared carrier (the bearer schemes) by `config.scheme_order`. + If no carrier matches, or the selected authenticator rejects the credential, it raises + `401` with a combined `WWW-Authenticate` challenge built from each scheme. + +2. Resolve identity. The verified `Credential` is handed to the configured + `IdentityResolver`, which builds the `Principal`. The DB resolver looks the subject up in + the proxy's Prisma tables (key object, user, teams, org). A blocked key or unknown subject + raises `401`/`403` here, before any route logic runs. 3. Attach network context. The client IP and host are resolved (trusted-proxy aware, see - `network.py`) and copied onto the principal. + `network.py`) and set on the principal. 4. Enforce scopes. The scopes declared on the `Security()` dependency must be a subset of the principal's scopes (`principal.has_required_scopes`). A miss raises `403` @@ -67,11 +68,11 @@ The resolved `Principal` is then injected into the route handler. ``` request - -> authenticators (scheme_order, first match wins) [401 if none] - -> resolver.resolve(credential) -> Principal [401/403 on bad/blocked identity] - -> resolve_network_context - -> principal.has_required_scopes(scopes) [403 insufficient_scope] - -> require_roles / require_permission (optional) [403 forbidden_*] + -> authenticator dispatch (carrier match, scheme_order tiebreak) [401 if none] + -> resolver.resolve(credential) -> Principal [401/403 on bad/blocked identity] + -> principal.network = resolve_network_context(...) + -> principal.has_required_scopes(scopes) [403 insufficient_scope] + -> require_roles / require_permission (optional) [403 forbidden_*] -> route handler(principal) ``` @@ -81,9 +82,9 @@ request built once at the composition root. `authenticators/` holds one authenticator per scheme behind the `Authenticator` protocol -(`authenticate -> Optional[Credential]`, plus a `challenge`). `build_authenticators` -constructs and orders them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared -via `JWTVerifier`. +(`authenticate -> Optional[Credential]`, a `challenge`, and `carriers` so `security.py` can +dispatch to it by where its credential lives). `build_authenticators` constructs and orders +them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared via `JWTVerifier`. `resolvers/` holds the `IdentityResolver` / `IdentityStore` protocols and their implementations (`DbIdentityStore` against Prisma, an in-memory store for tests). The diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 5a744d71210c..9c28253c9341 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -11,7 +11,7 @@ ) from litellm.proxy.auth_v2.models import Principal from litellm.proxy.auth_v2.authorization import Role -from litellm.proxy.auth_v2.resolvers import IdentityResolver, InMemoryIdentityStore, ProvisioningStore +from litellm.proxy.auth_v2.resolvers import IdentityResolver, ProvisioningStore from litellm.proxy.auth_v2.security import AuthSecurity __all__ = [ @@ -21,7 +21,6 @@ "Role", "IdentityResolver", "ProvisioningStore", - "InMemoryIdentityStore", "ApiKeySchemeConfig", "HttpBasicConfig", "OIDCProviderConfig", diff --git a/litellm/proxy/auth_v2/authenticators/__init__.py b/litellm/proxy/auth_v2/authenticators/__init__.py index 2944e20757e0..006f247c76d5 100644 --- a/litellm/proxy/auth_v2/authenticators/__init__.py +++ b/litellm/proxy/auth_v2/authenticators/__init__.py @@ -1,6 +1,13 @@ -from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) from litellm.proxy.auth_v2.authenticators.config import build_authenticators -from litellm.proxy.auth_v2.authenticators.http import HttpAuthenticator, hash_basic_password +from litellm.proxy.auth_v2.authenticators.http import ( + HttpAuthenticator, + hash_basic_password, +) from litellm.proxy.auth_v2.authenticators.key import APIKeyAuthenticator from litellm.proxy.auth_v2.authenticators.mtls import MutualTLSAuthenticator from litellm.proxy.auth_v2.authenticators.oauth import OAuth2Authenticator @@ -10,6 +17,8 @@ __all__ = [ "Authenticator", + "Carrier", + "CredentialLocation", "BasicAuthVerifier", "JWTVerifier", "APIKeyAuthenticator", diff --git a/litellm/proxy/auth_v2/authenticators/base.py b/litellm/proxy/auth_v2/authenticators/base.py index a4e2377e070b..5a9084f2c0f3 100644 --- a/litellm/proxy/auth_v2/authenticators/base.py +++ b/litellm/proxy/auth_v2/authenticators/base.py @@ -1,14 +1,64 @@ from __future__ import annotations -from typing import Optional, Protocol, runtime_checkable +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Protocol, Sequence, Tuple, runtime_checkable from fastapi import Request from litellm.proxy.auth_v2.models import Credential +from litellm.proxy.auth_v2.network import ip_in_cidrs + + +def verified_client_cert_name(request: Request) -> Optional[str]: + name = request.scope.get("extensions", {}).get("tls", {}).get("client_cert_name") + return name or None + + +class CredentialLocation(str, Enum): + AUTHORIZATION_SCHEME = "authorization_scheme" + HEADER = "header" + COOKIE = "cookie" + CLIENT_CERTIFICATE = "client_certificate" + + +@dataclass(frozen=True) +class Carrier: + """Where an authenticator reads its credential from. + + Each authenticator advertises the single carrier it reads, so the security + layer can route a request straight to the one authenticator whose credential + is present instead of trying each in turn. ``present`` mirrors that + authenticator's accept condition exactly, so the chosen authenticator never + declines and shadows a lower-priority credential. + """ + + location: CredentialLocation + name: str = "" + trusted_proxy_cidrs: Tuple[str, ...] = field(default=()) + + def present(self, request: Request) -> bool: + if self.location is CredentialLocation.AUTHORIZATION_SCHEME: + scheme, _, value = request.headers.get("authorization", "").partition(" ") + return scheme.lower() == self.name and bool(value) + if self.location is CredentialLocation.HEADER: + return bool(request.headers.get(self.name)) + if self.location is CredentialLocation.COOKIE: + return self.name in request.cookies + if verified_client_cert_name(request) is not None: + return True + peer = request.client.host if request.client else None + return ( + bool(self.name) + and ip_in_cidrs(peer, self.trusted_proxy_cidrs) + and bool(request.headers.get(self.name)) + ) @runtime_checkable class Authenticator(Protocol): async def authenticate(self, request: Request) -> Optional[Credential]: ... + def carriers(self) -> Sequence[Carrier]: ... + def challenge(self) -> str: ... diff --git a/litellm/proxy/auth_v2/authenticators/http.py b/litellm/proxy/auth_v2/authenticators/http.py index 5bdb8dbf0c2d..08908c95f9bd 100644 --- a/litellm/proxy/auth_v2/authenticators/http.py +++ b/litellm/proxy/auth_v2/authenticators/http.py @@ -4,23 +4,32 @@ import binascii import hashlib import secrets -from typing import List, Optional +from typing import List, Optional, Sequence from fastapi import Request from litellm.proxy.auth_v2 import errors from litellm.proxy.auth_v2.config import HttpBasicConfig from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType -from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) from litellm.proxy.auth_v2.authenticators.types import BasicAuthVerifier -from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, authenticate_bearer_jwt +from litellm.proxy.auth_v2.authenticators.utils import ( + JWTVerifier, + authenticate_bearer_jwt, +) _PBKDF2_ITERATIONS = 600_000 def hash_basic_password(password: str, salt: Optional[str] = None) -> str: salt = salt or secrets.token_hex(16) - digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS).hex() + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS + ).hex() return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest}" @@ -42,7 +51,9 @@ async def authenticate(self, request: Request) -> Optional[Credential]: scheme, _, value = header.partition(" ") scheme_lower = scheme.lower() if scheme_lower == "bearer" and value: - return await authenticate_bearer_jwt(value, self._verifiers, SecuritySchemeType.HTTP, AuthMethod.BEARER_JWT) + return await authenticate_bearer_jwt( + value, self._verifiers, SecuritySchemeType.HTTP, AuthMethod.BEARER_JWT + ) if scheme_lower == "basic" and self._basic.enabled and value: return self._verify_basic(value) return None @@ -67,6 +78,12 @@ def _verify_basic(self, value: str) -> Credential: subject=username, ) + def carriers(self) -> Sequence[Carrier]: + schemes = [Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "bearer")] + if self._basic.enabled: + schemes.append(Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "basic")) + return tuple(schemes) + def challenge(self) -> str: bearer = errors.bearer_challenge() if self._basic.enabled: diff --git a/litellm/proxy/auth_v2/authenticators/key.py b/litellm/proxy/auth_v2/authenticators/key.py index 02859eb72bd1..c1adc9bce734 100644 --- a/litellm/proxy/auth_v2/authenticators/key.py +++ b/litellm/proxy/auth_v2/authenticators/key.py @@ -1,12 +1,21 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence from fastapi import Request from litellm.proxy.auth_v2.config import ApiKeySchemeConfig -from litellm.proxy.auth_v2.models import AuthMethod, Credential, CredentialRef, SecuritySchemeType -from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Credential, + CredentialRef, + SecuritySchemeType, +) +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) class APIKeyAuthenticator(Authenticator): @@ -25,5 +34,8 @@ async def authenticate(self, request: Request) -> Optional[Credential]: claims={"_raw_api_key": raw}, ) + def carriers(self) -> Sequence[Carrier]: + return (Carrier(CredentialLocation.HEADER, self._header_name),) + def challenge(self) -> str: return "" diff --git a/litellm/proxy/auth_v2/authenticators/mtls.py b/litellm/proxy/auth_v2/authenticators/mtls.py index c460d40bbe45..814fd8807ce3 100644 --- a/litellm/proxy/auth_v2/authenticators/mtls.py +++ b/litellm/proxy/auth_v2/authenticators/mtls.py @@ -1,13 +1,23 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence from fastapi import Request from litellm.proxy.auth_v2.config import MutualTLSConfig, TrustedProxyConfig -from litellm.proxy.auth_v2.models import AuthMethod, ClientCertificate, Credential, SecuritySchemeType -from litellm.proxy.auth_v2.network import ip_in_trusted_proxies -from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.models import ( + AuthMethod, + ClientCertificate, + Credential, + SecuritySchemeType, +) +from litellm.proxy.auth_v2.network import ip_in_cidrs +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, + verified_client_cert_name, +) class MutualTLSAuthenticator(Authenticator): @@ -27,17 +37,25 @@ async def authenticate(self, request: Request) -> Optional[Credential]: ) def _read_client_cert(self, request: Request) -> Optional[ClientCertificate]: - tls = request.scope.get("extensions", {}).get("tls", {}) - verified_dn = tls.get("client_cert_name") + verified_dn = verified_client_cert_name(request) if verified_dn: return ClientCertificate(subject_dn=verified_dn) if self._config.forwarded_subject_header: peer = request.client.host if request.client else None - if not ip_in_trusted_proxies(peer, self._network): + if not ip_in_cidrs(peer, self._network.trusted_proxy_cidrs): return None dn = request.headers.get(self._config.forwarded_subject_header) return ClientCertificate(subject_dn=dn) if dn else None return None + def carriers(self) -> Sequence[Carrier]: + return ( + Carrier( + CredentialLocation.CLIENT_CERTIFICATE, + self._config.forwarded_subject_header or "", + tuple(self._network.trusted_proxy_cidrs), + ), + ) + def challenge(self) -> str: return "" diff --git a/litellm/proxy/auth_v2/authenticators/oauth.py b/litellm/proxy/auth_v2/authenticators/oauth.py index 275471dd45c1..f9e0383e00ec 100644 --- a/litellm/proxy/auth_v2/authenticators/oauth.py +++ b/litellm/proxy/auth_v2/authenticators/oauth.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from typing import List, Optional +from typing import List, Optional, Sequence from fastapi import Request @@ -11,8 +11,15 @@ from litellm.proxy.auth_v2 import errors from litellm.proxy.auth_v2.config import OAuth2IntrospectionConfig from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType -from litellm.proxy.auth_v2.authenticators.base import Authenticator -from litellm.proxy.auth_v2.authenticators.types import IntrospectionClient, IntrospectionClientFactory +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) +from litellm.proxy.auth_v2.authenticators.types import ( + IntrospectionClient, + IntrospectionClientFactory, +) from litellm.proxy.auth_v2.authenticators.utils import ( JWTVerifier, authenticate_bearer_jwt, @@ -57,7 +64,9 @@ async def authenticate(self, request: Request) -> Optional[Credential]: async def _introspect(self, token: str) -> Credential: config = self._introspection assert config is not None - basic = base64.b64encode(f"{config.client_id}:{config.client_secret.get_secret_value()}".encode()).decode() + basic = base64.b64encode( + f"{config.client_id}:{config.client_secret.get_secret_value()}".encode() + ).decode() client = self._client_factory() response = await client.post( str(config.introspection_endpoint), @@ -90,5 +99,8 @@ async def _introspect(self, token: str) -> Credential: subject_token=token, ) + def carriers(self) -> Sequence[Carrier]: + return (Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "bearer"),) + def challenge(self) -> str: return errors.bearer_challenge() diff --git a/litellm/proxy/auth_v2/authenticators/oidc.py b/litellm/proxy/auth_v2/authenticators/oidc.py index 53751eb0dedf..df9b5950de77 100644 --- a/litellm/proxy/auth_v2/authenticators/oidc.py +++ b/litellm/proxy/auth_v2/authenticators/oidc.py @@ -1,13 +1,21 @@ from __future__ import annotations -from typing import List, Optional +from typing import List, Optional, Sequence from fastapi import Request from litellm.proxy.auth_v2 import errors from litellm.proxy.auth_v2.models import AuthMethod, Credential, SecuritySchemeType -from litellm.proxy.auth_v2.authenticators.base import Authenticator -from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, authenticate_bearer_jwt, extract_bearer +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) +from litellm.proxy.auth_v2.authenticators.utils import ( + JWTVerifier, + authenticate_bearer_jwt, + extract_bearer, +) class OIDCAuthenticator(Authenticator): @@ -18,7 +26,12 @@ async def authenticate(self, request: Request) -> Optional[Credential]: token = extract_bearer(request) if token is None: return None - return await authenticate_bearer_jwt(token, self._verifiers, SecuritySchemeType.OPENID_CONNECT, AuthMethod.OIDC) + return await authenticate_bearer_jwt( + token, self._verifiers, SecuritySchemeType.OPENID_CONNECT, AuthMethod.OIDC + ) + + def carriers(self) -> Sequence[Carrier]: + return (Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "bearer"),) def challenge(self) -> str: return errors.bearer_challenge() diff --git a/litellm/proxy/auth_v2/authenticators/session.py b/litellm/proxy/auth_v2/authenticators/session.py index 907e826409be..81739d5a3667 100644 --- a/litellm/proxy/auth_v2/authenticators/session.py +++ b/litellm/proxy/auth_v2/authenticators/session.py @@ -1,10 +1,14 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence from fastapi import Request -from litellm.proxy.auth_v2.authenticators.base import Authenticator +from litellm.proxy.auth_v2.authenticators.base import ( + Authenticator, + Carrier, + CredentialLocation, +) from litellm.proxy.auth_v2.models import ( AuthMethod, Credential, @@ -36,5 +40,8 @@ async def authenticate(self, request: Request) -> Optional[Credential]: credential_ref=CredentialRef(token_id=session_id), ) + def carriers(self) -> Sequence[Carrier]: + return (Carrier(CredentialLocation.COOKIE, self._cookie_name),) + def challenge(self) -> str: return "" diff --git a/litellm/proxy/auth_v2/network.py b/litellm/proxy/auth_v2/network.py index 2ae43dd22922..d8ff22348ba8 100644 --- a/litellm/proxy/auth_v2/network.py +++ b/litellm/proxy/auth_v2/network.py @@ -1,7 +1,7 @@ from __future__ import annotations import ipaddress -from typing import List, Optional, Tuple +from typing import Optional, Sequence, Tuple from fastapi import Request @@ -17,7 +17,7 @@ def _is_valid_ip(value: str) -> bool: return False -def _ip_in_cidrs(ip: Optional[str], cidrs: List[str]) -> bool: +def ip_in_cidrs(ip: Optional[str], cidrs: Sequence[str]) -> bool: if not ip or not _is_valid_ip(ip): return False address = ipaddress.ip_address(ip) @@ -30,23 +30,25 @@ def _ip_in_cidrs(ip: Optional[str], cidrs: List[str]) -> bool: return False -def ip_in_trusted_proxies(ip: Optional[str], config: TrustedProxyConfig) -> bool: - return _ip_in_cidrs(ip, config.trusted_proxy_cidrs) - - -def resolve_client_ip(request: Request, config: TrustedProxyConfig) -> Tuple[Optional[str], bool]: +def resolve_client_ip( + request: Request, config: TrustedProxyConfig +) -> Tuple[Optional[str], bool]: peer = request.client.host if request.client else None - if not config.use_forwarded_for or not _ip_in_cidrs(peer, config.trusted_proxy_cidrs): + if not config.use_forwarded_for or not ip_in_cidrs( + peer, config.trusted_proxy_cidrs + ): return peer, False forwarded = request.headers.get("x-forwarded-for", "") hops = [h.strip() for h in forwarded.split(",") if h.strip()] for hop in reversed(hops): - if not _ip_in_cidrs(hop, config.trusted_proxy_cidrs) and _is_valid_ip(hop): + if not ip_in_cidrs(hop, config.trusted_proxy_cidrs) and _is_valid_ip(hop): return hop, True return peer, True -def resolve_network_context(request: Request, config: TrustedProxyConfig) -> NetworkContext: +def resolve_network_context( + request: Request, config: TrustedProxyConfig +) -> NetworkContext: ip, via_proxy = resolve_client_ip(request, config) return NetworkContext( client_ip=ip, diff --git a/litellm/proxy/auth_v2/resolvers/__init__.py b/litellm/proxy/auth_v2/resolvers/__init__.py index 09959cca19d9..546366a1b947 100644 --- a/litellm/proxy/auth_v2/resolvers/__init__.py +++ b/litellm/proxy/auth_v2/resolvers/__init__.py @@ -3,7 +3,6 @@ IdentityStore, ProvisioningStore, ) -from litellm.proxy.auth_v2.resolvers.memory import InMemoryIdentityStore # DbIdentityStore is intentionally not re-exported here: it pulls in the v1 # proxy DB machinery (auth_checks, repositories). Import it directly from @@ -13,5 +12,4 @@ "IdentityResolver", "ProvisioningStore", "IdentityStore", - "InMemoryIdentityStore", ] diff --git a/litellm/proxy/auth_v2/resolvers/base.py b/litellm/proxy/auth_v2/resolvers/base.py index 6227d38b1694..046f7f753042 100644 --- a/litellm/proxy/auth_v2/resolvers/base.py +++ b/litellm/proxy/auth_v2/resolvers/base.py @@ -10,7 +10,17 @@ @runtime_checkable class IdentityResolver(Protocol): - async def resolve(self, credential: Credential) -> Principal: ... + async def resolve(self, credential: Credential) -> Principal: + """Resolve a verified credential to a Principal. + + Must return a freshly constructed Principal, never a cached or shared + instance. The caller stamps request-scoped state (the network context) + onto the returned object, so handing back a shared one would leak that + state across concurrent requests for the same identity. Cache the + underlying identity lookups (as the DB resolver does), not the assembled + Principal. + """ + ... @runtime_checkable diff --git a/litellm/proxy/auth_v2/resolvers/memory.py b/litellm/proxy/auth_v2/resolvers/memory.py deleted file mode 100644 index ccc37094044a..000000000000 --- a/litellm/proxy/auth_v2/resolvers/memory.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import uuid -from typing import Any, Dict, List, Optional - -from scim2_models import Group as ScimGroup -from scim2_models import User as ScimUser - -from litellm.proxy.auth_v2 import errors -from litellm.proxy.auth_v2.models import ( - AuthMethod, - Credential, - Principal, - PrincipalType, - TeamIdentity, - UserIdentity, -) -from litellm.proxy.auth_v2.resolvers.base import IdentityStore -from litellm.proxy.auth_v2.resolvers.utils import ( - hash_api_key, - public_claims, - roles_from_claims, -) - - -class InMemoryIdentityStore(IdentityStore): - def __init__( - self, - api_keys: Optional[Dict[str, Principal]] = None, - subjects: Optional[Dict[str, Principal]] = None, - users: Optional[Dict[str, ScimUser]] = None, - groups: Optional[Dict[str, ScimGroup]] = None, - ) -> None: - self._api_keys = api_keys or {} - self._subjects = subjects or {} - self._users = users or {} - self._groups = groups or {} - - async def resolve(self, credential: Credential) -> Principal: - if credential.method == AuthMethod.API_KEY: - principal = self._resolve_api_key(credential) - else: - principal = self._resolve_subject(credential) - self._reject_if_deactivated(principal) - return principal - - def _reject_if_deactivated(self, principal: Principal) -> None: - user = self._lookup_scim_user(principal) - if user is not None and user.active is False: - raise errors.account_disabled() - - def _resolve_teams(self, claims: Dict[str, Any]) -> List[TeamIdentity]: - groups = claims.get("groups", []) - if not isinstance(groups, list): - return [] - teams: List[TeamIdentity] = [] - for group in groups: - scim_group = self._find_group(str(group)) - if scim_group is not None: - teams.append( - TeamIdentity( - id=scim_group.id or str(group), - name=scim_group.display_name or str(group), - ) - ) - return teams - - def _find_group(self, value: str) -> Optional[ScimGroup]: - for group in self._groups.values(): - if group.id == value or group.display_name == value: - return group - return None - - def _lookup_scim_user(self, principal: Principal) -> Optional[ScimUser]: - if principal.user is None: - return None - by_id = self._users.get(principal.user.id) - if by_id is not None: - return by_id - external = principal.user.external_id - if external: - for user in self._users.values(): - if user.external_id == external: - return user - return None - - def _resolve_api_key(self, credential: Credential) -> Principal: - raw = credential.claims.get("_raw_api_key") - if not isinstance(raw, str): - raise errors.invalid_token() - principal = self._api_keys.get(hash_api_key(raw)) - if principal is None: - raise errors.invalid_token() - return principal - - def _resolve_subject(self, credential: Credential) -> Principal: - stored = self._subjects.get(f"{credential.issuer}|{credential.subject}") - if stored is not None: - return stored - return self._principal_from_claims(credential) - - def _principal_from_claims(self, credential: Credential) -> Principal: - claims = credential.claims - if credential.method == AuthMethod.MUTUAL_TLS: - return Principal( - principal_type=PrincipalType.SERVICE_ACCOUNT, - subject=credential.subject, - issuer=credential.issuer, - audience=list(credential.audience), - scopes=list(credential.scopes), - auth_method=credential.method, - credential_ref=credential.credential_ref, - claims=public_claims(claims), - ) - return Principal( - principal_type=PrincipalType.HUMAN, - subject=credential.subject, - issuer=credential.issuer, - audience=list(credential.audience), - user=UserIdentity( - id=credential.subject, - external_id=credential.subject, - email=claims.get("email"), - user_name=claims.get("preferred_username"), - display_name=claims.get("name"), - ), - teams=self._resolve_teams(claims), - roles=roles_from_claims(claims), - scopes=list(credential.scopes), - auth_method=credential.method, - credential_ref=credential.credential_ref, - claims=public_claims(claims), - ) - - async def upsert_user(self, user: ScimUser) -> ScimUser: - if not user.id: - user.id = str(uuid.uuid4()) - self._users[user.id] = user - return user - - async def get_user(self, resource_id: str) -> Optional[ScimUser]: - return self._users.get(resource_id) - - async def deactivate_user(self, resource_id: str) -> None: - user = self._users.get(resource_id) - if user is not None: - user.active = False - - async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: - return list(self._users.values()) - - async def upsert_group(self, group: ScimGroup) -> ScimGroup: - if not group.id: - group.id = str(uuid.uuid4()) - self._groups[group.id] = group - return group - - async def get_group(self, resource_id: str) -> Optional[ScimGroup]: - return self._groups.get(resource_id) - - async def delete_group(self, resource_id: str) -> None: - self._groups.pop(resource_id, None) - - async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: - return list(self._groups.values()) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index f713372eabf1..9fd3562af50c 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -1,5 +1,5 @@ import os -from typing import Annotated, Callable, List, Optional +from typing import Annotated, Callable, Dict, List, Optional from fastapi import Request, Security from fastapi.security import SecurityScopes @@ -9,6 +9,7 @@ from litellm.proxy.auth_v2.authenticators import ( Authenticator, BasicAuthVerifier, + Carrier, build_authenticators, ) from litellm.proxy.auth_v2.config import AuthConfig @@ -54,12 +55,8 @@ def _open_session_store( def _combined_challenge(authenticators: List[Authenticator]) -> str: - seen: List[str] = [] - for authenticator in authenticators: - challenge = authenticator.challenge() - if challenge and challenge not in seen: - seen.append(challenge) - return ", ".join(seen) + challenges = (authenticator.challenge() for authenticator in authenticators) + return ", ".join(dict.fromkeys(c for c in challenges if c)) class AuthSecurity: @@ -102,23 +99,31 @@ def __init__( ) chain.append(SessionAuthenticator(config.session.cookie, self.session_store)) self.authenticators = chain + self._by_carrier: Dict[Carrier, Authenticator] = {} + for authenticator in chain: + for carrier in authenticator.carriers(): + self._by_carrier.setdefault(carrier, authenticator) + + def _authenticator_for(self, request: Request) -> Optional[Authenticator]: + """The single authenticator whose credential the request carries, by scheme_order.""" + return next( + (a for carrier, a in self._by_carrier.items() if carrier.present(request)), + None, + ) async def principal( self, security_scopes: SecurityScopes, request: Request ) -> Principal: """Resolve the caller to a Principal, enforcing scheme OR and required scopes.""" - credential = None - for authenticator in self.authenticators: - credential = await authenticator.authenticate(request) - if credential is not None: - break + authenticator = self._authenticator_for(request) + if authenticator is None: + raise errors.unauthenticated(_combined_challenge(self.authenticators)) + credential = await authenticator.authenticate(request) if credential is None: raise errors.unauthenticated(_combined_challenge(self.authenticators)) - resolved = await self.resolver.resolve(credential) - principal = resolved.model_copy( - update={"network": resolve_network_context(request, self.config.network)} - ) + principal = await self.resolver.resolve(credential) + principal.network = resolve_network_context(request, self.config.network) if not principal.has_required_scopes(security_scopes): raise errors.insufficient_scope() return principal diff --git a/tests/test_litellm/proxy/auth_v2/test_authenticators.py b/tests/test_litellm/proxy/auth_v2/test_authenticators.py index 7950649c06a7..01afa8740a71 100644 --- a/tests/test_litellm/proxy/auth_v2/test_authenticators.py +++ b/tests/test_litellm/proxy/auth_v2/test_authenticators.py @@ -1,15 +1,16 @@ from __future__ import annotations import base64 -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +import hashlib +import hmac +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock import pytest from litellm.proxy.auth_v2.authenticators import ( APIKeyAuthenticator, HttpAuthenticator, - InMemoryBasicAuthStore, JWTVerifier, MutualTLSAuthenticator, OAuth2Authenticator, @@ -36,6 +37,30 @@ make_request, ) + +class _BasicAuthStore: + """A minimal in-memory BasicAuthVerifier injected into HttpAuthenticator. + + Verifies passwords against the pbkdf2_sha256$iterations$salt$digest format + produced by the production hash_basic_password helper.""" + + def __init__(self, credentials: Dict[str, str]) -> None: + self._credentials = credentials + + def verify(self, username: str, password: str) -> bool: + stored = self._credentials.get(username) + if stored is None: + return False + try: + _algorithm, iterations, salt, expected = stored.split("$") + candidate = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), int(iterations) + ).hex() + except ValueError: + return False + return hmac.compare_digest(candidate, expected) + + # --------------------------------------------------------------------------- # # JWTVerifier: every RFC 7519 check must be enforced. # --------------------------------------------------------------------------- # @@ -174,8 +199,8 @@ async def test_http_basic_disabled_ignores_basic_scheme(rsa_keypair): assert await auth.authenticate(request) is None -def _basic_store() -> InMemoryBasicAuthStore: - return InMemoryBasicAuthStore({"alice": hash_basic_password("supersecret")}) +def _basic_store() -> _BasicAuthStore: + return _BasicAuthStore({"alice": hash_basic_password("supersecret")}) async def test_http_basic_verifies_correct_credentials(rsa_keypair): @@ -253,7 +278,7 @@ def test_hash_basic_password_is_salted_and_verifiable(): assert "supersecret" not in first assert first != second # random salt per call - store = InMemoryBasicAuthStore({"alice": first}) + store = _BasicAuthStore({"alice": first}) assert store.verify("alice", "supersecret") assert not store.verify("alice", "supersecre") assert not store.verify("unknown", "supersecret") @@ -303,7 +328,7 @@ async def test_oauth2_no_bearer_returns_none(rsa_keypair): assert await _oauth2(public_key).authenticate(make_request()) is None -def _introspecting_oauth2() -> OAuth2Authenticator: +def _introspecting_oauth2(client_factory) -> OAuth2Authenticator: return OAuth2Authenticator( [], introspection=OAuth2IntrospectionConfig( @@ -312,29 +337,26 @@ def _introspecting_oauth2() -> OAuth2Authenticator: client_secret="rs-secret", subject_field="sub", ), + client_factory=client_factory, ) -def _mock_introspection_post(status_code: int, body: dict) -> AsyncMock: +def _introspection_client_factory(status_code: int, body: dict) -> MagicMock: response = MagicMock() response.status_code = status_code response.json.return_value = body - handler = MagicMock() - handler.post = AsyncMock(return_value=response) - factory = MagicMock(return_value=handler) - return factory + client = MagicMock() + client.post = AsyncMock(return_value=response) + return MagicMock(return_value=client) async def test_oauth2_opaque_token_introspects_active_to_credential(): - factory = _mock_introspection_post( + factory = _introspection_client_factory( 200, {"active": True, "sub": "svc-9", "scope": "models:read tools:run", "aud": "rs"}, ) request = make_request(headers={"authorization": "Bearer opaque-xyz"}) - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory - ): - credential = await _introspecting_oauth2().authenticate(request) + credential = await _introspecting_oauth2(factory).authenticate(request) assert credential is not None assert credential.method == AuthMethod.OAUTH2_INTROSPECTION @@ -342,33 +364,27 @@ async def test_oauth2_opaque_token_introspects_active_to_credential(): assert credential.scopes == ["models:read", "tools:run"] assert credential.audience == ["rs"] - handler = factory.return_value - _, kwargs = handler.post.call_args + client = factory.return_value + _, kwargs = client.post.call_args assert kwargs["data"] == {"token": "opaque-xyz"} expected_basic = base64.b64encode(b"rs-client:rs-secret").decode() assert kwargs["headers"]["Authorization"] == f"Basic {expected_basic}" - assert handler.post.call_args.args[0] == "https://idp.example.com/introspect" + assert client.post.call_args.args[0] == "https://idp.example.com/introspect" async def test_oauth2_introspection_inactive_token_raises(): - factory = _mock_introspection_post(200, {"active": False}) + factory = _introspection_client_factory(200, {"active": False}) request = make_request(headers={"authorization": "Bearer opaque-xyz"}) - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory - ): - with pytest.raises(AuthError) as exc: - await _introspecting_oauth2().authenticate(request) + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2(factory).authenticate(request) assert exc.value.status_code == 401 async def test_oauth2_introspection_non_200_raises(): - factory = _mock_introspection_post(500, {}) + factory = _introspection_client_factory(500, {}) request = make_request(headers={"authorization": "Bearer opaque-xyz"}) - with patch( - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory - ): - with pytest.raises(AuthError) as exc: - await _introspecting_oauth2().authenticate(request) + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2(factory).authenticate(request) assert exc.value.status_code == 401 diff --git a/tests/test_litellm/proxy/auth_v2/test_models.py b/tests/test_litellm/proxy/auth_v2/test_models.py index 4c0a5e74f9df..aa330e14b201 100644 --- a/tests/test_litellm/proxy/auth_v2/test_models.py +++ b/tests/test_litellm/proxy/auth_v2/test_models.py @@ -13,7 +13,7 @@ TeamRole, UserIdentity, ) -from litellm.proxy.auth_v2.rbac import Role +from litellm.proxy.auth_v2.authorization import Role def _credential() -> Credential: diff --git a/tests/test_litellm/proxy/auth_v2/test_oidc.py b/tests/test_litellm/proxy/auth_v2/test_oidc.py deleted file mode 100644 index 441e828788fd..000000000000 --- a/tests/test_litellm/proxy/auth_v2/test_oidc.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from litellm.proxy.auth_v2 import OIDCProviderConfig -from litellm.proxy.auth_v2.oidc.router import _provider_key, _user_from_userinfo -from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore - - -def test_userinfo_maps_standard_claims_to_scim_user(): - user = _user_from_userinfo( - { - "sub": "idp-subject-123", - "preferred_username": "dana", - "email": "dana@example.com", - "name": "Dana D", - } - ) - assert user.external_id == "idp-subject-123" - assert user.user_name == "dana" - assert user.display_name == "Dana D" - - -def test_userinfo_falls_back_to_email_when_no_preferred_username(): - user = _user_from_userinfo({"sub": "s1", "email": "eve@example.com"}) - assert user.user_name == "eve@example.com" - - -def test_provider_key_sanitizes_issuer_url(): - key = _provider_key( - OIDCProviderConfig(issuer="https://Login.Example.com/realm", audience=["x"]) - ) - assert key == "https-login-example-com-realm" - assert " " not in key - - -async def test_callback_seam_upserts_userinfo_into_store(): - store = InMemoryIdentityStore() - userinfo = { - "sub": "idp-subject-123", - "preferred_username": "dana", - "email": "dana@example.com", - "name": "Dana D", - } - # this is exactly what the OIDC callback does: map userinfo -> SCIM user -> upsert - stored = await store.upsert_user(_user_from_userinfo(userinfo)) - - assert stored.id # store assigned an id - fetched = await store.get_user(stored.id) - assert fetched is not None - assert fetched.external_id == "idp-subject-123" - assert fetched.user_name == "dana" - - -async def _oidc_login_session_roles(userinfo, provider): - # mirror the callback's identity build: map userinfo, gate roles, store a session, - # then authenticate + resolve through the same seam a request would - from litellm.proxy.auth_v2.authenticators import _apply_role_policy - from litellm.proxy.auth_v2.oidc.router import _mapped_claims - from litellm.proxy.auth_v2.session import SessionAuthenticator, SessionStore - - from auth_v2_helpers import make_request - - claims = _mapped_claims(userinfo) - _apply_role_policy(claims, provider) - store = SessionStore() - sid = store.create_session( - {"method": "oidc", "subject": userinfo["sub"], "claims": claims} - ) - authenticator = SessionAuthenticator("litellm_session", store) - credential = await authenticator.authenticate( - make_request(cookies={"litellm_session": sid}) - ) - principal = await InMemoryIdentityStore().resolve(credential) - return [role.value for role in principal.roles] - - -async def test_oidc_login_platform_role_denied_by_default(): - provider = OIDCProviderConfig(issuer="https://idp.example.com", audience=["x"]) - userinfo = { - "sub": "u", - "email": "e@x.com", - "roles": ["platform_admin", "org_admin"], - } - assert await _oidc_login_session_roles(userinfo, provider) == [] - - -async def test_oidc_login_roles_filtered_to_allowlist(): - provider = OIDCProviderConfig( - issuer="https://idp.example.com", audience=["x"], allowed_roles=["org_admin"] - ) - userinfo = { - "sub": "u", - "email": "e@x.com", - "roles": ["platform_admin", "org_admin"], - } - assert await _oidc_login_session_roles(userinfo, provider) == ["org_admin"] diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index 8388a63200f5..a53b04a84e12 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -1,21 +1,46 @@ from __future__ import annotations +from typing import Dict, Optional + import pytest -from scim2_models import Group as ScimGroup -from scim2_models import User as ScimUser +from litellm.proxy._types import LiteLLM_UserTable, UserAPIKeyAuth, hash_token +from litellm.proxy.auth_v2.authorization import Role from litellm.proxy.auth_v2.errors import AuthError from litellm.proxy.auth_v2.models import ( AuthMethod, ClientCertificate, Credential, - Principal, PrincipalType, SecuritySchemeType, - UserIdentity, ) -from litellm.proxy.auth_v2.rbac import Role -from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.proxy.auth_v2.resolvers.db import DbIdentityStore + + +class _FakeCache: + """Stands in for the DualCache that get_key_object / get_user_object read. + + Both helpers return a cache hit before touching the DB, so seeding this and + injecting it into DbIdentityStore exercises the real resolver mapping without + a database. A non-None prisma client is still required (the helpers guard on + it); it is never reached on a hit. + """ + + def __init__(self, entries: Optional[Dict[str, object]] = None) -> None: + self._entries = entries or {} + + async def async_get_cache(self, key, *args, **kwargs): + return self._entries.get(key) + + async def async_set_cache(self, *args, **kwargs): + return None + + +_PRISMA_STUB = object() + + +def _store(entries: Optional[Dict[str, object]] = None) -> DbIdentityStore: + return DbIdentityStore(_PRISMA_STUB, _FakeCache(entries)) def _api_key_credential(raw: str) -> Credential: @@ -27,168 +52,91 @@ def _api_key_credential(raw: str) -> Credential: ) -def _principal(subject: str = "user-1") -> Principal: - return Principal( - principal_type=PrincipalType.HUMAN, +def _oidc_credential(subject: str) -> Credential: + return Credential( + scheme=SecuritySchemeType.OPENID_CONNECT, + method=AuthMethod.OIDC, subject=subject, - auth_method=AuthMethod.API_KEY, + issuer="https://idp", + scopes=["models:read"], + claims={"email": "dana@example.com"}, ) -async def test_api_key_resolves_only_for_exact_key(): - raw = "sk-live-correct-horse" - store = InMemoryIdentityStore(api_keys={_hash_api_key(raw): _principal("svc-a")}) +async def test_api_key_resolves_to_principal_with_db_role(): + raw = "sk-live-abc" + key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1", user_role="org_admin") + store = _store({hash_token(raw): key}) - resolved = await store.resolve(_api_key_credential(raw)) - assert resolved.subject == "svc-a" + principal = await store.resolve(_api_key_credential(raw)) + assert principal.principal_type == PrincipalType.HUMAN + assert principal.subject == "u-1" + assert principal.user is not None and principal.user.id == "u-1" + # role comes from the key's user_role mapped through the DB role map + assert principal.roles == [Role.ORG_ADMIN] -async def test_wrong_api_key_never_resolves(): - raw = "sk-live-correct-horse" - store = InMemoryIdentityStore(api_keys={_hash_api_key(raw): _principal()}) + +async def test_api_key_lookup_is_keyed_on_hashed_token(): + raw = "sk-live-abc" + key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1") + # cache seeded under the RAW key, not its hash -> resolver hashes first -> miss + store = DbIdentityStore(None, _FakeCache({raw: key})) with pytest.raises(AuthError) as exc: - await store.resolve(_api_key_credential("sk-live-wrong-key")) + await store.resolve(_api_key_credential(raw)) assert exc.value.status_code == 401 -async def test_api_key_lookup_is_keyed_on_sha256_not_raw(): - raw = "sk-live-correct-horse" - # store keyed by the raw value (not its hash) must NOT resolve: resolver hashes first - store = InMemoryIdentityStore(api_keys={raw: _principal()}) - with pytest.raises(AuthError): - await store.resolve(_api_key_credential(raw)) - +async def test_blocked_key_is_rejected_403(): + raw = "sk-live-blocked" + key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1", blocked=True) + store = _store({hash_token(raw): key}) -async def test_missing_raw_api_key_claim_is_rejected(): - store = InMemoryIdentityStore(api_keys={}) - credential = Credential( - scheme=SecuritySchemeType.API_KEY, - method=AuthMethod.API_KEY, - subject="sk-x", - ) - with pytest.raises(AuthError): - await store.resolve(credential) + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential(raw)) + assert exc.value.status_code == 403 -async def test_subject_lookup_prefers_stored_principal(): - stored = _principal("from-store") - store = InMemoryIdentityStore(subjects={"https://idp|sub-9": stored}) - credential = Credential( - scheme=SecuritySchemeType.OPENID_CONNECT, - method=AuthMethod.OIDC, - subject="sub-9", - issuer="https://idp", - ) - resolved = await store.resolve(credential) - assert resolved.subject == "from-store" +async def test_unknown_key_is_rejected_401(): + # cache miss + no prisma -> get_key_object raises -> resolver maps to 401 + store = DbIdentityStore(None, _FakeCache()) + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential("sk-live-unknown")) + assert exc.value.status_code == 401 -def _oidc_credential(**claims) -> Credential: - return Credential( - scheme=SecuritySchemeType.OPENID_CONNECT, - method=AuthMethod.OIDC, - subject="sub-42", - issuer="https://idp", - scopes=["models:read"], - claims={ - "email": "dana@example.com", - "preferred_username": "dana", - "name": "Dana D", - **claims, - }, +async def test_subject_resolves_to_user_principal(): + user = LiteLLM_UserTable( + user_id="u-9", + user_role="org_admin", + user_email="dana@example.com", + sso_user_id="ext-9", + user_alias="Dana", + teams=[], ) + store = _store({"u-9": user}) + principal = await store.resolve(_oidc_credential("u-9")) -async def test_self_describing_token_builds_principal_from_claims(): - store = InMemoryIdentityStore() - principal = await store.resolve(_oidc_credential(roles=["org_admin", "bogus_role"])) + assert principal.principal_type == PrincipalType.HUMAN + assert principal.user is not None + assert principal.user.id == "u-9" assert principal.user.email == "dana@example.com" - assert principal.user.user_name == "dana" - # invalid role strings are filtered out, valid ones become Role enums + assert principal.user.external_id == "ext-9" assert principal.roles == [Role.ORG_ADMIN] assert principal.scopes == ["models:read"] -async def test_group_claim_without_provisioned_scim_group_is_not_a_team(): - # H1: a token group claim is not authoritative on its own - store = InMemoryIdentityStore() - principal = await store.resolve(_oidc_credential(groups=["eng", "oncall"])) - assert principal.teams == [] - - -async def test_group_claim_becomes_team_only_when_provisioned(): - store = InMemoryIdentityStore( - groups={"eng": ScimGroup(id="eng", display_name="Engineering")} - ) - principal = await store.resolve(_oidc_credential(groups=["eng", "unprovisioned"])) - # only the provisioned group resolves to a team; the unknown one is dropped - assert len(principal.teams) == 1 - assert principal.teams[0].id == "eng" - assert principal.teams[0].name == "Engineering" - - async def test_mtls_credential_resolves_to_service_account(): - store = InMemoryIdentityStore() credential = Credential( scheme=SecuritySchemeType.MUTUAL_TLS, method=AuthMethod.MUTUAL_TLS, subject="CN=svc-a,O=Co", client_certificate=ClientCertificate(subject_dn="CN=svc-a,O=Co"), ) - principal = await store.resolve(credential) + # service-account path does no identity lookup, so no cache/prisma needed + principal = await DbIdentityStore(None, _FakeCache()).resolve(credential) + assert principal.principal_type == PrincipalType.SERVICE_ACCOUNT assert principal.user is None assert principal.subject == "CN=svc-a,O=Co" - - -# --------------------------------------------------------------------------- # -# Deactivated users (M1) and claims scrubbing -# --------------------------------------------------------------------------- # - - -async def test_deactivated_user_is_rejected(): - principal = Principal( - principal_type=PrincipalType.HUMAN, - subject="u-1", - auth_method=AuthMethod.API_KEY, - user=UserIdentity(id="u-1", email="u@example.com"), - ) - store = InMemoryIdentityStore( - api_keys={_hash_api_key("sk-deact"): principal}, - users={"u-1": ScimUser(id="u-1", user_name="u@example.com", active=False)}, - ) - with pytest.raises(AuthError) as exc: - await store.resolve(_api_key_credential("sk-deact")) - assert exc.value.status_code == 403 - - -async def test_active_user_is_allowed(): - principal = Principal( - principal_type=PrincipalType.HUMAN, - subject="u-2", - auth_method=AuthMethod.API_KEY, - user=UserIdentity(id="u-2", email="ok@example.com"), - ) - store = InMemoryIdentityStore( - api_keys={_hash_api_key("sk-ok"): principal}, - users={"u-2": ScimUser(id="u-2", user_name="ok@example.com", active=True)}, - ) - resolved = await store.resolve(_api_key_credential("sk-ok")) - assert resolved.subject == "u-2" - - -async def test_principal_claims_scrub_underscore_keys(): - # internal underscore-prefixed claims (e.g. _raw_api_key) must never surface - # on the Principal built from a self-describing credential - store = InMemoryIdentityStore() - credential = Credential( - scheme=SecuritySchemeType.OPENID_CONNECT, - method=AuthMethod.OIDC, - subject="sub-x", - issuer="https://idp", - claims={"_raw_api_key": "leak", "_basic_password": "leak", "email": "e@x.com"}, - ) - principal = await store.resolve(credential) - assert "_raw_api_key" not in principal.claims - assert "_basic_password" not in principal.claims - assert principal.claims.get("email") == "e@x.com" diff --git a/tests/test_litellm/proxy/auth_v2/test_saml.py b/tests/test_litellm/proxy/auth_v2/test_saml.py deleted file mode 100644 index 3d3413844500..000000000000 --- a/tests/test_litellm/proxy/auth_v2/test_saml.py +++ /dev/null @@ -1,517 +0,0 @@ -from __future__ import annotations - -import base64 -import datetime -import shutil -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, Optional - -import pytest -from fastapi import FastAPI, Security -from fastapi.testclient import TestClient - -xmlsec1 = shutil.which("xmlsec1") -pytestmark = pytest.mark.skipif( - xmlsec1 is None, reason="SAML SP requires the xmlsec1 binary on PATH" -) - -SP_ENTITY_ID = "https://sp.test.litellm.ai/auth/saml/metadata" -ACS_URL = "https://sp.test.litellm.ai/auth/saml/acs" -IDP_ENTITY_ID = "https://idp.test.litellm.ai/idp" -IDP_SSO_URL = "https://idp.test.litellm.ai/sso" - - -def _gen_cert(directory: Path, prefix: str) -> tuple[str, str]: - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.x509.oid import NameOID - - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, prefix)]) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime(2020, 1, 1)) - .not_valid_after(datetime.datetime(2035, 1, 1)) - .sign(key, hashes.SHA256()) - ) - key_path = directory / f"{prefix}.key" - cert_path = directory / f"{prefix}.crt" - key_path.write_bytes( - key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) - ) - cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - return str(key_path), str(cert_path) - - -@dataclass -class SamlEnv: - config: Any - idp: Any # saml2.server.Server - name_id_value: str = "alice@example.com" - - def mint_response( - self, - *, - identity: Optional[Dict[str, Any]] = None, - sign_assertion: bool = True, - ) -> str: - from saml2.authn_context import PASSWORD - from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NameID - - name_id = NameID(format=NAMEID_FORMAT_EMAILADDRESS, text=self.name_id_value) - response = self.idp.create_authn_response( - identity=identity - or { - "email": ["alice@example.com"], - "displayName": ["Alice Anderson"], - "groups": ["eng", "admins"], - }, - in_response_to=None, - destination=ACS_URL, - sp_entity_id=SP_ENTITY_ID, - name_id=name_id, - sign_assertion=sign_assertion, - authn={"class_ref": PASSWORD, "authn_auth": IDP_ENTITY_ID}, - ) - return base64.b64encode(str(response).encode()).decode() - - -@pytest.fixture -def saml_env(tmp_path: Path) -> SamlEnv: - from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT - from saml2.config import IdPConfig, SPConfig - from saml2.metadata import entity_descriptor - from saml2.saml import NAMEID_FORMAT_EMAILADDRESS - from saml2.server import Server - - from litellm.proxy.auth_v2 import SAMLConfig - - idp_key, idp_cert = _gen_cert(tmp_path, "idp") - sp_key, sp_cert = _gen_cert(tmp_path, "sp") - - sp_conf = SPConfig() - sp_conf.load( - { - "entityid": SP_ENTITY_ID, - "service": { - "sp": { - "endpoints": { - "assertion_consumer_service": [(ACS_URL, BINDING_HTTP_POST)] - }, - "allow_unsolicited": True, - "authn_requests_signed": False, - "want_assertions_signed": True, - "want_response_signed": False, - } - }, - "allow_unknown_attributes": True, - "xmlsec_binary": xmlsec1, - } - ) - sp_metadata_path = tmp_path / "sp_metadata.xml" - sp_metadata_path.write_text(str(entity_descriptor(sp_conf))) - - idp_conf = IdPConfig() - idp_conf.load( - { - "entityid": IDP_ENTITY_ID, - "service": { - "idp": { - "endpoints": { - "single_sign_on_service": [(IDP_SSO_URL, BINDING_HTTP_REDIRECT)] - }, - "name_id_format": [NAMEID_FORMAT_EMAILADDRESS], - } - }, - "metadata": {"local": [str(sp_metadata_path)]}, - "key_file": idp_key, - "cert_file": idp_cert, - "xmlsec_binary": xmlsec1, - } - ) - idp = Server(config=idp_conf) - idp_metadata = str(entity_descriptor(idp.config)) - - config = SAMLConfig( - enabled=True, - entity_id=SP_ENTITY_ID, - acs_url=ACS_URL, - idp_metadata=idp_metadata, - sp_key_file=sp_key, - sp_cert_file=sp_cert, - xmlsec_binary=xmlsec1, - # this harness mints IdP-initiated (unsolicited) responses; pin the config - # explicitly so the suite is independent of the allow_unsolicited default - allow_unsolicited=True, - ) - return SamlEnv(config=config, idp=idp) - - -def _build_app(saml_env: SamlEnv): - from litellm.proxy.auth_v2.config import AuthConfig - from litellm.proxy.auth_v2.models import Principal - from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore - from litellm.proxy.auth_v2.saml import build_saml_router - from litellm.proxy.auth_v2.security import AuthSecurity - - app = FastAPI() - store = InMemoryIdentityStore() - auth = AuthSecurity(AuthConfig(saml=saml_env.config), store) - app.include_router(build_saml_router(auth)) - - @app.get("/whoami") - async def whoami( - principal: "Principal" = Security(auth.principal), - ): - return { - "subject": principal.subject, - "auth_method": principal.auth_method.value, - "email": principal.user.email if principal.user else None, - "roles": [role.value for role in principal.roles], - } - - return app, store - - -def _saml_session_roles(env, *, asserted_roles): - app, _ = _build_app(env) - client = TestClient(app) - acs = client.post( - "/auth/saml/acs", - data={ - "SAMLResponse": env.mint_response( - identity={"email": ["alice@example.com"], "roles": asserted_roles} - ) - }, - follow_redirects=False, - ) - client.cookies.set("litellm_session", acs.cookies["litellm_session"]) - return client.get("/whoami").json()["roles"] - - -def test_saml_sso_platform_role_denied_by_default(saml_env): - # H1 on the SSO path: an IdP-asserted platform_admin grants nothing by default - roles = _saml_session_roles( - saml_env, asserted_roles=["platform_admin", "org_admin"] - ) - assert roles == [] - - -def test_saml_sso_roles_filtered_to_allowlist(saml_env): - env = SamlEnv( - config=saml_env.config.model_copy(update={"allowed_roles": ["org_admin"]}), - idp=saml_env.idp, - ) - roles = _saml_session_roles(env, asserted_roles=["platform_admin", "org_admin"]) - assert roles == ["org_admin"] - - -# --------------------------------------------------------------------------- # -# Metadata + login redirect -# --------------------------------------------------------------------------- # - - -def test_metadata_endpoint_serves_sp_descriptor(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - response = client.get("/auth/saml/metadata") - assert response.status_code == 200 - assert "EntityDescriptor" in response.text - assert SP_ENTITY_ID in response.text - assert ACS_URL in response.text - - -def test_login_redirects_to_idp_sso(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - response = client.get("/auth/saml/login", follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"].startswith(IDP_SSO_URL) - - -# --------------------------------------------------------------------------- # -# ACS: signed assertion provisions + authenticates; tampering is rejected -# --------------------------------------------------------------------------- # - - -def test_acs_accepts_signed_assertion_and_provisions_user(saml_env): - app, store = _build_app(saml_env) - client = TestClient(app) - saml_response = saml_env.mint_response() - - acs = client.post( - "/auth/saml/acs", - data={"SAMLResponse": saml_response}, - follow_redirects=False, - ) - assert acs.status_code == 303 - assert "litellm_session" in acs.cookies - - # user was provisioned into the ProvisioningStore via the shared upsert seam - users = list(store._users.values()) - assert len(users) == 1 - assert users[0].external_id == "alice@example.com" - assert users[0].emails[0].value == "alice@example.com" - - -def test_session_cookie_authenticates_with_saml_method(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - acs = client.post( - "/auth/saml/acs", - data={"SAMLResponse": saml_env.mint_response()}, - follow_redirects=False, - ) - client.cookies.set("litellm_session", acs.cookies["litellm_session"]) - - whoami = client.get("/whoami") - assert whoami.status_code == 200 - body = whoami.json() - assert body["auth_method"] == "saml" - assert body["subject"] == "alice@example.com" - assert body["email"] == "alice@example.com" - - -def test_acs_rejects_tampered_assertion(saml_env): - app, store = _build_app(saml_env) - client = TestClient(app) - valid = saml_env.mint_response() - decoded = base64.b64decode(valid).decode() - tampered = decoded.replace("alice@example.com", "attacker@evil.com") - tampered_b64 = base64.b64encode(tampered.encode()).decode() - - response = client.post( - "/auth/saml/acs", - data={"SAMLResponse": tampered_b64}, - follow_redirects=False, - ) - assert response.status_code == 401 - assert store._users == {} - - -def test_acs_rejects_unsigned_assertion(saml_env): - app, store = _build_app(saml_env) - client = TestClient(app) - unsigned = saml_env.mint_response(sign_assertion=False) - response = client.post( - "/auth/saml/acs", - data={"SAMLResponse": unsigned}, - follow_redirects=False, - ) - assert response.status_code == 401 - assert store._users == {} - - -def test_acs_rejects_replayed_assertion(saml_env): - # a signed assertion is single-use; replaying it is rejected - app, _ = _build_app(saml_env) - client = TestClient(app) - response = saml_env.mint_response() - first = client.post( - "/auth/saml/acs", data={"SAMLResponse": response}, follow_redirects=False - ) - assert first.status_code == 303 - second = client.post( - "/auth/saml/acs", data={"SAMLResponse": response}, follow_redirects=False - ) - assert second.status_code == 401 - - -def test_acs_rejects_unsolicited_when_disabled(saml_env): - # default-secure: an IdP-initiated (no InResponseTo) response is rejected - disabled = saml_env.config.model_copy(update={"allow_unsolicited": False}) - env = SamlEnv(config=disabled, idp=saml_env.idp) - app, store = _build_app(env) - client = TestClient(app) - response = client.post( - "/auth/saml/acs", - data={"SAMLResponse": env.mint_response()}, - follow_redirects=False, - ) - assert response.status_code == 401 - assert store._users == {} - - -def test_acs_missing_response_is_rejected(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - response = client.post("/auth/saml/acs", data={}, follow_redirects=False) - assert response.status_code == 400 - - -def test_acs_rejects_garbage_response(saml_env): - app, store = _build_app(saml_env) - client = TestClient(app) - response = client.post( - "/auth/saml/acs", - data={"SAMLResponse": "this-is-not-a-saml-response"}, - follow_redirects=False, - ) - assert response.status_code == 401 - assert store._users == {} - - -def test_acs_ignores_untrusted_form_relay_state(saml_env): - # the redirect target is bound server-side to the originating AuthnRequest, so a - # client-supplied form RelayState on an (unsolicited) response is NOT trusted and - # the ACS falls back to default_redirect_path - app, _ = _build_app(saml_env) - client = TestClient(app) - acs = client.post( - "/auth/saml/acs", - data={"SAMLResponse": saml_env.mint_response(), "RelayState": "/dashboard"}, - follow_redirects=False, - ) - assert acs.status_code == 303 - assert acs.headers["location"] == "/" - - -def test_acs_never_redirects_to_attacker_relay_state(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - acs = client.post( - "/auth/saml/acs", - data={ - "SAMLResponse": saml_env.mint_response(), - "RelayState": "https://evil.example.com/phish", - }, - follow_redirects=False, - ) - assert acs.status_code == 303 - assert "evil.example.com" not in acs.headers["location"] - assert acs.headers["location"] == "/" - - -def test_login_threads_safe_next_as_relay_state(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - response = client.get("/auth/saml/login?next=/dashboard", follow_redirects=False) - assert response.status_code == 303 - assert "RelayState=%2Fdashboard" in response.headers["location"] - - -def test_login_rejects_open_redirect_next(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - response = client.get( - "/auth/saml/login?next=https://evil.example.com", follow_redirects=False - ) - assert response.status_code == 303 - location = response.headers["location"] - assert "evil.example.com" not in location - # falls back to default_redirect_path ("/") as the RelayState - assert "RelayState=%2F&" in location or location.endswith("RelayState=%2F") - - -# --------------------------------------------------------------------------- # -# Pure helpers (no xmlsec1 required) - attribute mapping + open-redirect guard -# --------------------------------------------------------------------------- # - - -def test_map_attributes_applies_attribute_map(): - from litellm.proxy.auth_v2.saml.config import DEFAULT_SAML_ATTRIBUTE_MAP - from litellm.proxy.auth_v2.saml.router import _map_attributes - - ava = { - "email": ["alice@example.com"], - "givenName": ["Alice"], - "surname": ["Anderson"], - "groups": ["eng", "admins"], - } - mapped = _map_attributes(ava, dict(DEFAULT_SAML_ATTRIBUTE_MAP)) - assert mapped["email"] == "alice@example.com" - assert mapped["given_name"] == "Alice" - assert mapped["family_name"] == "Anderson" - assert mapped["groups"] == ["eng", "admins"] - - -def test_user_from_mapped_builds_name_and_email(): - from litellm.proxy.auth_v2.saml.router import _user_from_mapped - - user = _user_from_mapped( - "alice@example.com", - { - "given_name": "Alice", - "family_name": "Anderson", - "email": "alice@example.com", - }, - ) - assert user.external_id == "alice@example.com" - assert user.display_name == "Alice Anderson" - assert user.emails[0].value == "alice@example.com" - assert user.name.given_name == "Alice" - - -@pytest.mark.parametrize( - "candidate,expected", - [ - ("/dashboard", "/dashboard"), - ("//evil.com", "/"), - ("https://evil.com", "/"), - ("/path\\with-backslash", "/"), - (None, "/"), - ], -) -def test_safe_relay_state_blocks_open_redirects(candidate, expected): - from litellm.proxy.auth_v2.session import safe_relay_state - - assert safe_relay_state(candidate, "/") == expected - - -@pytest.mark.parametrize( - "metadata,expected_key", - [ - ("", "inline"), - ("https://idp.example.com/metadata", "remote"), - ("/etc/saml/idp.xml", "local"), - ], -) -def test_metadata_source_classifies_input(metadata, expected_key): - from litellm.proxy.auth_v2.saml.router import _metadata_source - - assert expected_key in _metadata_source(metadata) - - -def test_acs_session_cookie_is_secure(saml_env): - app, _ = _build_app(saml_env) - client = TestClient(app) - acs = client.post( - "/auth/saml/acs", - data={"SAMLResponse": saml_env.mint_response()}, - follow_redirects=False, - ) - assert "litellm_session" in acs.cookies - assert "secure" in acs.headers["set-cookie"].lower() - - -# --------------------------------------------------------------------------- # -# SessionStore TTL + size eviction (no xmlsec1 needed) -# --------------------------------------------------------------------------- # - - -def test_session_store_expires_entries(): - from litellm.proxy.auth_v2.session import SessionStore - - store = SessionStore(ttl_seconds=0) - session_id = store.create_session({"name_id": "alice@example.com"}) - # ttl of 0 means the entry is already past its expiry on the next read - assert store.get(session_id) is None - - -def test_session_store_evicts_when_over_capacity(): - from litellm.proxy.auth_v2.session import SessionStore - - store = SessionStore(max_size=3) - ids = [store.create_session({"name_id": f"user-{i}"}) for i in range(5)] - live = [sid for sid in ids if store.get(sid) is not None] - assert len(live) <= 3 diff --git a/tests/test_litellm/proxy/auth_v2/test_scim.py b/tests/test_litellm/proxy/auth_v2/test_scim.py deleted file mode 100644 index f46677ac29ba..000000000000 --- a/tests/test_litellm/proxy/auth_v2/test_scim.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from litellm.proxy.auth_v2.config import AuthConfig -from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key -from litellm.proxy.auth_v2.scim import build_scim_router -from litellm.proxy.auth_v2.security import AuthSecurity - -USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" -GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" -ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" - -SCIM_KEY = "sk-scim-writer" -NOSCOPE_KEY = "sk-no-scim-scope" - - -def _principal(subject: str, scopes: list) -> Principal: - return Principal( - principal_type=PrincipalType.HUMAN, - subject=subject, - auth_method=AuthMethod.API_KEY, - scopes=scopes, - ) - - -def _app() -> FastAPI: - app = FastAPI() - store = InMemoryIdentityStore( - api_keys={ - _hash_api_key(SCIM_KEY): _principal("scim-writer", ["scim:write"]), - _hash_api_key(NOSCOPE_KEY): _principal("no-scope", []), - } - ) - auth = AuthSecurity(AuthConfig(), store) - app.include_router(build_scim_router(auth)) - return app - - -@pytest.fixture -def client() -> TestClient: - # SCIM routes require scim:write; authenticate every request with a scoped key - return TestClient(_app(), headers={"x-litellm-api-key": SCIM_KEY}) - - -def _create_user(client: TestClient, user_name="alice@example.com", display="Alice"): - return client.post( - "/scim/v2/Users", - json={"schemas": [USER_SCHEMA], "userName": user_name, "displayName": display}, - ) - - -def test_create_user_returns_201_with_id(client): - response = _create_user(client) - assert response.status_code == 201 - body = response.json() - assert body["id"] - assert body["userName"] == "alice@example.com" - assert USER_SCHEMA in body["schemas"] - - -def test_get_user_round_trips(client): - user_id = _create_user(client).json()["id"] - response = client.get(f"/scim/v2/Users/{user_id}") - assert response.status_code == 200 - assert response.json()["userName"] == "alice@example.com" - - -def test_get_unknown_user_returns_scim_404(client): - response = client.get("/scim/v2/Users/does-not-exist") - assert response.status_code == 404 - assert ERROR_SCHEMA in response.json()["schemas"] - - -def test_patch_replace_display_name(client): - user_id = _create_user(client).json()["id"] - response = client.patch( - f"/scim/v2/Users/{user_id}", - json={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [ - {"op": "replace", "path": "displayName", "value": "Alice B"} - ], - }, - ) - assert response.status_code == 200 - assert response.json()["displayName"] == "Alice B" - # persisted - assert client.get(f"/scim/v2/Users/{user_id}").json()["displayName"] == "Alice B" - - -def test_list_users_returns_list_response(client): - _create_user(client, user_name="a@example.com") - _create_user(client, user_name="b@example.com") - response = client.get("/scim/v2/Users") - assert response.status_code == 200 - body = response.json() - assert body["totalResults"] == 2 - user_names = {r["userName"] for r in body["Resources"]} - assert user_names == {"a@example.com", "b@example.com"} - - -def test_deactivate_user_sets_active_false(client): - user_id = _create_user(client).json()["id"] - assert client.delete(f"/scim/v2/Users/{user_id}").status_code == 204 - assert client.get(f"/scim/v2/Users/{user_id}").json()["active"] is False - - -def test_malformed_user_returns_scim_400_error(client): - # userName is required for a SCIM User creation request - response = client.post( - "/scim/v2/Users", json={"schemas": [USER_SCHEMA], "displayName": "No Username"} - ) - assert response.status_code == 400 - body = response.json() - assert ERROR_SCHEMA in body["schemas"] - assert body["status"] == "400" - - -def test_group_membership_round_trips(client): - response = client.post( - "/scim/v2/Groups", - json={ - "schemas": [GROUP_SCHEMA], - "displayName": "Engineering", - "members": [{"value": "user-1", "display": "Alice"}], - }, - ) - assert response.status_code == 201 - group_id = response.json()["id"] - - fetched = client.get(f"/scim/v2/Groups/{group_id}").json() - assert fetched["displayName"] == "Engineering" - assert fetched["members"][0]["value"] == "user-1" - - -def test_delete_group_removes_it(client): - group_id = client.post( - "/scim/v2/Groups", - json={"schemas": [GROUP_SCHEMA], "displayName": "Temp"}, - ).json()["id"] - assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 204 - assert client.get(f"/scim/v2/Groups/{group_id}").status_code == 404 - - -def test_service_provider_config_advertises_patch(client): - response = client.get("/scim/v2/ServiceProviderConfig") - assert response.status_code == 200 - assert response.json()["patch"]["supported"] is True - - -def test_resource_types_lists_user_and_group(client): - response = client.get("/scim/v2/ResourceTypes") - assert response.status_code == 200 - names = {r["name"] for r in response.json()["Resources"]} - assert names == {"User", "Group"} - - -def test_schemas_endpoint_returns_user_and_group(client): - response = client.get("/scim/v2/Schemas") - assert response.status_code == 200 - body = response.json() - assert body["totalResults"] == 2 - # a ListResponse envelope, not a bare dict (regression for the envelope fix) - assert body["schemas"][0].endswith(":ListResponse") - assert len(body["Resources"]) == 2 - - -# --------------------------------------------------------------------------- # -# SCIM routes are gated by scim:write (design section 11) -# --------------------------------------------------------------------------- # - - -def test_scim_requires_authentication(): - unauth = TestClient(_app()) - response = unauth.post( - "/scim/v2/Users", - json={"schemas": [USER_SCHEMA], "userName": "x@example.com"}, - ) - assert response.status_code == 401 - assert "WWW-Authenticate" in response.headers - # S7: auth failures are rendered as a SCIM Error, not the generic body - body = response.json() - assert body["schemas"] == [ERROR_SCHEMA] - assert body["status"] == "401" - - -def test_scim_requires_scim_write_scope(): - underscoped = TestClient(_app(), headers={"x-litellm-api-key": NOSCOPE_KEY}) - response = underscoped.get("/scim/v2/Users") - assert response.status_code == 403 - assert "insufficient_scope" in response.headers.get("WWW-Authenticate", "") - body = response.json() - assert body["schemas"] == [ERROR_SCHEMA] - assert body["status"] == "403" - - -# --------------------------------------------------------------------------- # -# id is read-only: PATCH attempting to mutate it is rejected (RFC 7643) -# --------------------------------------------------------------------------- # - - -@pytest.mark.parametrize( - "operation", - [ - {"op": "replace", "path": "id", "value": "evil"}, - {"op": "remove", "path": "id"}, - {"op": "replace", "value": {"id": "evil", "displayName": "X"}}, - ], -) -def test_patch_id_mutation_is_rejected(client, operation): - user_id = _create_user(client).json()["id"] - response = client.patch( - f"/scim/v2/Users/{user_id}", - json={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [operation], - }, - ) - assert response.status_code == 400 - assert response.json()["schemas"] == [ERROR_SCHEMA] - # the record keeps its id; the attacker id never materializes - assert client.get(f"/scim/v2/Users/{user_id}").status_code == 200 - assert client.get("/scim/v2/Users/evil").status_code == 404 - - -# --------------------------------------------------------------------------- # -# Discovery endpoints are public (RFC 7644); Users/Groups stay guarded -# --------------------------------------------------------------------------- # - - -@pytest.mark.parametrize( - "path", ["/ServiceProviderConfig", "/ResourceTypes", "/Schemas"] -) -def test_discovery_endpoints_are_public(path): - # no credential at all -> still 200 (provisioning clients negotiate before auth) - unauth = TestClient(_app()) - assert unauth.get(f"/scim/v2{path}").status_code == 200 - - -def test_users_endpoint_is_not_public(): - unauth = TestClient(_app()) - assert unauth.get("/scim/v2/Users").status_code == 401 - - -# --------------------------------------------------------------------------- # -# DELETE on a missing resource returns a SCIM 404 Error, not 204 -# --------------------------------------------------------------------------- # - - -def test_delete_missing_user_returns_scim_404(client): - response = client.delete("/scim/v2/Users/no-such-user") - assert response.status_code == 404 - body = response.json() - assert body["schemas"] == [ERROR_SCHEMA] - assert body["status"] == "404" - - -def test_delete_missing_group_returns_scim_404(client): - response = client.delete("/scim/v2/Groups/no-such-group") - assert response.status_code == 404 - body = response.json() - assert body["schemas"] == [ERROR_SCHEMA] - assert body["status"] == "404" - - -def test_second_delete_of_group_returns_404(client): - group_id = client.post( - "/scim/v2/Groups", - json={"schemas": [GROUP_SCHEMA], "displayName": "Temp"}, - ).json()["id"] - assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 204 - assert client.delete(f"/scim/v2/Groups/{group_id}").status_code == 404 - - -# --------------------------------------------------------------------------- # -# PATCH supports nested dotted paths; filter paths are rejected -# --------------------------------------------------------------------------- # - - -def test_patch_nested_path_sets_subattribute(client): - user_id = _create_user(client).json()["id"] - response = client.patch( - f"/scim/v2/Users/{user_id}", - json={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [{"op": "replace", "path": "name.givenName", "value": "Ada"}], - }, - ) - assert response.status_code == 200 - assert response.json()["name"]["givenName"] == "Ada" - assert client.get(f"/scim/v2/Users/{user_id}").json()["name"]["givenName"] == "Ada" - - -def test_patch_filter_path_is_rejected(client): - user_id = _create_user(client).json()["id"] - response = client.patch( - f"/scim/v2/Users/{user_id}", - json={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [ - {"op": "replace", "path": 'emails[type eq "work"].value', "value": "x"} - ], - }, - ) - assert response.status_code == 400 - assert response.json()["schemas"] == [ERROR_SCHEMA] diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index e2f64fb44fc6..9f01dfc29d18 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -1,13 +1,17 @@ from __future__ import annotations -from typing import Any, Tuple +import asyncio +from typing import Any, Dict, List, Optional, Sequence, Tuple import pytest -from fastapi import FastAPI, Security +from fastapi import FastAPI, Request, Security +from fastapi.security import SecurityScopes from fastapi.testclient import TestClient from litellm.proxy.auth_v2.authenticators import ( APIKeyAuthenticator, + Carrier, + CredentialLocation, HttpAuthenticator, JWTVerifier, ) @@ -16,10 +20,16 @@ AuthConfig, HttpBasicConfig, ) -from litellm.proxy.auth_v2 import OIDCProviderConfig -from litellm.proxy.auth_v2.models import AuthMethod, Principal, PrincipalType -from litellm.proxy.auth_v2.rbac import RBACEngine, Role -from litellm.proxy.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key +from litellm.proxy.auth_v2 import OIDCProviderConfig, errors +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Credential, + Principal, + PrincipalType, + SecuritySchemeType, +) +from litellm.proxy.auth_v2.authorization import RBACEngine, Role +from litellm.proxy.auth_v2.resolvers.utils import hash_api_key from litellm.proxy.auth_v2.security import AuthSecurity from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient @@ -41,9 +51,37 @@ def _principal(subject: str, *, scopes=None, roles=None) -> Principal: ) +class _FakeResolver: + """Resolver double for the security-layer tests. + + These tests inject fully-formed Principals (arbitrary scopes/roles) keyed by + API key, which the production DbIdentityStore cannot express; DbIdentityStore + has its own coverage in test_resolver.py. An API-key credential is looked up + by its raw-key claim; anything else echoes the credential's subject. Returns a + fresh Principal per the IdentityResolver contract. + """ + + def __init__(self, by_key: Dict[str, Principal]) -> None: + self._by_key = by_key + + async def resolve(self, credential: Credential) -> Principal: + raw = credential.claims.get("_raw_api_key") + if isinstance(raw, str): + principal = self._by_key.get(hash_api_key(raw)) + if principal is None: + raise errors.invalid_token() + return principal.model_copy() + return Principal( + principal_type=PrincipalType.HUMAN, + subject=credential.subject, + auth_method=credential.method, + scopes=list(credential.scopes), + ) + + def _build_app( public_key: Any, *, rbac: RBACEngine = None -) -> Tuple[FastAPI, InMemoryIdentityStore]: +) -> Tuple[FastAPI, _FakeResolver]: verifier = JWTVerifier( OIDCProviderConfig(issuer=TEST_ISSUER, audience=[TEST_AUDIENCE]), jwks_client=FakeJwksClient(public_key), @@ -52,25 +90,25 @@ def _build_app( APIKeyAuthenticator(ApiKeySchemeConfig()), HttpAuthenticator(HttpBasicConfig(), [verifier]), ] - resolver = InMemoryIdentityStore( - api_keys={ - _hash_api_key(ADMIN_KEY): _principal( + resolver = _FakeResolver( + { + hash_api_key(ADMIN_KEY): _principal( "admin-principal", scopes=["models:read"], roles=[Role.ORG_ADMIN] ), - _hash_api_key(READER_KEY): _principal( + hash_api_key(READER_KEY): _principal( "reader-principal", scopes=["models:read"] ), - _hash_api_key(NOSCOPE_KEY): _principal("noscope-principal"), - _hash_api_key(PLATFORM_ADMIN_KEY): _principal( + hash_api_key(NOSCOPE_KEY): _principal("noscope-principal"), + hash_api_key(PLATFORM_ADMIN_KEY): _principal( "platform-admin-principal", roles=[Role.PLATFORM_ADMIN] ), - _hash_api_key(PLATFORM_VIEWER_KEY): _principal( + hash_api_key(PLATFORM_VIEWER_KEY): _principal( "platform-viewer-principal", roles=[Role.PLATFORM_VIEWER] ), } ) auth = AuthSecurity( - AuthConfig(), resolver, rbac=rbac, authenticators=authenticators + AuthConfig(), resolver, authorizer=rbac, authenticators=authenticators ) app = FastAPI() @@ -273,3 +311,165 @@ def test_injected_rbac_engine_overrides_default_policy(rsa_keypair, tmp_path): ).status_code == 403 ) + + +# --------------------------------------------------------------------------- # +# Carrier dispatch: only authenticators whose credential is present are run +# --------------------------------------------------------------------------- # + + +def _request(headers=None, cookies=None, tls=None) -> Request: + raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] + if cookies: + cookie = "; ".join(f"{name}={value}" for name, value in cookies.items()) + raw.append((b"cookie", cookie.encode())) + scope = { + "type": "http", + "method": "GET", + "path": "/", + "query_string": b"", + "headers": raw, + "client": ("1.2.3.4", 0), + } + if tls is not None: + scope["extensions"] = {"tls": tls} + return Request(scope) + + +class _SpyAuthenticator: + def __init__(self, carriers, subject): + self._carriers = tuple(carriers) + self._subject = subject + self.calls = 0 + + async def authenticate(self, request: Request) -> Optional[Credential]: + self.calls += 1 + return Credential( + scheme=SecuritySchemeType.API_KEY, + method=AuthMethod.API_KEY, + subject=self._subject, + ) + + def carriers(self) -> Sequence[Carrier]: + return self._carriers + + def challenge(self) -> str: + return "spy" + + +def _auth(authenticators: List[_SpyAuthenticator]) -> AuthSecurity: + return AuthSecurity(AuthConfig(), _FakeResolver({}), authenticators=authenticators) + + +_BEARER = Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "bearer") +_API_HEADER = Carrier(CredentialLocation.HEADER, "x-litellm-api-key") +_COOKIE = Carrier(CredentialLocation.COOKIE, "litellm_session") + + +def test_dispatch_runs_only_the_matching_authenticator(): + # the bearer authenticator is earlier in order, but the request carries only + # an api key: dispatch must select the api-key authenticator and never touch + # the bearer one + bearer = _SpyAuthenticator([_BEARER], "bearer-subject") + api_key = _SpyAuthenticator([_API_HEADER], "api-subject") + auth = _auth([bearer, api_key]) + + request = _request(headers={"x-litellm-api-key": "sk-x"}) + principal = asyncio.run(auth.principal(SecurityScopes(scopes=[]), request)) + + assert principal.subject == "api-subject" + assert api_key.calls == 1 + assert bearer.calls == 0 + + +def test_first_claiming_authenticator_owns_a_shared_carrier(): + # bearer is read by several schemes; the earliest in scheme_order owns it and + # is the only one ever consulted + first = _SpyAuthenticator([_BEARER], "first") + second = _SpyAuthenticator([_BEARER], "second") + auth = _auth([first, second]) + + request = _request(headers={"authorization": "Bearer abc"}) + principal = asyncio.run(auth.principal(SecurityScopes(scopes=[]), request)) + + assert principal.subject == "first" + assert first.calls == 1 + assert second.calls == 0 + + +def test_untrusted_forwarded_cert_does_not_shadow_session(): + # a spoofed client-cert header from an untrusted peer must not be selected; + # dispatch falls through to the valid session cookie + cert = _SpyAuthenticator( + [ + Carrier( + CredentialLocation.CLIENT_CERTIFICATE, + "x-forwarded-client-cert", + ("9.9.9.9/32",), + ) + ], + "cert", + ) + session = _SpyAuthenticator([_COOKIE], "session") + auth = _auth([cert, session]) + + request = _request( + headers={"x-forwarded-client-cert": "CN=svc"}, + cookies={"litellm_session": "sid"}, + ) + principal = asyncio.run(auth.principal(SecurityScopes(scopes=[]), request)) + + assert principal.subject == "session" + assert cert.calls == 0 + assert session.calls == 1 + + +def test_no_matching_carrier_is_unauthenticated(): + api_key = _SpyAuthenticator([_API_HEADER], "api-subject") + auth = _auth([api_key]) + + with pytest.raises(Exception) as exc_info: + asyncio.run(auth.principal(SecurityScopes(scopes=[]), _request())) + + assert getattr(exc_info.value, "status_code", None) == 401 + assert api_key.calls == 0 + + +def test_authorization_scheme_carrier_discriminates_bearer_from_basic(): + request = _request(headers={"authorization": "Bearer abc"}) + assert _BEARER.present(request) is True + assert ( + Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "basic").present(request) + is False + ) + + +def test_header_carrier_requires_nonempty_value(): + assert _API_HEADER.present(_request(headers={"x-litellm-api-key": "sk"})) is True + assert _API_HEADER.present(_request()) is False + + +def test_cookie_carrier_detects_named_cookie(): + assert _COOKIE.present(_request(cookies={"litellm_session": "sid"})) is True + assert _COOKIE.present(_request(cookies={"other": "x"})) is False + + +def test_client_certificate_carrier_is_trusted_proxy_aware(): + direct = Carrier(CredentialLocation.CLIENT_CERTIFICATE, "x-forwarded-client-cert") + assert direct.present(_request(tls={"client_cert_name": "CN=svc"})) is True + + # request client ip is 1.2.3.4; only a matching trusted CIDR accepts the header + trusted = Carrier( + CredentialLocation.CLIENT_CERTIFICATE, + "x-forwarded-client-cert", + ("1.2.3.4/32",), + ) + untrusted = Carrier( + CredentialLocation.CLIENT_CERTIFICATE, + "x-forwarded-client-cert", + ("9.9.9.9/32",), + ) + header = {"x-forwarded-client-cert": "CN=svc"} + assert trusted.present(_request(headers=header)) is True + assert untrusted.present(_request(headers=header)) is False + assert trusted.present(_request()) is False From 2e999ee1ad00e8817fb9090c42ff8c6e9b2ec35c Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 12 Jun 2026 21:17:09 -0700 Subject: [PATCH 47/51] refactor(auth_v2): flatten resolvers package into resolvers.py + utils.py There was only ever one resolver, so the resolvers/ package (base, db, utils) collapses into a single resolvers.py holding the protocols and DbIdentityStore, plus a utils.py for the pure SCIM/role-mapping helpers. Drops the unused roles_from_claims/public_claims helpers. --- litellm/proxy/auth_v2/README.md | 8 +- .../auth_v2/{resolvers/db.py => resolvers.py} | 96 ++++++++++++++++--- litellm/proxy/auth_v2/resolvers/__init__.py | 15 --- litellm/proxy/auth_v2/resolvers/base.py | 45 --------- .../proxy/auth_v2/{resolvers => }/utils.py | 18 +--- .../proxy/auth_v2/test_resolver.py | 2 +- .../proxy/auth_v2/test_security.py | 2 +- 7 files changed, 91 insertions(+), 95 deletions(-) rename litellm/proxy/auth_v2/{resolvers/db.py => resolvers.py} (72%) delete mode 100644 litellm/proxy/auth_v2/resolvers/__init__.py delete mode 100644 litellm/proxy/auth_v2/resolvers/base.py rename litellm/proxy/auth_v2/{resolvers => }/utils.py (84%) diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index 2c3c4818d143..b316a05e4c8b 100644 --- a/litellm/proxy/auth_v2/README.md +++ b/litellm/proxy/auth_v2/README.md @@ -86,10 +86,10 @@ built once at the composition root. dispatch to it by where its credential lives). `build_authenticators` constructs and orders them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared via `JWTVerifier`. -`resolvers/` holds the `IdentityResolver` / `IdentityStore` protocols and their -implementations (`DbIdentityStore` against Prisma, an in-memory store for tests). The -store also handles SCIM user/group provisioning so a provisioned user is immediately -resolvable. +`resolvers.py` holds the `IdentityResolver` / `IdentityStore` protocols and the single +`DbIdentityStore` implementation against Prisma. The store also handles SCIM user/group +provisioning so a provisioned user is immediately resolvable. `utils.py` holds the pure +SCIM/role-mapping helpers the store uses. `authorization/` holds the `Authorizer` protocol and its implementations: `RBACEngine` (Casbin role hierarchy and policy) and `ABACEngine`. `Role` and the JWT/OIDC/SAML role diff --git a/litellm/proxy/auth_v2/resolvers/db.py b/litellm/proxy/auth_v2/resolvers.py similarity index 72% rename from litellm/proxy/auth_v2/resolvers/db.py rename to litellm/proxy/auth_v2/resolvers.py index 7f7957d60234..fe043b74a245 100644 --- a/litellm/proxy/auth_v2/resolvers/db.py +++ b/litellm/proxy/auth_v2/resolvers.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, Protocol, runtime_checkable from scim2_models import Group as ScimGroup from scim2_models import User as ScimUser @@ -24,8 +24,7 @@ TeamRole, UserIdentity, ) -from litellm.proxy.auth_v2.resolvers.base import IdentityStore -from litellm.proxy.auth_v2.resolvers.utils import ( +from litellm.proxy.auth_v2.utils import ( db_team_to_scim, db_user_to_scim, map_role, @@ -43,6 +42,43 @@ from litellm.proxy.utils import PrismaClient +@runtime_checkable +class IdentityResolver(Protocol): + async def resolve(self, credential: Credential) -> Principal: + """Resolve a verified credential to a Principal. + + Must return a freshly constructed Principal, never a cached or shared + instance. The caller stamps request-scoped state (the network context) + onto the returned object, so handing back a shared one would leak that + state across concurrent requests for the same identity. Cache the + underlying identity lookups (as the DB resolver does), not the assembled + Principal. + """ + ... + + +@runtime_checkable +class ProvisioningStore(Protocol): + async def upsert_user(self, user: ScimUser) -> ScimUser: ... + async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... + async def deactivate_user(self, resource_id: str) -> None: ... + async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: ... + async def upsert_group(self, group: ScimGroup) -> ScimGroup: ... + async def get_group(self, resource_id: str) -> Optional[ScimGroup]: ... + async def delete_group(self, resource_id: str) -> None: ... + async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... + + +@runtime_checkable +class IdentityStore(IdentityResolver, ProvisioningStore, Protocol): + """An identity backend: resolves credentials and provisions SCIM users/groups. + + This is the single interface every implementation satisfies (in-memory, + database, ...). Resolution and provisioning live behind one store so a + provisioned user is immediately resolvable. + """ + + class DbIdentityStore(IdentityStore): """Resolves credentials against the proxy's Prisma tables and provisions SCIM users/groups into ``LiteLLM_UserTable`` / ``LiteLLM_TeamTable``. @@ -105,18 +141,30 @@ def _service_account(self, credential: Credential) -> Principal: credential_ref=credential.credential_ref, ) - def _principal_from_key(self, credential: Credential, key: UserAPIKeyAuth) -> Principal: + def _principal_from_key( + self, credential: Credential, key: UserAPIKeyAuth + ) -> Principal: teams: List[TeamIdentity] = [] if key.team_id is not None: - role = team_role(key.team_member.role) if key.team_member else TeamRole.MEMBER + role = ( + team_role(key.team_member.role) if key.team_member else TeamRole.MEMBER + ) teams.append(TeamIdentity(id=key.team_id, name=key.team_alias, role=role)) organization = ( - OrganizationIdentity(id=key.org_id, name=key.organization_alias) if key.org_id is not None else None + OrganizationIdentity(id=key.org_id, name=key.organization_alias) + if key.org_id is not None + else None + ) + user = ( + UserIdentity(id=key.user_id, email=key.user_email) + if key.user_id is not None + else None ) - user = UserIdentity(id=key.user_id, email=key.user_email) if key.user_id is not None else None mapped = map_role(key.user_role) return Principal( - principal_type=(PrincipalType.HUMAN if key.user_id else PrincipalType.SERVICE_ACCOUNT), + principal_type=( + PrincipalType.HUMAN if key.user_id else PrincipalType.SERVICE_ACCOUNT + ), subject=key.user_id or key.key_alias or credential.subject, issuer=credential.issuer, user=user, @@ -128,7 +176,9 @@ def _principal_from_key(self, credential: Credential, key: UserAPIKeyAuth) -> Pr credential_ref=credential.credential_ref, ) - async def _principal_from_user(self, credential: Credential, user: "LiteLLM_UserTable") -> Principal: + async def _principal_from_user( + self, credential: Credential, user: "LiteLLM_UserTable" + ) -> Principal: teams: List[TeamIdentity] = [] for team_id in user.teams or []: try: @@ -164,7 +214,9 @@ async def _principal_from_user(self, credential: Credential, user: "LiteLLM_User credential_ref=credential.credential_ref, ) - async def _organization(self, user: "LiteLLM_UserTable") -> Optional[OrganizationIdentity]: + async def _organization( + self, user: "LiteLLM_UserTable" + ) -> Optional[OrganizationIdentity]: if user.organization_id is None: return None try: @@ -180,7 +232,11 @@ async def _organization(self, user: "LiteLLM_UserTable") -> Optional[Organizatio async def upsert_user(self, user: ScimUser) -> ScimUser: repo = UserRepository(self._prisma) data = scim_user_to_db(user) - existing = await repo.table.find_unique(where={"user_id": user.id}) if user.id else None + existing = ( + await repo.table.find_unique(where={"user_id": user.id}) + if user.id + else None + ) if existing is None: data["user_id"] = user.id or str(uuid.uuid4()) stored = await repo.table.create(data=data) @@ -189,7 +245,9 @@ async def upsert_user(self, user: ScimUser) -> ScimUser: return db_user_to_scim(stored) async def get_user(self, resource_id: str) -> Optional[ScimUser]: - stored = await UserRepository(self._prisma).table.find_unique(where={"user_id": resource_id}) + stored = await UserRepository(self._prisma).table.find_unique( + where={"user_id": resource_id} + ) return db_user_to_scim(stored) if stored is not None else None async def deactivate_user(self, resource_id: str) -> None: @@ -199,7 +257,9 @@ async def deactivate_user(self, resource_id: str) -> None: return metadata = dict(getattr(stored, "metadata", None) or {}) metadata["scim_active"] = False - await repo.table.update(where={"user_id": resource_id}, data={"metadata": metadata}) + await repo.table.update( + where={"user_id": resource_id}, data={"metadata": metadata} + ) async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: rows = await UserRepository(self._prisma).table.find_many() @@ -208,7 +268,11 @@ async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: async def upsert_group(self, group: ScimGroup) -> ScimGroup: repo = TeamRepository(self._prisma) data = scim_group_to_db(group) - existing = await repo.table.find_unique(where={"team_id": group.id}) if group.id else None + existing = ( + await repo.table.find_unique(where={"team_id": group.id}) + if group.id + else None + ) if existing is None: data["team_id"] = group.id or str(uuid.uuid4()) stored = await repo.table.create(data=data) @@ -217,7 +281,9 @@ async def upsert_group(self, group: ScimGroup) -> ScimGroup: return db_team_to_scim(stored) async def get_group(self, resource_id: str) -> Optional[ScimGroup]: - stored = await TeamRepository(self._prisma).table.find_unique(where={"team_id": resource_id}) + stored = await TeamRepository(self._prisma).table.find_unique( + where={"team_id": resource_id} + ) return db_team_to_scim(stored) if stored is not None else None async def delete_group(self, resource_id: str) -> None: diff --git a/litellm/proxy/auth_v2/resolvers/__init__.py b/litellm/proxy/auth_v2/resolvers/__init__.py deleted file mode 100644 index 546366a1b947..000000000000 --- a/litellm/proxy/auth_v2/resolvers/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from litellm.proxy.auth_v2.resolvers.base import ( - IdentityResolver, - IdentityStore, - ProvisioningStore, -) - -# DbIdentityStore is intentionally not re-exported here: it pulls in the v1 -# proxy DB machinery (auth_checks, repositories). Import it directly from -# litellm.proxy.auth_v2.resolvers.db when wiring a database-backed store. - -__all__ = [ - "IdentityResolver", - "ProvisioningStore", - "IdentityStore", -] diff --git a/litellm/proxy/auth_v2/resolvers/base.py b/litellm/proxy/auth_v2/resolvers/base.py deleted file mode 100644 index 046f7f753042..000000000000 --- a/litellm/proxy/auth_v2/resolvers/base.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Protocol, runtime_checkable - -from scim2_models import Group as ScimGroup -from scim2_models import User as ScimUser - -from litellm.proxy.auth_v2.models import Credential, Principal - - -@runtime_checkable -class IdentityResolver(Protocol): - async def resolve(self, credential: Credential) -> Principal: - """Resolve a verified credential to a Principal. - - Must return a freshly constructed Principal, never a cached or shared - instance. The caller stamps request-scoped state (the network context) - onto the returned object, so handing back a shared one would leak that - state across concurrent requests for the same identity. Cache the - underlying identity lookups (as the DB resolver does), not the assembled - Principal. - """ - ... - - -@runtime_checkable -class ProvisioningStore(Protocol): - async def upsert_user(self, user: ScimUser) -> ScimUser: ... - async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... - async def deactivate_user(self, resource_id: str) -> None: ... - async def list_users(self, filter_expr: Optional[str]) -> List[ScimUser]: ... - async def upsert_group(self, group: ScimGroup) -> ScimGroup: ... - async def get_group(self, resource_id: str) -> Optional[ScimGroup]: ... - async def delete_group(self, resource_id: str) -> None: ... - async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... - - -@runtime_checkable -class IdentityStore(IdentityResolver, ProvisioningStore, Protocol): - """An identity backend: resolves credentials and provisions SCIM users/groups. - - This is the single interface every implementation satisfies (in-memory, - database, ...). Resolution and provisioning live behind one store so a - provisioned user is immediately resolvable. - """ diff --git a/litellm/proxy/auth_v2/resolvers/utils.py b/litellm/proxy/auth_v2/utils.py similarity index 84% rename from litellm/proxy/auth_v2/resolvers/utils.py rename to litellm/proxy/auth_v2/utils.py index 9a85ced7a105..9eda35cc89cb 100644 --- a/litellm/proxy/auth_v2/resolvers/utils.py +++ b/litellm/proxy/auth_v2/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import hashlib -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from scim2_models import Email, Name from scim2_models import Group as ScimGroup @@ -19,18 +19,6 @@ def hash_api_key(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() -def roles_from_claims(claims: Dict[str, Any]) -> List[Role]: - raw = claims.get("roles", []) - if not isinstance(raw, list): - return [] - valid = {role.value for role in Role} - return [Role(value) for value in raw if value in valid] - - -def public_claims(claims: Dict[str, Any]) -> Dict[str, Any]: - return {key: value for key, value in claims.items() if not key.startswith("_")} - - _ROLE_MAP: Dict[str, Role] = { "proxy_admin": Role.PLATFORM_ADMIN, "proxy_admin_viewer": Role.PLATFORM_VIEWER, @@ -96,7 +84,9 @@ def db_user_to_scim(user: "LiteLLM_UserTable") -> ScimUser: def scim_group_to_db(group: ScimGroup) -> Dict[str, object]: - members = [{"user_id": member.value, "role": "user"} for member in (group.members or [])] + members = [ + {"user_id": member.value, "role": "user"} for member in (group.members or []) + ] return {"team_alias": group.display_name, "members_with_roles": members} diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index a53b04a84e12..f2d40258081d 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -14,7 +14,7 @@ PrincipalType, SecuritySchemeType, ) -from litellm.proxy.auth_v2.resolvers.db import DbIdentityStore +from litellm.proxy.auth_v2.resolvers import DbIdentityStore class _FakeCache: diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index 9f01dfc29d18..1f41d898ce35 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -29,7 +29,7 @@ SecuritySchemeType, ) from litellm.proxy.auth_v2.authorization import RBACEngine, Role -from litellm.proxy.auth_v2.resolvers.utils import hash_api_key +from litellm.proxy.auth_v2.utils import hash_api_key from litellm.proxy.auth_v2.security import AuthSecurity from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient From 6e51b3fc9008a28718cc935f1a0024347db1d21e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 21:47:18 +0000 Subject: [PATCH 48/51] style(auth_v2): apply black formatting to satisfy lint --- .../proxy/auth_v2/authenticators/config.py | 12 +++++-- litellm/proxy/auth_v2/authenticators/utils.py | 31 ++++++++++++++----- litellm/proxy/auth_v2/authorization/rbac.py | 4 ++- litellm/proxy/auth_v2/authorization/roles.py | 4 ++- litellm/proxy/auth_v2/errors.py | 12 +++++-- litellm/proxy/auth_v2/sessions/base.py | 4 ++- litellm/proxy/auth_v2/sessions/memory.py | 8 +++-- litellm/proxy/auth_v2/sessions/redis.py | 12 +++++-- 8 files changed, 66 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators/config.py b/litellm/proxy/auth_v2/authenticators/config.py index 9002cb128f56..a60c12119383 100644 --- a/litellm/proxy/auth_v2/authenticators/config.py +++ b/litellm/proxy/auth_v2/authenticators/config.py @@ -21,9 +21,15 @@ def build_authenticators( by_scheme: Dict[SecuritySchemeType, Authenticator] = {} if config.api_key is not None: by_scheme[SecuritySchemeType.API_KEY] = APIKeyAuthenticator(config.api_key) - by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator(config.http_basic, verifiers, basic_verifier) + by_scheme[SecuritySchemeType.HTTP] = HttpAuthenticator( + config.http_basic, verifiers, basic_verifier + ) by_scheme[SecuritySchemeType.OPENID_CONNECT] = OIDCAuthenticator(verifiers) - by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator(verifiers, config.oauth2_introspection) + by_scheme[SecuritySchemeType.OAUTH2] = OAuth2Authenticator( + verifiers, config.oauth2_introspection + ) if config.mutual_tls.enabled: - by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTLSAuthenticator(config.mutual_tls, config.network) + by_scheme[SecuritySchemeType.MUTUAL_TLS] = MutualTLSAuthenticator( + config.mutual_tls, config.network + ) return [by_scheme[scheme] for scheme in config.scheme_order if scheme in by_scheme] diff --git a/litellm/proxy/auth_v2/authenticators/utils.py b/litellm/proxy/auth_v2/authenticators/utils.py index eab8311dc350..f205f471f33c 100644 --- a/litellm/proxy/auth_v2/authenticators/utils.py +++ b/litellm/proxy/auth_v2/authenticators/utils.py @@ -11,7 +11,12 @@ from starlette.concurrency import run_in_threadpool from litellm.proxy.auth_v2 import errors -from litellm.proxy.auth_v2.models import AuthMethod, Credential, CredentialRef, SecuritySchemeType +from litellm.proxy.auth_v2.models import ( + AuthMethod, + Credential, + CredentialRef, + SecuritySchemeType, +) from litellm.proxy.auth_v2.config import OIDCProviderConfig from litellm.proxy.auth_v2.authorization import filter_claim_roles from litellm.proxy.auth_v2.authenticators.types import Claims @@ -20,7 +25,9 @@ def apply_role_policy(claims: Claims, provider: OIDCProviderConfig) -> None: - claims["roles"] = filter_claim_roles(claims.get("roles"), provider.allowed_roles, provider.allow_platform_roles) + claims["roles"] = filter_claim_roles( + claims.get("roles"), provider.allowed_roles, provider.allow_platform_roles + ) def extract_bearer(request: Request) -> Optional[str]: @@ -65,7 +72,9 @@ def credential_from_claims( audience=normalize_audience(claims.get("aud")), scopes=split_scope(claims.get("scope")), claims=claims, - credential_ref=CredentialRef(key_id=header.get("kid"), token_id=claims.get("jti")), + credential_ref=CredentialRef( + key_id=header.get("kid"), token_id=claims.get("jti") + ), subject_token=token, ) @@ -80,7 +89,9 @@ def __init__( if jwks_client is not None: self._jwks_client = jwks_client return - jwks_uri = str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() + jwks_uri = ( + str(provider.jwks_uri) if provider.jwks_uri else self._discover_jwks() + ) self._jwks_client = PyJWKClient( jwks_uri, cache_keys=True, @@ -99,7 +110,9 @@ def _discover_jwks(self) -> str: return str(jwks_uri) def verify(self, token: str, *, require_at_jwt: Optional[bool] = None) -> Claims: - enforce = self.provider.require_at_jwt if require_at_jwt is None else require_at_jwt + enforce = ( + self.provider.require_at_jwt if require_at_jwt is None else require_at_jwt + ) if enforce: header = jwt.get_unverified_header(token) if str(header.get("typ", "")).lower() not in AT_JWT_TYPES: @@ -118,8 +131,12 @@ def verify(self, token: str, *, require_at_jwt: Optional[bool] = None) -> Claims raise errors.invalid_token("token verification failed") from exc -async def _verify_jwt_off_loop(verifier: JWTVerifier, token: str, *, require_at_jwt: Optional[bool] = None) -> Claims: - return await run_in_threadpool(functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt)) +async def _verify_jwt_off_loop( + verifier: JWTVerifier, token: str, *, require_at_jwt: Optional[bool] = None +) -> Claims: + return await run_in_threadpool( + functools.partial(verifier.verify, token, require_at_jwt=require_at_jwt) + ) def _select_verifier(token: str, verifiers: List[JWTVerifier]) -> Optional[JWTVerifier]: diff --git a/litellm/proxy/auth_v2/authorization/rbac.py b/litellm/proxy/auth_v2/authorization/rbac.py index 5ff34e006521..5f8c15a997ae 100644 --- a/litellm/proxy/auth_v2/authorization/rbac.py +++ b/litellm/proxy/auth_v2/authorization/rbac.py @@ -56,7 +56,9 @@ def __init__(self, policy_path: Optional[str] = None) -> None: self._enforcer.add_policy(*rule) def enforce(self, principal: "Principal", obj: str, act: str) -> bool: - return any(self._enforcer.enforce(role.value, obj, act) for role in principal.roles) + return any( + self._enforcer.enforce(role.value, obj, act) for role in principal.roles + ) def has_any_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool: allowed_values = {role.value for role in allowed} diff --git a/litellm/proxy/auth_v2/authorization/roles.py b/litellm/proxy/auth_v2/authorization/roles.py index c05b61be0106..3110760c9adb 100644 --- a/litellm/proxy/auth_v2/authorization/roles.py +++ b/litellm/proxy/auth_v2/authorization/roles.py @@ -16,7 +16,9 @@ class Role(str, Enum): _PLATFORM_ROLE_VALUES = {Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value} -def filter_claim_roles(roles: Any, allowed_roles: List[str], allow_platform_roles: bool) -> List[str]: +def filter_claim_roles( + roles: Any, allowed_roles: List[str], allow_platform_roles: bool +) -> List[str]: if not isinstance(roles, list): return [] allowed = set(allowed_roles) diff --git a/litellm/proxy/auth_v2/errors.py b/litellm/proxy/auth_v2/errors.py index 6e1b37617725..0b8d26c4bf36 100644 --- a/litellm/proxy/auth_v2/errors.py +++ b/litellm/proxy/auth_v2/errors.py @@ -6,12 +6,16 @@ class AuthError(HTTPException): - def __init__(self, status_code: int, detail: str, challenge: Optional[str] = None) -> None: + def __init__( + self, status_code: int, detail: str, challenge: Optional[str] = None + ) -> None: headers = {"WWW-Authenticate": challenge} if challenge else None super().__init__(status_code=status_code, detail=detail, headers=headers) -def bearer_challenge(error: Optional[str] = None, description: Optional[str] = None) -> str: +def bearer_challenge( + error: Optional[str] = None, description: Optional[str] = None +) -> str: parts = ['Bearer realm="litellm"'] if error: parts.append(f'error="{error}"') @@ -29,7 +33,9 @@ def unauthenticated(challenge: str) -> AuthError: def invalid_token(description: Optional[str] = None) -> AuthError: - return AuthError(401, "Invalid token", bearer_challenge("invalid_token", description)) + return AuthError( + 401, "Invalid token", bearer_challenge("invalid_token", description) + ) def insufficient_scope() -> AuthError: diff --git a/litellm/proxy/auth_v2/sessions/base.py b/litellm/proxy/auth_v2/sessions/base.py index e82353dc5a7a..a979a2fb4e1b 100644 --- a/litellm/proxy/auth_v2/sessions/base.py +++ b/litellm/proxy/auth_v2/sessions/base.py @@ -16,7 +16,9 @@ class SessionStore(Protocol[SessionValue]): async def get(self, key: str) -> Optional[SessionValue]: ... - async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: ... + async def set( + self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None + ) -> None: ... async def pop(self, key: str) -> Optional[SessionValue]: ... diff --git a/litellm/proxy/auth_v2/sessions/memory.py b/litellm/proxy/auth_v2/sessions/memory.py index ce19b07c22e9..8f3e6259a84e 100644 --- a/litellm/proxy/auth_v2/sessions/memory.py +++ b/litellm/proxy/auth_v2/sessions/memory.py @@ -21,7 +21,9 @@ def _key(self, key: str) -> str: def _expiry(self, ttl_seconds: Optional[int]) -> float: return time.time() + (self._default_ttl if ttl_seconds is None else ttl_seconds) - def _live(self, key: str, now: float) -> Optional[Tuple[float, Optional[SessionValue]]]: + def _live( + self, key: str, now: float + ) -> Optional[Tuple[float, Optional[SessionValue]]]: entry = self._entries.get(key) if entry is None: return None @@ -34,7 +36,9 @@ async def get(self, key: str) -> Optional[SessionValue]: entry = self._live(self._key(key), time.time()) return entry[1] if entry is not None else None - async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: + async def set( + self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None + ) -> None: self._evict(time.time()) self._entries[self._key(key)] = (self._expiry(ttl_seconds), value) diff --git a/litellm/proxy/auth_v2/sessions/redis.py b/litellm/proxy/auth_v2/sessions/redis.py index 7242c89ebdab..370e4236328f 100644 --- a/litellm/proxy/auth_v2/sessions/redis.py +++ b/litellm/proxy/auth_v2/sessions/redis.py @@ -27,8 +27,12 @@ async def get(self, key: str) -> Optional[SessionValue]: raw = await self._client.get(self._key(key)) return cast(SessionValue, json.loads(raw)) if raw is not None else None - async def set(self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None) -> None: - await self._client.set(self._key(key), json.dumps(value), ex=self._ttl(ttl_seconds)) + async def set( + self, key: str, value: SessionValue, ttl_seconds: Optional[int] = None + ) -> None: + await self._client.set( + self._key(key), json.dumps(value), ex=self._ttl(ttl_seconds) + ) async def pop(self, key: str) -> Optional[SessionValue]: raw = await self._client.getdel(self._key(key)) @@ -38,5 +42,7 @@ async def delete(self, key: str) -> None: await self._client.delete(self._key(key)) async def add_if_absent(self, key: str, ttl_seconds: Optional[int] = None) -> bool: - added = await self._client.set(self._key(key), "1", nx=True, ex=self._ttl(ttl_seconds)) + added = await self._client.set( + self._key(key), "1", nx=True, ex=self._ttl(ttl_seconds) + ) return bool(added) From ffbcb8b48c1cac9b835fd805f72079e9abc4d722 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 22:00:08 +0000 Subject: [PATCH 49/51] fix(auth_v2): resolve mypy arg-type errors in security and jwt credential mapping --- litellm/proxy/auth_v2/authenticators/utils.py | 3 ++- litellm/proxy/auth_v2/security.py | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth_v2/authenticators/utils.py b/litellm/proxy/auth_v2/authenticators/utils.py index f205f471f33c..42bf209f3749 100644 --- a/litellm/proxy/auth_v2/authenticators/utils.py +++ b/litellm/proxy/auth_v2/authenticators/utils.py @@ -64,6 +64,7 @@ def credential_from_claims( ) -> Credential: header = jwt.get_unverified_header(token) issuer = claims.get("iss") + jti = claims.get("jti") return Credential( scheme=scheme, method=method, @@ -73,7 +74,7 @@ def credential_from_claims( scopes=split_scope(claims.get("scope")), claims=claims, credential_ref=CredentialRef( - key_id=header.get("kid"), token_id=claims.get("jti") + key_id=header.get("kid"), token_id=jti if isinstance(jti, str) else None ), subject_token=token, ) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index 9fd3562af50c..8ee83ac5c577 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -1,9 +1,12 @@ import os -from typing import Annotated, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Annotated, Callable, Dict, List, Optional, cast from fastapi import Request, Security from fastapi.security import SecurityScopes +if TYPE_CHECKING: + from redis.asyncio import Redis + from litellm._redis import get_redis_async_client from litellm.proxy.auth_v2 import errors from litellm.proxy.auth_v2.authenticators import ( @@ -50,7 +53,9 @@ def _open_session_store( silently stranding state on one pod. """ if any(os.getenv(signal) for signal in _REDIS_ENV_SIGNALS): - return RedisSessionStore(get_redis_async_client(), namespace, default_ttl) + return RedisSessionStore( + cast("Redis", get_redis_async_client()), namespace, default_ttl + ) return InMemorySessionStore(namespace, default_ttl) From 03f1c11e9b30d0762eb16f5b5d2052e38caedfc5 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 17 Jun 2026 13:57:04 -0700 Subject: [PATCH 50/51] feat(auth_v2): add project and end_user identity to Principal Principal carried user, organization and teams but omitted the project and end-user attribution axes that the existing key path tracks. Add ProjectIdentity and EndUserIdentity sub-models and the matching optional fields, and project them off the key object in _principal_from_key: project_id/project_alias map to ProjectIdentity, end_user_id to EndUserIdentity. Both stay None when absent. project_id is a column on the verification token, so it resolves from the combined-view key object directly. end_user_id is request-scoped and will be stamped at the seam, the same way network context is; the resolver maps it whenever the key carries it. --- litellm/proxy/auth_v2/README.md | 5 +-- litellm/proxy/auth_v2/models.py | 11 +++++++ litellm/proxy/auth_v2/resolvers.py | 12 +++++++ .../test_litellm/proxy/auth_v2/test_models.py | 14 +++++++++ .../proxy/auth_v2/test_resolver.py | 31 +++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index b316a05e4c8b..2bdd8143f978 100644 --- a/litellm/proxy/auth_v2/README.md +++ b/litellm/proxy/auth_v2/README.md @@ -16,8 +16,9 @@ the scheme, the subject, scopes/claims from the token, and, for exchangeable bea tokens, the raw token for downstream RFC 8693 token exchange. `Principal` is what the route handler receives: a normalized identity with the user, -organization, teams, roles and scopes filled in. It holds identity only, no budget or -policy state. Scope checking lives here as `Principal.has_required_scopes`. +organization, teams, project, end user, roles and scopes filled in. It holds identity +only, no budget or policy state. Scope checking lives here as +`Principal.has_required_scopes`. The split matters: authentication proves the credential, resolution turns the proven credential into a known identity, and only then can authorization run. diff --git a/litellm/proxy/auth_v2/models.py b/litellm/proxy/auth_v2/models.py index c94417489a50..d5afb46fdbfa 100644 --- a/litellm/proxy/auth_v2/models.py +++ b/litellm/proxy/auth_v2/models.py @@ -68,6 +68,15 @@ class TeamIdentity(BaseModel): role: TeamRole = TeamRole.MEMBER +class ProjectIdentity(BaseModel): + id: str + name: Optional[str] = None + + +class EndUserIdentity(BaseModel): + id: str + + class CredentialRef(BaseModel): key_id: Optional[str] = None token_id: Optional[str] = None @@ -118,6 +127,8 @@ class Principal(BaseModel): user: Optional[UserIdentity] = None organization: Optional[OrganizationIdentity] = None teams: List[TeamIdentity] = Field(default_factory=list) + project: Optional[ProjectIdentity] = None + end_user: Optional[EndUserIdentity] = None roles: List[Role] = Field(default_factory=list) scopes: List[str] = Field(default_factory=list) diff --git a/litellm/proxy/auth_v2/resolvers.py b/litellm/proxy/auth_v2/resolvers.py index fe043b74a245..156c435ec3b6 100644 --- a/litellm/proxy/auth_v2/resolvers.py +++ b/litellm/proxy/auth_v2/resolvers.py @@ -17,9 +17,11 @@ from litellm.proxy.auth_v2.models import ( AuthMethod, Credential, + EndUserIdentity, OrganizationIdentity, Principal, PrincipalType, + ProjectIdentity, TeamIdentity, TeamRole, UserIdentity, @@ -160,6 +162,14 @@ def _principal_from_key( if key.user_id is not None else None ) + project = ( + ProjectIdentity(id=key.project_id, name=key.project_alias) + if key.project_id is not None + else None + ) + end_user = ( + EndUserIdentity(id=key.end_user_id) if key.end_user_id is not None else None + ) mapped = map_role(key.user_role) return Principal( principal_type=( @@ -170,6 +180,8 @@ def _principal_from_key( user=user, organization=organization, teams=teams, + project=project, + end_user=end_user, roles=[mapped] if mapped else [], scopes=list(credential.scopes), auth_method=credential.method, diff --git a/tests/test_litellm/proxy/auth_v2/test_models.py b/tests/test_litellm/proxy/auth_v2/test_models.py index aa330e14b201..e36c83b84842 100644 --- a/tests/test_litellm/proxy/auth_v2/test_models.py +++ b/tests/test_litellm/proxy/auth_v2/test_models.py @@ -6,8 +6,10 @@ from litellm.proxy.auth_v2.models import ( AuthMethod, Credential, + EndUserIdentity, Principal, PrincipalType, + ProjectIdentity, SecuritySchemeType, TeamIdentity, TeamRole, @@ -70,6 +72,8 @@ def test_principal_default_network_and_collections(): ) assert principal.teams == [] assert principal.scopes == [] + assert principal.project is None + assert principal.end_user is None assert principal.network.client_ip is None assert principal.network.via_trusted_proxy is False @@ -91,3 +95,13 @@ def test_user_identity_optional_fields_default_none(): user = UserIdentity(id="u1") assert user.email is None assert user.external_id is None + + +def test_project_identity_name_is_optional(): + assert ProjectIdentity(id="p1").name is None + assert ProjectIdentity(id="p1", name="Acme").name == "Acme" + + +def test_end_user_identity_requires_id(): + with pytest.raises(ValidationError): + EndUserIdentity() diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index f2d40258081d..7ff048466ea7 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -77,6 +77,37 @@ async def test_api_key_resolves_to_principal_with_db_role(): assert principal.roles == [Role.ORG_ADMIN] +async def test_api_key_principal_carries_project_and_end_user(): + raw = "sk-live-proj" + key = UserAPIKeyAuth( + token=hash_token(raw), + user_id="u-1", + project_id="proj-1", + project_alias="Acme Prod", + end_user_id="cust-7", + ) + store = _store({hash_token(raw): key}) + + principal = await store.resolve(_api_key_credential(raw)) + + assert principal.project is not None + assert principal.project.id == "proj-1" + assert principal.project.name == "Acme Prod" + assert principal.end_user is not None + assert principal.end_user.id == "cust-7" + + +async def test_api_key_principal_omits_project_and_end_user_when_absent(): + raw = "sk-live-bare" + key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1") + store = _store({hash_token(raw): key}) + + principal = await store.resolve(_api_key_credential(raw)) + + assert principal.project is None + assert principal.end_user is None + + async def test_api_key_lookup_is_keyed_on_hashed_token(): raw = "sk-live-abc" key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1") From 770ae5902ce498ec42382f4f383dea9045bbee37 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 18 Jun 2026 17:56:57 -0700 Subject: [PATCH 51/51] refactor(auth_v2): rename resolver classes, drop dead protocol boilerplate Rename IdentityResolver -> Resolver and DbIdentityStore -> DbResolver so the names stop colliding with authentication; the package these move to is resolvers/, not identity/. Drop the IdentityStore union protocol, which was only used as DbResolver's base; DbResolver now inherits Resolver and ProvisioningStore directly. Drop the @runtime_checkable decorators on these protocols, which had no isinstance callers. ProvisioningStore keeps its name. --- litellm/proxy/auth_v2/README.md | 8 ++--- litellm/proxy/auth_v2/__init__.py | 4 +-- litellm/proxy/auth_v2/resolvers.py | 30 +++++-------------- litellm/proxy/auth_v2/security.py | 4 +-- .../proxy/auth_v2/test_resolver.py | 14 ++++----- .../proxy/auth_v2/test_security.py | 4 +-- 6 files changed, 24 insertions(+), 40 deletions(-) diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index 2bdd8143f978..1813222f3f3c 100644 --- a/litellm/proxy/auth_v2/README.md +++ b/litellm/proxy/auth_v2/README.md @@ -46,7 +46,7 @@ always run first. `401` with a combined `WWW-Authenticate` challenge built from each scheme. 2. Resolve identity. The verified `Credential` is handed to the configured - `IdentityResolver`, which builds the `Principal`. The DB resolver looks the subject up in + `Resolver`, which builds the `Principal`. The DB resolver looks the subject up in the proxy's Prisma tables (key object, user, teams, org). A blocked key or unknown subject raises `401`/`403` here, before any route logic runs. @@ -87,8 +87,8 @@ built once at the composition root. dispatch to it by where its credential lives). `build_authenticators` constructs and orders them from `AuthConfig`. JWT verification for OIDC/OAuth2 is shared via `JWTVerifier`. -`resolvers.py` holds the `IdentityResolver` / `IdentityStore` protocols and the single -`DbIdentityStore` implementation against Prisma. The store also handles SCIM user/group +`resolvers.py` holds the `Resolver` and `ProvisioningStore` protocols and the single +`DbResolver` implementation against Prisma. The store also handles SCIM user/group provisioning so a provisioned user is immediately resolvable. `utils.py` holds the pure SCIM/role-mapping helpers the store uses. @@ -108,7 +108,7 @@ the right status and challenge header. ## Adding things A new credential scheme is a new `Authenticator` plus a branch in `build_authenticators`. -A new identity backend is a new `IdentityStore`. A new authorization method (ReBAC, an +A new identity backend is a new `Resolver` (also a `ProvisioningStore` if it provisions). A new authorization method (ReBAC, an external PDP) is a new `Authorizer` passed as `AuthSecurity(..., authorizer=...)`. Dependencies are injected at construction, so each of these is unit-testable with a fake in place of the real backend. diff --git a/litellm/proxy/auth_v2/__init__.py b/litellm/proxy/auth_v2/__init__.py index 9c28253c9341..e5fc4702eb22 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -11,7 +11,7 @@ ) from litellm.proxy.auth_v2.models import Principal from litellm.proxy.auth_v2.authorization import Role -from litellm.proxy.auth_v2.resolvers import IdentityResolver, ProvisioningStore +from litellm.proxy.auth_v2.resolvers import ProvisioningStore, Resolver from litellm.proxy.auth_v2.security import AuthSecurity __all__ = [ @@ -19,7 +19,7 @@ "AuthConfig", "Principal", "Role", - "IdentityResolver", + "Resolver", "ProvisioningStore", "ApiKeySchemeConfig", "HttpBasicConfig", diff --git a/litellm/proxy/auth_v2/resolvers.py b/litellm/proxy/auth_v2/resolvers.py index 156c435ec3b6..5c259adecb6e 100644 --- a/litellm/proxy/auth_v2/resolvers.py +++ b/litellm/proxy/auth_v2/resolvers.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from typing import TYPE_CHECKING, List, Optional, Protocol, runtime_checkable +from typing import TYPE_CHECKING, List, Optional, Protocol from scim2_models import Group as ScimGroup from scim2_models import User as ScimUser @@ -44,8 +44,7 @@ from litellm.proxy.utils import PrismaClient -@runtime_checkable -class IdentityResolver(Protocol): +class Resolver(Protocol): async def resolve(self, credential: Credential) -> Principal: """Resolve a verified credential to a Principal. @@ -59,7 +58,6 @@ async def resolve(self, credential: Credential) -> Principal: ... -@runtime_checkable class ProvisioningStore(Protocol): async def upsert_user(self, user: ScimUser) -> ScimUser: ... async def get_user(self, resource_id: str) -> Optional[ScimUser]: ... @@ -71,31 +69,20 @@ async def delete_group(self, resource_id: str) -> None: ... async def list_groups(self, filter_expr: Optional[str]) -> List[ScimGroup]: ... -@runtime_checkable -class IdentityStore(IdentityResolver, ProvisioningStore, Protocol): - """An identity backend: resolves credentials and provisions SCIM users/groups. - - This is the single interface every implementation satisfies (in-memory, - database, ...). Resolution and provisioning live behind one store so a - provisioned user is immediately resolvable. - """ - - -class DbIdentityStore(IdentityStore): +class DbResolver(Resolver, ProvisioningStore): """Resolves credentials against the proxy's Prisma tables and provisions SCIM users/groups into ``LiteLLM_UserTable`` / ``LiteLLM_TeamTable``. - The Prisma client and key cache are injected so this stays a plain object - the composition root can build once the proxy DB is connected. + Resolution and provisioning share one object so a provisioned user is + immediately resolvable. The Prisma client and key cache are injected so this + stays a plain object the composition root can build once the proxy DB is + connected. """ def __init__(self, prisma_client: "PrismaClient", cache: "DualCache") -> None: self._prisma = prisma_client self._cache = cache - # ------------------------------------------------------------------ # - # IdentityResolver - # ------------------------------------------------------------------ # async def resolve(self, credential: Credential) -> Principal: if credential.method == AuthMethod.API_KEY: return await self._resolve_api_key(credential) @@ -238,9 +225,6 @@ async def _organization( name = org.organization_alias if org is not None else None return OrganizationIdentity(id=user.organization_id, name=name) - # ------------------------------------------------------------------ # - # ProvisioningStore - # ------------------------------------------------------------------ # async def upsert_user(self, user: ScimUser) -> ScimUser: repo = UserRepository(self._prisma) data = scim_user_to_db(user) diff --git a/litellm/proxy/auth_v2/security.py b/litellm/proxy/auth_v2/security.py index 8ee83ac5c577..68661ddab29b 100644 --- a/litellm/proxy/auth_v2/security.py +++ b/litellm/proxy/auth_v2/security.py @@ -24,7 +24,7 @@ Role, ) from litellm.proxy.auth_v2.authenticators.session import SessionAuthenticator -from litellm.proxy.auth_v2.resolvers import IdentityResolver +from litellm.proxy.auth_v2.resolvers import Resolver from litellm.proxy.auth_v2.sessions import ( InMemorySessionStore, RedisSessionStore, @@ -83,7 +83,7 @@ class AuthSecurity: def __init__( self, config: AuthConfig, - resolver: IdentityResolver, + resolver: Resolver, authorizer: Optional[Authorizer] = None, authenticators: Optional[List[Authenticator]] = None, basic_verifier: Optional[BasicAuthVerifier] = None, diff --git a/tests/test_litellm/proxy/auth_v2/test_resolver.py b/tests/test_litellm/proxy/auth_v2/test_resolver.py index 7ff048466ea7..039113badb61 100644 --- a/tests/test_litellm/proxy/auth_v2/test_resolver.py +++ b/tests/test_litellm/proxy/auth_v2/test_resolver.py @@ -14,14 +14,14 @@ PrincipalType, SecuritySchemeType, ) -from litellm.proxy.auth_v2.resolvers import DbIdentityStore +from litellm.proxy.auth_v2.resolvers import DbResolver class _FakeCache: """Stands in for the DualCache that get_key_object / get_user_object read. Both helpers return a cache hit before touching the DB, so seeding this and - injecting it into DbIdentityStore exercises the real resolver mapping without + injecting it into DbResolver exercises the real resolver mapping without a database. A non-None prisma client is still required (the helpers guard on it); it is never reached on a hit. """ @@ -39,8 +39,8 @@ async def async_set_cache(self, *args, **kwargs): _PRISMA_STUB = object() -def _store(entries: Optional[Dict[str, object]] = None) -> DbIdentityStore: - return DbIdentityStore(_PRISMA_STUB, _FakeCache(entries)) +def _store(entries: Optional[Dict[str, object]] = None) -> DbResolver: + return DbResolver(_PRISMA_STUB, _FakeCache(entries)) def _api_key_credential(raw: str) -> Credential: @@ -112,7 +112,7 @@ async def test_api_key_lookup_is_keyed_on_hashed_token(): raw = "sk-live-abc" key = UserAPIKeyAuth(token=hash_token(raw), user_id="u-1") # cache seeded under the RAW key, not its hash -> resolver hashes first -> miss - store = DbIdentityStore(None, _FakeCache({raw: key})) + store = DbResolver(None, _FakeCache({raw: key})) with pytest.raises(AuthError) as exc: await store.resolve(_api_key_credential(raw)) assert exc.value.status_code == 401 @@ -130,7 +130,7 @@ async def test_blocked_key_is_rejected_403(): async def test_unknown_key_is_rejected_401(): # cache miss + no prisma -> get_key_object raises -> resolver maps to 401 - store = DbIdentityStore(None, _FakeCache()) + store = DbResolver(None, _FakeCache()) with pytest.raises(AuthError) as exc: await store.resolve(_api_key_credential("sk-live-unknown")) assert exc.value.status_code == 401 @@ -166,7 +166,7 @@ async def test_mtls_credential_resolves_to_service_account(): client_certificate=ClientCertificate(subject_dn="CN=svc-a,O=Co"), ) # service-account path does no identity lookup, so no cache/prisma needed - principal = await DbIdentityStore(None, _FakeCache()).resolve(credential) + principal = await DbResolver(None, _FakeCache()).resolve(credential) assert principal.principal_type == PrincipalType.SERVICE_ACCOUNT assert principal.user is None diff --git a/tests/test_litellm/proxy/auth_v2/test_security.py b/tests/test_litellm/proxy/auth_v2/test_security.py index 1f41d898ce35..b23422b8cbad 100644 --- a/tests/test_litellm/proxy/auth_v2/test_security.py +++ b/tests/test_litellm/proxy/auth_v2/test_security.py @@ -55,10 +55,10 @@ class _FakeResolver: """Resolver double for the security-layer tests. These tests inject fully-formed Principals (arbitrary scopes/roles) keyed by - API key, which the production DbIdentityStore cannot express; DbIdentityStore + API key, which the production DbResolver cannot express; DbResolver has its own coverage in test_resolver.py. An API-key credential is looked up by its raw-key claim; anything else echoes the credential's subject. Returns a - fresh Principal per the IdentityResolver contract. + fresh Principal per the Resolver contract. """ def __init__(self, by_key: Dict[str, Principal]) -> None: