mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix: cleanup
This commit is contained in:
parent
12d08df374
commit
97186ba754
22 changed files with 591 additions and 1361 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue