Commit graph

39689 commits

Author SHA1 Message Date
Yassin Kortam
97186ba754 fix: cleanup 2026-06-12 21:02:37 -07:00
Yassin Kortam
12d08df374 docs(auth_v2): add README explaining the request auth flow 2026-06-11 21:27:59 -07:00
Yassin Kortam
fad066cd30 refactor(auth_v2): move has_required_scopes onto Principal
Scope checking is identity state, so it belongs on the Principal rather
than a standalone authorization/scopes.py helper. Callers now use
principal.has_required_scopes(security_scopes).
2026-06-11 18:35:10 -07:00
Yassin Kortam
c883abfc56 feat(auth_v2): add Casbin ABAC authorizer
Add ABACEngine alongside RBACEngine for attribute-based decisions over subject
attributes (roles, teams, org, scopes, claims) and resource attributes
(endpoint, method, model, mcp_server, mcp_tool). Policies are operator-supplied
YAML loaded via add_policy.

The Casbin CSV FileAdapter is avoided on purpose: it retains the quotes around a
comma-bearing expression, turning an eval'd rule into a truthy string literal and
silently allowing inputs that should deny. Claims access yields None for missing
keys so a single policy row referencing an absent claim cannot poison the whole
decision, and rule-evaluation errors fail closed.

Engine only; not yet wired into the live request path
2026-06-11 18:26:29 -07:00
Yassin Kortam
8caa4da357 fix: token exchange support 2026-06-11 17:23:10 -07:00
Yassin Kortam
4ebb7bb890 fix: refactor 2026-06-11 16:50:10 -07:00
Yassin Kortam
c71291f291 refactor: authv2 2026-06-11 16:27:52 -07:00
Yassin Kortam
343909d632 test(auth_v2): cover deep SCIM path under keyMatch and drop stale comment
Add a platform_admin POST /scim/v2/Groups assertion alongside the existing
/scim/v2/Users DELETE so the multi-segment grant is pinned on a second deep
path, and correct the stale keyMatch2 comment to keyMatch.
2026-06-11 09:52:18 -07:00
Yassin Kortam
2fad79b4fe test(auth_v2): pin multi-segment path authorization (keyMatch)
The Casbin object matcher spans path separators now, so a "/*" or "/api/*"
policy covers nested routes:
- a granted role is allowed on a multi-level path (platform_viewer GET
  /api/v1/models, platform_admin POST /api/v1/x/y)
- an ungranted role/verb is still denied across segments (org_viewer and
  GET-only viewers on writes), and the anchored act matcher still rejects a
  superstring verb (GETX)
- an operator CSV object pattern spans segments the same way

Full auth_v2 suite: 178 passing.
2026-06-11 09:51:14 -07:00
Yassin Kortam
e989016c2d fix(auth_v2): match request paths with keyMatch so obj patterns span segments
Use keyMatch instead of keyMatch2 in the Casbin matcher so a "/*" or
"/scim/v2/*" obj pattern unambiguously spans path separators - a require_permission
check on a multi-level route like /api/v1/models now matches the granting policy
rather than risking a 403. keyMatch is the canonical trailing-wildcard route
matcher; the anchored act matcher is unchanged, so a "GET" policy still cannot
grant "GETX".
2026-06-11 09:46:52 -07:00
Yassin Kortam
a7f9d5d6b9 test(auth_v2): pin the mTLS forwarded-DN gate against XFF spoofing
The forwarded subject-DN trust gate keys on the raw socket peer and prefers a
verified TLS-layer cert:
- an untrusted peer cannot smuggle a forged DN by claiming a trusted address via
  X-Forwarded-For (the gate ignores XFF)
- a verified client cert from the ASGI TLS extension wins over a proxy-forwarded
  DN header

Full auth_v2 suite: 175 passing.
2026-06-10 20:29:03 -07:00
Yassin Kortam
c9e7fd829c fix(auth_v2): prefer verified mTLS cert and confirm forwarded-DN gates on the socket peer
The forwarded subject-DN trust gate already compared the raw transport peer
(request.client.host / ASGI scope client) against trusted_proxy_cidrs, never the
XFF-resolved IP, so an attacker spoofing X-Forwarded-For cannot defeat it. Make
that ordering explicit and stronger: a genuinely verified client certificate from
the ASGI TLS extension is now preferred when present, and the spoofable
forwarded-header path is only consulted as a fallback, still gated on the direct
socket peer.
2026-06-10 20:25:37 -07:00
Yassin Kortam
f158a6c9e0 ci(auth_v2): run auth_v2 tests in a dedicated shard to avoid test_models basename clash
Adding tests/test_litellm/proxy/auth_v2 to the proxy-auth shard collided with proxy/client/test_models.py (both bare test_models, no package __init__), failing collection with an import file mismatch. Run the auth_v2 suite in its own job with the xmlsec1 apt packages instead; the base apt-packages input added earlier is reused.
2026-06-10 20:18:24 -07:00
Yassin Kortam
ac971d7e8b test(auth_v2): pin the role allowlist on the OIDC-login and SAML-SSO paths
The veria review-response fix gates IdP-asserted roles through the same
per-provider allowlist on every role-bearing path, not just JWT bearer tokens.

