fix: cleanup

This commit is contained in:
Yassin Kortam 2026-06-12 21:02:37 -07:00
parent 12d08df374
commit 97186ba754
22 changed files with 591 additions and 1361 deletions

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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: ...

View file

@ -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:

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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()

View file

@ -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()

View file

@ -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 ""

View file

@ -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,

View file

@ -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",
]

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"]

View file

@ -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"

View file

@ -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",
[
("<EntityDescriptor/>", "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

View file

@ -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]

View file

@ -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