diff --git a/litellm/proxy/auth_v2/README.md b/litellm/proxy/auth_v2/README.md index 4a98eadd83b..2c3c4818d14 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. +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 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. +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 5a744d71210..9c28253c934 100644 --- a/litellm/proxy/auth_v2/__init__.py +++ b/litellm/proxy/auth_v2/__init__.py @@ -11,7 +11,7 @@ from litellm.proxy.auth_v2.config import ( ) 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 @@ __all__ = [ "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 2944e20757e..006f247c76d 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 @@ from litellm.proxy.auth_v2.authenticators.utils import JWTVerifier, apply_role_p __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 a4e2377e070..5a9084f2c0f 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 5bdb8dbf0c2..08908c95f9b 100644 --- a/litellm/proxy/auth_v2/authenticators/http.py +++ b/litellm/proxy/auth_v2/authenticators/http.py @@ -4,23 +4,32 @@ import base64 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 @@ class HttpAuthenticator(Authenticator): 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 @@ class HttpAuthenticator(Authenticator): 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 02859eb72bd..c1adc9bce73 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 @@ class APIKeyAuthenticator(Authenticator): 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 c460d40bbe4..814fd8807ce 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 @@ class MutualTLSAuthenticator(Authenticator): ) 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 275471dd45c..f9e0383e00e 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.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.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 @@ class OAuth2Authenticator(Authenticator): 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 @@ class OAuth2Authenticator(Authenticator): 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 53751eb0ded..df9b5950de7 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 @@ class OIDCAuthenticator(Authenticator): 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 907e826409b..81739d5a366 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 @@ class SessionAuthenticator(Authenticator): 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 2ae43dd2292..d8ff22348ba 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 09959cca19d..546366a1b94 100644 --- a/litellm/proxy/auth_v2/resolvers/__init__.py +++ b/litellm/proxy/auth_v2/resolvers/__init__.py @@ -3,7 +3,6 @@ from litellm.proxy.auth_v2.resolvers.base import ( 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 @@ __all__ = [ "IdentityResolver", "ProvisioningStore", "IdentityStore", - "InMemoryIdentityStore", ] diff --git a/litellm/proxy/auth_v2/resolvers/base.py b/litellm/proxy/auth_v2/resolvers/base.py index 6227d38b169..046f7f75304 100644 --- a/litellm/proxy/auth_v2/resolvers/base.py +++ b/litellm/proxy/auth_v2/resolvers/base.py @@ -10,7 +10,17 @@ from litellm.proxy.auth_v2.models import Credential, Principal @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 ccc37094044..00000000000 --- 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 f713372eabf..9fd3562af50 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 import errors 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 @@ class AuthSecurity: ) 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 7950649c06a..01afa8740a7 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 @@ from auth_v2_helpers import ( 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 4c0a5e74f9d..aa330e14b20 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 @@ from litellm.proxy.auth_v2.models import ( 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 441e828788f..00000000000 --- 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 8388a63200f..a53b04a84e1 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 -import pytest -from scim2_models import Group as ScimGroup -from scim2_models import User as ScimUser +from typing import Dict, Optional +import pytest + +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, - 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" - - -def _oidc_credential(**claims) -> Credential: +def _oidc_credential(subject: str) -> Credential: return Credential( scheme=SecuritySchemeType.OPENID_CONNECT, method=AuthMethod.OIDC, - subject="sub-42", + subject=subject, issuer="https://idp", scopes=["models:read"], - claims={ - "email": "dana@example.com", - "preferred_username": "dana", - "name": "Dana D", - **claims, - }, + claims={"email": "dana@example.com"}, ) -async def test_self_describing_token_builds_principal_from_claims(): - store = InMemoryIdentityStore() - principal = await store.resolve(_oidc_credential(roles=["org_admin", "bogus_role"])) +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}) + + 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_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(raw)) + assert exc.value.status_code == 401 + + +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}) + + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential(raw)) + assert exc.value.status_code == 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()) + with pytest.raises(AuthError) as exc: + await store.resolve(_api_key_credential("sk-live-unknown")) + assert exc.value.status_code == 401 + + +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")) + + 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 3d341384450..00000000000 --- 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 f46677ac29b..00000000000 --- 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 e2f64fb44fc..9f01dfc29d1 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 @@ from litellm.proxy.auth_v2.config import ( 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