- rbac: filter_claim_roles (the shared gate) denies a self-asserted role by
  default, filters to the allowlist, and only admits platform roles behind the
  explicit allow_platform_roles flag
- saml: a signed SSO assertion asserting platform_admin yields a session whose
  Principal has no roles by default, and is filtered to the allowlist when set
- oidc: the login callback's identity build (map userinfo -> gate roles ->
  session) denies platform_admin by default and filters to the allowlist

Full auth_v2 suite: 173 passing.
2026-06-10 20:06:04 -07:00
Yassin Kortam
44ac50493e ci(auth_v2): run the proxy auth_v2 suite with xmlsec1 in the proxy-auth shard
The proxy/auth_v2 tests were not picked up by any shard because the proxy-auth path list matched the literal proxy/auth directory, not its sibling. Add the path and install xmlsec1 so the pysaml2 signing tests run rather than skip; the install is gated behind a new optional apt-packages input so the other shards are unchanged.
2026-06-10 20:03:24 -07:00
Yassin Kortam
200f674b94 test(auth_v2): pin the token-claim hardening and provisioning security fixes
Regression coverage for the security review fixes (H1/M1/M3/S7):
- resolver: a deactivated SCIM user (active=False) is rejected 403; claims
  whose keys start with "_" never surface on the Principal
- authenticators: H1 privilege escalation - a self-asserted token role grants
  nothing without a per-provider allowlist, the allowlist filters roles, and
  platform-level roles need an explicit allow_platform_roles gate
- rbac: the Casbin act matcher is anchored, so a "GET" policy does not grant
  "GETX"
- scim: PATCH that targets the read-only id (replace, remove, no-path replace)
  is rejected 400 with the record's id unchanged; unauthenticated/under-scoped
  requests render a SCIM Error body (401/403); /Schemas is a ListResponse
  envelope
- saml: a replayed signed assertion is rejected 401 (single-use), and an
  unsolicited IdP-initiated response is rejected 401 when allow_unsolicited is
  off (default secure)

Full auth_v2 suite: 165 passing.
2026-06-10 19:58:44 -07:00
Yassin Kortam
99efd314f7 fix(auth_v2): enforce the role allowlist on the SAML SSO path too
The per-provider role allowlist + platform-role gate only covered the bearer-JWT
path, so a SAML IdP could still mint platform_admin (or any Role) through its
attribute->roles mapping. Extract the filter into a shared rbac.filter_claim_roles,
add allowed_roles/allow_platform_roles to SAMLConfig (default empty = no roles
from the assertion), and apply it in the ACS before the attributes become claims,
so SSO paths enforce the same role policy as token paths. The JWT path now reuses
the same helper.
2026-06-10 19:52:46 -07:00
Yassin Kortam
fc6d51cfc0 fix(auth_v2): gate OIDC login roles through the provider allowlist
The browser OIDC login/callback path accepted IdP-asserted roles straight into
the session, so a malicious or misconfigured IdP could assert platform_admin
over SSO and have it land in the Principal - the same escalation the bearer
token path already closes. The callback now runs the mapped claims through the
shared _apply_role_policy with the matched provider config before minting the
session, so roles outside allowed_roles are dropped and platform roles require
allow_platform_roles. With the defaults (empty allowlist, platform off) no
IdP-asserted role survives.
2026-06-10 19:52:15 -07:00
Yassin Kortam
341e75ab45 test(auth_v2): repoint to frozen sub-package layout and pin H1 group provisioning
Follow the sub-package split (oidc/saml/scim/*) and the token-claim hardening:
- import config models (OIDCProviderConfig, SAMLConfig) from the top-level
  package and the moved helpers from their concrete sub-modules
  (saml.router, saml.config, oidc.router) so tests are stable against
  __init__ re-export churn
- resolver: a token group claim is no longer authoritative on its own; it
  becomes a TeamIdentity only when it resolves to a provisioned SCIM Group in
  the store (split into provisioned vs not-provisioned cases)

Full auth_v2 suite green (153) and stable across repeated runs.
2026-06-10 19:50:23 -07:00
Yassin Kortam
a9be3d23e0 refactor(auth_v2): drop duplicate SCIM error helper and private re-exports
S7 is already handled self-contained by the SCIM router's route_class (renders
401/403 as a SCIM Error while preserving WWW-Authenticate), so remove the
redundant errors.scim_error_response and its AuthSecurity docstring note. Also
stop re-exporting private underscore helpers from the oidc/saml sub-package
__init__s; the public names (config + build_*_router) remain re-exported and
test code references the concrete modules for internals.
2026-06-10 19:45:14 -07:00
Yassin Kortam
fec8e0a039 fix(auth_v2): complete the freeze batch (re-exports, SCIM error helper, bounded SAML login state)
- Re-export the test/public helpers from the sub-package __init__s so existing
  imports resolve: oidc exposes _provider_key/_user_from_userinfo, saml exposes
  _map_attributes/_metadata_source/_user_from_mapped.
- S7: add errors.scim_error_response(exc) rendering the RFC 7644 SCIM Error schema;
  a host registers it as the exception handler for the SCIM routes (noted on the
  AuthSecurity docstring).
- veria-ai MEDIUM: bound the SAML outstanding-request map with a TTL (300s) and
  max-size eviction, the same treatment as the session store, so unauthenticated
  /auth/saml/login traffic can no longer accumulate login state unbounded.
- Silence a fastapi/starlette generic-Request override quirk on the SCIM route's
  get_route_handler so mypy is clean at the freeze sha.
2026-06-10 19:41:28 -07:00
Yassin Kortam
75509686f7 fix(auth_v2): reject SCIM id mutation and SCIM-shape auth errors
PATCH no longer lets a scim:write principal reassign the read-only id
attribute (RFC 7643): any operation whose path targets id, or a no-path value
object carrying id, is rejected with 400 instead of rewriting the record
identifier and clobbering another resource. Authentication failures on the
guarded SCIM routes now render the SCIM Error schema (RFC 7644) for 401 and
403 via a router-scoped route class, preserving the WWW-Authenticate
challenge, rather than the generic {detail} body.
2026-06-10 19:38:20 -07:00
Yassin Kortam
422c4df16e fix(auth_v2): close token-claim privilege escalation and related hardening
Token-flows and credential-flows security review fixes:

- H1 (privilege escalation): a validly-signed token could self-assert
  platform_admin and arbitrary teams via the roles/groups claims. Roles from a
  token are now filtered to a per-provider allowlist (OIDCProviderConfig.allowed_roles,
  default empty = none) with platform-level roles gated behind an explicit
  allow_platform_roles flag; groups become authoritative TeamIdentity only when they
  resolve to a provisioned SCIM Group in the store. Introspection responses carry no
  roles (no per-provider policy applies).
- M1: enforce iss on introspection (OAuth2IntrospectionConfig.issuer) in addition to aud.
- M2: bound JWKS refetch with cache_jwk_set + a 300s lifespan and add a 10s PyJWKClient
  timeout, so an unknown-kid stream can't amplify into per-request network fetches.
- M3: require https for issuer/jwks_uri/introspection_endpoint (loopback excepted for dev).
- rbac: anchor the Casbin act matcher (^(...)$) so a "GET" policy can't grant "GETX".
- basic auth: switch the reference store to pbkdf2_hmac-sha256 (600k iterations); the
  verifier protocol still lets deployments plug argon2/bcrypt.
- LOW: generic invalid_token description instead of echoing PyJWT internals; guard the
  introspection response.json() and require active to be boolean true.
2026-06-10 19:32:44 -07:00
Yassin Kortam
9b9cc60994 refactor(auth_v2): split oidc/saml/scim into sub-packages
Per the revised design §2, each protocol that carries its own config/routes
becomes a sub-package while the shared core stays flat. oidc.py -> oidc/router.py
with oidc/config.py (OIDCProviderConfig); saml.py -> saml/router.py with
saml/config.py (SAMLConfig + the attribute-map default); scim.py -> scim/router.py.
Each sub-package __init__ re-exports its public names so call sites read
from litellm.proxy.auth_v2.saml import SAMLConfig, build_saml_router. SessionConfig
moves next to the SessionStore it configures in session.py. git mv preserves
history; AuthConfig now composes the protocol configs from their sub-packages.
2026-06-10 19:25:44 -07:00
Yassin Kortam
0ee4397a59 test(auth_v2): adapt the suite to the AuthSecurity refactor and renames
Repoint the whole test surface off install_auth/app.state onto the AuthSecurity
composition root: build AuthSecurity(config, store, ...) and declare routes with
Security(auth.principal[, scopes]), auth.require_roles, auth.require_permission;
mount routers via build_*_router(auth). Apply the PEP8 renames (JWTVerifier,
APIKeyAuthenticator, OIDCAuthenticator, MutualTLSAuthenticator, OIDCProviderConfig,
SAMLConfig, MutualTLSConfig, RBACEngine.has_any_role). SAML moves to the shared
SessionStore + "litellm_session" cookie and session.safe_relay_state; the
build_authenticators tests assert concrete types now that the scheme attribute is
gone. No coverage lost; 151 tests pass.

Note: routes use the default-value Security() style instead of Annotated[...]
because this module runs under future annotations, where an Annotated marker is
stringified and FastAPI re-evaluates it in module globals, which cannot see the
closure-local auth instance.
2026-06-10 19:24:10 -07:00
Yassin Kortam
5f02c88369 fix(auth_v2): authenticate OIDC login sessions and adapt SCIM to AuthSecurity
The OIDC callback now mints a server-side session and sets the shared session
cookie (httponly, secure, samesite=lax) before redirecting, so login yields an
authenticated session that the shared SessionAuthenticator resolves; previously
it returned the user record as JSON and left the caller unauthenticated. The
login flow owns its CSRF protection without Starlette SessionMiddleware: it
stores state, nonce and the PKCE (S256) verifier in the short-lived
oauth_txn_store keyed by a temporary cookie, then on callback consumes the
transaction one-time, checks the returned state, exchanges the code with the
verifier and validates the nonce against the id_token. A replayed or expired
state finds no transaction and returns 400.

SCIM moves onto the AuthSecurity DI surface: build_scim_router(auth) reads
auth.resolver and guards writes with Security(auth.principal, scopes=
["scim:write"]) instead of app.state and get_current_principal.
2026-06-10 19:15:43 -07:00
Yassin Kortam
4ec48302a5 docs(auth_v2): one-line docstrings on AuthSecurity Security() entrypoints (D1) 2026-06-10 19:10:08 -07:00
Yassin Kortam
8302f55995 refactor(auth_v2): replace install_auth with AuthSecurity DI
Per user direction, drop install_auth/AuthContext/app.state entirely; the
enforcement layer is now an AuthSecurity object whose bound methods are the
FastAPI Security() dependencies. The app constructs AuthSecurity(config, resolver)
once and passes auth.principal / auth.require_roles / auth.require_permission to
Security(); routers take the instance explicitly via build_*_router(auth) and read
auth.resolver/auth.config rather than request.app.state.

Browser sessions are unified behind a shared SessionStore + SessionAuthenticator
(session.py): one cookie, keyed on identity["method"], so SAML and the upcoming
OIDC login flow share one store. AuthSecurity owns the post-login session_store
and a short-TTL oauth_txn_store for OIDC state/nonce/PKCE; SessionConfig moves the
cookie/TTL/redirect settings off SAMLConfig. SAML keeps its protocol-specific
outstanding/replay state local.

Fold in the standing judge findings: collapse the triplicated http/oauth2/oidc
bearer paths into one _authenticate_bearer_jwt helper, inject the introspection
async-client via a factory instead of importing litellm inline, drop the dead
scheme attribute from the authenticators, and rename to PEP 8 acronym casing
(JWTVerifier, OIDCAuthenticator, OIDCProviderConfig, SAMLConfig, RBACEngine,
APIKeyAuthenticator, MutualTLSAuthenticator). __all__ now exports AuthSecurity,
Role and the resolver protocols.

scim.py and oidc.py move to build_*_router(auth) separately.
2026-06-10 19:08:13 -07:00
Yassin Kortam
c512a49fa5 test(auth_v2): pin the hardened auth behaviors from the security fixes
Cover the security fixes landed in 71a189b, ca896ac, 6f3fc5e and 4503499:

- HTTP basic now verifies the password via an injected BasicAuthVerifier:
  correct creds 200, wrong password / unknown user / no verifier wired all 401
  (fail closed), and the password is never carried on the credential; plus a
  unit test that hash_basic_password is salted and InMemoryBasicAuthStore
  verifies it
- mTLS only trusts the forwarded subject-DN header from a peer inside the
  trusted-proxy CIDRs; a forged header from an untrusted peer is ignored
- SCIM discovery endpoints (ServiceProviderConfig, ResourceTypes, Schemas) are
  public, Users/Groups stay guarded, DELETE on a missing resource is a SCIM 404
  Error, and PATCH honors nested dotted paths while rejecting filter paths 400
- SAML ACS sets a Secure session cookie, binds the redirect target server-side
  so a client-supplied form RelayState is never trusted (falls back to the
  default path), and the session store enforces TTL expiry and size eviction

Mutation-checked: removing the basic-auth password check or the mTLS
trusted-peer gate fails these.
2026-06-10 18:59:41 -07:00
Yassin Kortam
450349965c fix(auth_v2): honor nested SCIM patch paths, align /Schemas, unshadow filter
PATCH now applies dotted attribute paths like name.givenName instead of
silently dropping them, and rejects unsupported value-filter paths
(emails[type eq "work"].value) with a 400 SCIM Error so behavior matches the
advertised patch support. /Schemas now uses the ListResponse envelope like the
other discovery endpoints, and the list route's query parameter no longer
shadows the builtin while keeping the RFC 7644 ?filter= wire contract.
2026-06-10 18:52:58 -07:00
Yassin Kortam
6f3fc5eba4 fix(auth_v2): close mTLS spoofing, introspection audience, SAML replay, and deactivated-user gaps
Address the SSO and credential-flow security review findings:

- mTLS (HIGH): the forwarded subject-DN header was trusted unconditionally, so
  any caller could send it and mint a service-account principal. Trust it only
  when the immediate peer is inside trusted_proxy_cidrs (same model as XFF) and
  fail closed otherwise; the ASGI-TLS-extension path already fails closed when
  no verified cert is present.
- OAuth2 introspection (HIGH): RFC 7662 responses were accepted regardless of
  audience. Enforce the response aud against OAuth2IntrospectionConfig.audience
  and reject active tokens whose audience does not match.
- SAML (HIGH): default allow_unsolicited to False so IdP-initiated/login-CSRF
  responses are rejected, add a single-use assertion-id replay cache, and bind
  the post-login redirect to the RelayState stored against the matched
  InResponseTo request rather than trusting the echoed form field.
- Deactivated users (M1): the resolver now rejects a credential that resolves to
  a SCIM user with active=False, so deactivation actually blocks authentication.
- Stop carrying underscore-prefixed carrier keys (raw api key, basic password)
  into Principal.claims, which is documented for audit logging.
2026-06-10 18:52:26 -07:00
Yassin Kortam
ca896ac073 fix(auth_v2): make SCIM discovery public and return 404 on missing DELETE
RFC 7644 requires /ServiceProviderConfig, /ResourceTypes and /Schemas to be
publicly readable; split them onto an unguarded router while Users and Groups
stay behind scim:write. DELETE on a missing User or Group now returns a 404
SCIM Error instead of a misleading 204.
2026-06-10 18:34:37 -07:00
Yassin Kortam
71a189bf65 fix(auth_v2): harden HTTP basic, SAML sessions, and JWKS fetch
Address Greptile security findings in the authenticator, SAML and config layers:

- HTTP Basic accepted any password and copied the cleartext password into
  Principal.claims. Verify the password against an injected BasicAuthVerifier
  (InMemoryBasicAuthStore holds username -> salted sha256, constant-time compared
  with hmac.compare_digest) and stop putting the password in the credential;
  basic with no configured verifier now rejects rather than trusting the caller.
- SAML session cookie gains the Secure flag (httponly and samesite=lax already
  set), gated by SamlConfig.cookie_secure.
- SAML session store gains TTL expiry and max-size eviction
  (SamlConfig.session_ttl_seconds / session_max_size) so it can no longer grow
  unbounded or hand out stale sessions.
- JWKS signing-key lookup ran synchronously inside the async request path and
  blocked the event loop on a cache miss; run the JWT verify off-loop via
  starlette run_in_threadpool on the http-bearer, oauth2 at+jwt and oidc paths.
2026-06-10 18:34:10 -07:00
Yassin Kortam
2dd336a795 test(auth_v2): move tests under proxy/ to mirror the module relocation
The module moved from litellm/auth_v2 to litellm/proxy/auth_v2 (commit 104a5e1),
so the mirrored tests move from tests/test_litellm/auth_v2 to
tests/test_litellm/proxy/auth_v2 and their imports switch to
litellm.proxy.auth_v2. No behavior change; 134 tests still pass at the new path.
2026-06-10 18:32:12 -07:00
Yassin Kortam
104a5e1443 refactor(auth_v2): move module under litellm/proxy/auth_v2
The module imports FastAPI and is proxy-only, so it belongs under litellm/proxy
beside the legacy litellm/proxy/auth rather than at the top level. git mv
preserves history; the package is self-contained so the relative imports are
unchanged, and the scim2-models mypy override is path-independent.
2026-06-10 18:28:14 -07:00
Yassin Kortam
55a332bb91 test(auth_v2): cover Casbin-backed RBAC hierarchy and permissions
RBAC moved to an embedded Casbin enforcer: require_roles now honors the role
hierarchy and require_permission gates object/action against the policy.

- rbac: RbacEngine.has_role inherits down the g-rules (platform_admin satisfies
  an org_admin/team_member gate, org_admin satisfies org_viewer, team_admin
  satisfies team_member) and never climbs (team_member fails an org_admin gate);
  enforce honors the default policy (platform_admin any obj/act incl keyMatch2
  on /scim/v2/*, platform_viewer read-only, org_viewer no write) and an operator
  CSV fully replaces the in-code defaults
- security: require_roles passes a higher role through a lower-role gate via the
  hierarchy; require_permission allows platform_admin, denies a viewer on write
  with detail "Forbidden", and 401s when unauthenticated; an RbacEngine injected
  onto the AuthContext overrides the default policy (operator CSV path)

Replaces the removed has_any_role coverage. Mutation-checked: dropping the
hierarchy lookup or short-circuiting enforce fails these.
2026-06-10 18:26:19 -07:00
Yassin Kortam
3dc660a135 test(auth_v2): cover OAuth2 token introspection over the cached async client 2026-06-10 18:25:48 -07:00
Yassin Kortam
e309003c84 feat(auth_v2): back RBAC with Casbin
Replace the hand-rolled has_any_role set check with a Casbin-backed RbacEngine
(per design 03 §4). The engine wraps casbin.Enforcer over an embedded RBAC model
(request sub/obj/act, g role hierarchy, keyMatch2 on obj, regexMatch on act) and a
default in-code policy: platform_admin inherits org_admin/team_admin/platform_viewer,
org_admin inherits org_viewer, team_admin inherits team_member; grants platform_admin
/* .*, platform_admin /scim/v2/* .*, platform_viewer /* GET. Operators can replace the
whole policy with a CSV via AuthConfig.casbin_policy_path (FileAdapter); no DB adapter
yet.

require_roles now honors the hierarchy through the enforcer's grouping
(get_implicit_roles_for_user) instead of exact-match membership, so a platform_admin
passes a require_roles(ORG_ADMIN) gate; signature and 403 semantics are unchanged. New
require_permission(obj, act) dependency runs get_current_principal then RbacEngine.enforce
and 403s on deny. The engine is built in install_auth and injectable for tests via a new
rbac kwarg. Scope checks stay plain SecurityScopes (a token property, not policy).

Adds casbin to the proxy extra (pure python, no native deps).
2026-06-10 18:21:14 -07:00
Yassin Kortam
7cf35ccc0a test(auth_v2): cover SCIM scim:write guard and SAML RelayState redirect
Follow the auth module updates: SCIM routes now require the scim:write scope,
and the SAML ACS/login flow redirects to a validated RelayState instead of
returning JSON.

- scim: authenticate every request with a scoped key, and pin the guard
  directly: no credential -> 401 with WWW-Authenticate, an authenticated
  principal without scim:write -> 403 insufficient_scope
- saml: assert ACS returns 303 to a safe RelayState ("/dashboard") and falls
  back to default_redirect_path for an absolute/"//host" RelayState; assert
  GET /login threads ?next= through as a validated RelayState; add a garbage
  SAMLResponse -> 401 case

A mutation spot-check confirmed the open-redirect tests fail when the
_safe_relay_state guard is bypassed.
2026-06-10 18:06:06 -07:00
Yassin Kortam
d3878ee9a9 fix(auth_v2): require scim:write auth on all SCIM routes
The SCIM router mounted /scim/v2/* with no security dependency, so any caller
could create or delete users and groups unauthenticated. Guard the whole router
with Security(get_current_principal, scopes=["scim:write"]) per design 03 §11, so
provisioning callers authenticate with the same bearer token or API key as every
other route and the scope gates them: unauthenticated requests now 401, an
authenticated principal without scim:write gets 403 insufficient_scope.

Also document two deployment facts uncovered alongside this: uvicorn's
--proxy-headers rewrites request.client from X-Forwarded-For before this module's
trusted_proxy_cidrs check runs and silently bypasses it (install_auth docstring),
and the scheme_order precedence where HTTP precedes openIdConnect so a bearer JWT
is labeled bearer_jwt rather than oidc (both verify identically).
2026-06-10 18:03:08 -07:00
Yassin Kortam
03faccea17 test(auth_v2): add test suite for the standards-based auth module
Cover every layer of litellm/auth_v2 with tests that fail when the behavior
regresses, not just for coverage. Highlights:

- authenticators: JwtVerifier enforces signature, aud, iss, exp, required
  claims, and at+jwt typ via an injected jwks_client (real RS256 against an
  in-test RSA keypair, no monkeypatching); per-scheme apiKey/http-bearer/
  http-basic/oauth2/oidc/mTLS extraction and fail-fast on present-but-invalid
- security: OR precedence first-match-wins, a present-but-invalid api key does
  not fall through to a valid bearer, scope -> 403 insufficient_scope, role ->
  403, missing credential -> 401 with WWW-Authenticate, network wired onto the
  principal
- resolver: sha256 api-key lookup (wrong key never resolves), claims-driven
  principal build (groups -> teams, roles filtered to the Role enum), mTLS ->
  service account
- network: trusted-proxy XFF honored only from a trusted peer, right-to-left
  parse skips chained proxies, spoofed XFF from an untrusted peer ignored
- scim: Users/Groups create/get/patch/list/delete round-trip plus malformed
  body -> SCIM 400 Error and discovery endpoints
- oidc: userinfo -> scim2_models.User mapping and the upsert seam
- saml: a real pysaml2 IdP mints a signed assertion; ACS provisions the user,
  sets a session cookie, and authenticates with method=saml, while tampered and
  unsigned assertions are rejected (skipped when xmlsec1 is absent)
- models/rbac/config: frozen Credential, Role validation, scope/role helpers,
  SamlConfig metadata validation

A mutation spot-check confirmed the suite fails when JWT verification or the
api-key hash lookup is broken.
2026-06-10 17:59:29 -07:00
Yassin Kortam
da1a088a4f feat(auth_v2): redirect after SAML ACS with validated RelayState
Replace the test-convenience JSON body from /acs with the standard SP flow: set
the session cookie, then 303 redirect to the RelayState the IdP echoes back, or
to SamlConfig.default_redirect_path (default "/") when it is absent. RelayState
is validated to block open redirects - only relative paths are honored (must
start with a single "/", reject "//", any scheme, and backslashes), and
anything else falls back to the default. GET /login threads a ?next= query
param through as RelayState with the same validation so the post-login landing
page survives the round trip.
2026-06-10 17:40:05 -07:00
Yassin Kortam
677762bf60 refactor(auth_v2): align SamlConfig with the revised design doc
Match the updated 03-design.md SAML spec: rename sp_entity_id to entity_id,
collapse the split idp_metadata_path/idp_metadata_inline into one idp_metadata
field accepting inline XML, a local path, or a remote URL, and default the
attribute_map to the common Okta/Entra claims (email, givenName, surname,
groups). Make assertion signing mandatory by hardcoding want_assertions_signed
rather than exposing it as a togglable field. Map givenName/surname into the
SCIM User's Name (given/family/formatted) and email into emails, and fail the
ACS closed with 401 on any parse or signature-verification error.
2026-06-10 17:37:50 -07:00
Yassin Kortam
0b74ffa9c6 feat(auth_v2): implement full SAML 2.0 SP via pysaml2
Replace the deferred SAML thin-adapter stub with a working Service Provider
built on pysaml2: an SP metadata endpoint, an SP-initiated /login that
redirects to the IdP, and an ACS handling the HTTP-POST binding that verifies
the signed assertion, maps NameID and attribute statements into a
scim2_models.User, and upserts it through the same ProvisioningStore seam SCIM
and OIDC use. A SamlAuthenticator reads the post-ACS session cookie and resolves
to the one normalized Principal like every other scheme; AuthMethod gains a SAML
member. IdP metadata loads from a file path or inline XML via SamlConfig, and
install_auth mounts the router and authenticator when SAML is enabled.

pysaml2 pulls pyOpenSSL transitively without pinning it, and older pyOpenSSL
caps cryptography below 46 and breaks at import against the version this proxy
already requires; pin pyOpenSSL>=26 so the resolver stays on a
cryptography-46-compatible release. pysaml2 also needs the system xmlsec1
binary at runtime (brew install libxmlsec1 on macOS, apt-get install xmlsec1
libxmlsec1-dev on Debian); SamlConfig.xmlsec_binary can point at it when it is
not on PATH.
2026-06-10 17:33:02 -07:00
Yassin Kortam
a0a59a2197 feat(auth_v2): add standards-based auth and identity module
New additive litellm/auth_v2 package: a thin orchestration layer over PyJWT,
Authlib and scim2-models behind FastAPI's native Security() primitives that
normalizes every credential into one standards-shaped Principal carrying
org/team/user and network identity.

Authentication, identity resolution, authorization and enforcement are kept
as separate layers. Five authenticators cover the OpenAPI scheme types
(apiKey, http bearer-JWT/basic, oauth2 at+jwt + introspection, openIdConnect,
mutualTLS); a shared JwtVerifier enforces signature, issuer, audience and exp
on every JWT path via a cached PyJWKClient. RBAC is a flat Role enum plus
scope/role checks wired through SecurityScopes. Missing or invalid credentials
return 401 with an RFC 9110/6750 WWW-Authenticate challenge, scope failures
return 403 insufficient_scope. SCIM 2.0 Users/Groups/PATCH/discovery and an
Authlib OIDC login flow share one ProvisioningStore seam; the SAML SP is a
documented thin adapter pending pysaml2.

The module is unimported by the proxy app and depends on nothing in
litellm/proxy/auth.
2026-06-10 17:22:20 -07:00
Yassin Kortam
2bbf688613 build(auth_v2): add Authlib and scim2-models for the auth_v2 module
Pull in the OSS libraries the standards-based auth module orchestrates:
Authlib for the OIDC login flow and scim2-models for SCIM 2.0, and switch
PyJWT to the [crypto] extra so JWKS-backed RS256 verification is explicit
(cryptography was already a proxy dependency). scim2-models ships py.typed
but its generic, alias-driven models trip mypy's call-arg check though they
work at runtime, so treat the library as untyped at the boundary in both
litellm/mypy.ini (used by CI) and the root pyproject mypy config.
2026-06-10 17:22:20 -07:00
Yassin Kortam
dff25fef44
feat(proxy): add option to disable server-side prepared statements for DB lookups (#29984) 2026-06-10 16:06:32 -07:00
Yassin Kortam
3bd3951e37
fix(proxy): recover from cached-plan errors by reconnecting the Prisma client (#29983) 2026-06-10 16:06:01 -07:00
tin-berri
1436ee9092
fix(mcp): drop orphaned per-user credential rows when an MCP server is deleted (#30141) 2026-06-10 15:56:58 -07:00
yuneng-jiang
7899463c6a
fix(callbacks): forward callback_settings to callback initializers and guard consumers against non-dict values (#30161)
* fix(datadog): pass callback_specific_params so DatadogCostManagementLogger receives cost_tag_keys (#29590)

* fix(datadog): pass callback_specific_params so DatadogCostManagementLogger receives cost_tag_keys

* test(proxy): regression test that load_config forwards callback_specific_params

* fix(proxy): guard lakera_prompt_injection callback_specific_params against non-dict

Addresses review feedback: forwarding callback_settings as callback_specific_params
(so DatadogCostManagementLogger receives cost_tag_keys) exposed the
lakera_prompt_injection branch, which did lakeraAI_Moderation(**callback_specific_params
["lakera_prompt_injection"]) with no type guard. A config like
`callback_settings: {lakera_prompt_injection: "any-string"}` then hit `**"any-string"`
-> TypeError: argument after ** must be a mapping, not str.

Guard the lakera branch with isinstance(dict), matching the existing presidio and
datadog_cost_management branches (non-dict values fall back to {}). Add a regression
test asserting initialize_callbacks_on_proxy ignores a non-dict value instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: inject fake lakera_ai module to avoid importing the real one

CI fix for the lakera regression test: it stubbed litellm.proxy.proxy_server with
a SimpleNamespace and then monkeypatch.setattr'd the real lakera_ai module, which
forces importing it — and lakera_ai does `from litellm.proxy.proxy_server import
LiteLLM_TeamTable`, absent on the stub -> ImportError under proxy-infra tests.

Inject a fake lakera_ai module into sys.modules instead, so the callbacks branch's
`from ...lakera_ai import lakeraAI_Moderation` resolves to the stub without loading
the real module. The guard under test (isinstance(dict) in the lakera branch) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(callbacks): guard compression/websearch interceptors against non-dict callback_settings (#30153)

#29590 forwards the full callback_settings dict into initialize_callbacks_on_proxy, which activates the compression_interception and websearch_interception consumers. Their initialize_from_proxy_config read the callback_settings subkey without an isinstance(dict) guard, so a non-dict value such as `compression_interception: true` reached from_config_yaml(...).get(...) and aborted proxy startup with AttributeError. #29590 added that guard for lakera_prompt_injection but not for these two

Mirror the isinstance(dict) guard already used by the lakera, presidio, and datadog branches so a non-dict value is ignored and the callback initializes with defaults. A parametrized test feeds every callback_settings consumer a non-dict value through initialize_callbacks_on_proxy to catch a future consumer that forgets the guard

* fix(callbacks): normalize non-dict callback_specific_params to empty dict

A blank callback_settings: key in YAML loads as None, and
config.get('callback_settings', {}) returns None because dict.get only
falls back to the default when the key is absent. Forwarding that value
verbatim to initialize_callbacks_on_proxy made the first
'<name>' in callback_specific_params membership test raise
TypeError: argument of type 'NoneType' is not iterable, aborting proxy
startup. Same failure for any non-dict root such as callback_settings: true.

Normalize the value at the function boundary so both callsites (and any
future ones) initialize callbacks with their defaults instead of crashing.

---------

Co-authored-by: Hedi Daoud <150018939+hdaoud23@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:22:00 -07:00