Skip to content

feat(auth_v2): standards-based auth and identity module - #30171

Draft
yassin-berriai wants to merge 55 commits into
litellm_internal_stagingfrom
litellm_fix/auth-module
Draft

feat(auth_v2): standards-based auth and identity module#30171
yassin-berriai wants to merge 55 commits into
litellm_internal_stagingfrom
litellm_fix/auth-module

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

None

Linear ticket

None

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.

Screenshots / Proof of Fix

Every command below was run against a live FastAPI instance that builds an AuthSecurity over an in-memory identity store and is exercised with curl. The demo seeds two API keys (sk-demo-admin carries ORG_ADMIN plus the models:read and scim:write scopes; sk-demo-reader carries only models:read), a PBKDF2 basic-auth verifier, an OIDC provider whose JWKS is served on a second port with allowed_roles=["org_admin"] and platform roles off, and two provisioned SCIM Groups (eng, oncall) so bearer-token group claims resolve to teams under the hardened rule that group claims are authoritative only once provisioned. SCIM is mounted with build_scim_router(auth). A /dev/mint endpoint signs short-lived RS256 tokens so the bearer flow is curl-able end to end. A second app drives the SAML SP. Both demo apps live under a gitignored research directory and are not part of this PR.

1. API key (apiKey scheme)

Valid key resolves to the seeded principal:

$ curl -s http://127.0.0.1:8099/v1/models -H "x-litellm-api-key: sk-demo-reader"
{"subject":"user-reader","auth_method":"api_key","organization":{"id":"org-acme","name":"Acme"},"teams":[{"id":"team-eng","name":"eng","role":"member"}],"scopes":["models:read"],"roles":[],"client_ip":"127.0.0.1","via_trusted_proxy":true}

Wrong key never resolves to a different principal; it is a clean 401 with an RFC 6750 challenge:

$ curl -s -i http://127.0.0.1:8099/v1/models -H "x-litellm-api-key: sk-WRONG"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm", error="invalid_token"

{"detail":"Invalid token"}

2. Missing credential

No credential at all is a 401 with a WWW-Authenticate challenge advertising the enabled schemes (here the api-key and http authenticators both contribute, so Bearer and Basic are offered):

$ curl -s -i http://127.0.0.1:8099/v1/models
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm", Basic realm="litellm", Bearer realm="litellm"

{"detail":"Not authenticated"}

3. HTTP Basic (http scheme, verified)

Correct credentials resolve through the injected PBKDF2 verifier to the seeded service-account principal:

$ curl -s http://127.0.0.1:8099/v1/models -u "svc-basic:correct-horse"
{"subject":"svc-basic","auth_method":"http_basic","organization":{"id":"org-acme","name":"Acme"},"teams":[],"scopes":["models:read"],"roles":[],"client_ip":"127.0.0.1","via_trusted_proxy":true}

A wrong password fails closed; the password is verified with hmac.compare_digest and is never copied onto the principal:

$ curl -s -i http://127.0.0.1:8099/v1/models -u "svc-basic:nope"
HTTP/1.1 401 Unauthorized
www-authenticate: Basic realm="litellm"

{"detail":"Not authenticated"}

4. Bearer JWT (OIDC provider, local JWKS)

Mint a token whose groups are provisioned as SCIM Groups; those groups resolve to teams:

$ TOKEN=$(curl -s "http://127.0.0.1:8099/dev/mint?sub=alice&scope=models:read&email=alice@acme.example&groups=eng,oncall" \
    | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
$ curl -s http://127.0.0.1:8099/v1/models -H "Authorization: Bearer $TOKEN"
{"subject":"alice","auth_method":"bearer_jwt","organization":null,"teams":[{"id":"eng","name":"eng","role":"member"},{"id":"oncall","name":"oncall","role":"member"}],"scopes":["models:read"],"roles":[],"client_ip":"127.0.0.1","via_trusted_proxy":true}

Expired and wrong-audience tokens are rejected against the real RS256 signature, exp, and aud checks (PyJWT reports a generic failure so the endpoint does not leak which check failed):

$ curl -s -i http://127.0.0.1:8099/v1/models \
    -H "Authorization: Bearer $(curl -s 'http://127.0.0.1:8099/dev/mint?sub=alice&expires_in=-60' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm", error="invalid_token", error_description="token verification failed"

$ curl -s -i http://127.0.0.1:8099/v1/models \
    -H "Authorization: Bearer $(curl -s 'http://127.0.0.1:8099/dev/mint?sub=alice&aud=other-app' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm", error="invalid_token", error_description="token verification failed"

5. Token-claim hardening (positive security demonstrations)

A group the IdP asserts but that is not provisioned does not become a team; the hardened resolver treats group claims as authoritative only once provisioned:

$ TOKEN=$(curl -s "http://127.0.0.1:8099/dev/mint?sub=alice&scope=models:read&groups=marketing" \
    | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
$ curl -s http://127.0.0.1:8099/v1/models -H "Authorization: Bearer $TOKEN" \
    | python3 -c "import sys,json;print('teams=%s'%json.load(sys.stdin)['teams'])"
teams=[]

A token asserting platform_admin gets it stripped by the provider role allowlist (allowed_roles=["org_admin"], platform roles off), so only the allowlisted role survives:

$ TOKEN=$(curl -s "http://127.0.0.1:8099/dev/mint?sub=alice&scope=models:read&roles=org_admin,platform_admin" \
    | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
$ curl -s http://127.0.0.1:8099/v1/models -H "Authorization: Bearer $TOKEN" \
    | python3 -c "import sys,json;print('roles=%s'%json.load(sys.stdin)['roles'])"
roles=['org_admin']

6. Scope enforcement (RFC 6750 insufficient_scope)

The reader principal lacks chat:write, so /v1/secure returns 403 with the insufficient_scope challenge:

$ curl -s -i http://127.0.0.1:8099/v1/secure -H "x-litellm-api-key: sk-demo-reader"
HTTP/1.1 403 Forbidden
www-authenticate: Bearer realm="litellm", error="insufficient_scope"

{"detail":"Insufficient scope"}

7. Role enforcement

Admin key carries ORG_ADMIN and passes auth.require_roles(Role.ORG_ADMIN):

$ curl -s -X POST http://127.0.0.1:8099/admin/teams -H "x-litellm-api-key: sk-demo-admin"
{"created_by":"user-admin","roles":["org_admin"]}

Reader key does not, so the same route is a 403:

$ curl -s -i -X POST http://127.0.0.1:8099/admin/teams -H "x-litellm-api-key: sk-demo-reader"
HTTP/1.1 403 Forbidden

{"detail":"Insufficient role"}

8. OR precedence is fail-fast

A request carrying a wrong api key and a valid bearer returns 401; the api key is earlier in scheme_order, it matches first, its resolution fails, and the request is rejected without ever consulting the valid bearer:

$ TOKEN=$(curl -s "http://127.0.0.1:8099/dev/mint?sub=alice&scope=models:read" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
$ curl -s -i http://127.0.0.1:8099/v1/models -H "x-litellm-api-key: sk-WRONG" -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm", error="invalid_token"

{"detail":"Invalid token"}

9. Trusted-proxy XFF (network identity)

With no X-Forwarded-For, the loopback peer is in the trusted CIDR, so it is reported and flagged via-proxy:

$ curl -s http://127.0.0.1:8099/v1/models -H "x-litellm-api-key: sk-demo-reader" \
    | python3 -c "import sys,json;d=json.load(sys.stdin);print('client_ip=%s via_trusted_proxy=%s'%(d['client_ip'],d['via_trusted_proxy']))"
client_ip=127.0.0.1 via_trusted_proxy=True

With an X-Forwarded-For from the trusted loopback peer, the right-to-left parse skips the trusted hop and reports the real client:

$ curl -s http://127.0.0.1:8099/v1/models -H "x-litellm-api-key: sk-demo-reader" \
    -H "X-Forwarded-For: 203.0.113.42, 127.0.0.1" \
    | python3 -c "import sys,json;d=json.load(sys.stdin);print('client_ip=%s via_trusted_proxy=%s'%(d['client_ip'],d['via_trusted_proxy']))"
client_ip=203.0.113.42 via_trusted_proxy=True

The inverse, a spoofed X-Forwarded-For from an untrusted peer being ignored, cannot be shown from a loopback client because the peer is always trusted here; it is pinned in tests/test_litellm/proxy/auth_v2/test_network.py::test_spoofed_xff_from_untrusted_peer_is_ignored

10. SCIM 2.0 (public discovery, scim:write-guarded writes)

The RFC 7644 discovery endpoints are public so SCIM clients can read capabilities before provisioning; no credential returns 200:

$ curl -s -i http://127.0.0.1:8099/scim/v2/Schemas
HTTP/1.1 200 OK

The user and group routes are gated by the scim:write scope. Unauthenticated is 401:

$ curl -s -i -X POST http://127.0.0.1:8099/scim/v2/Users -H "Content-Type: application/scim+json" \
    -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"x@acme.example"}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="litellm"

Authenticated but missing the scope (reader key) is 403 insufficient_scope:

$ curl -s -i http://127.0.0.1:8099/scim/v2/Users -H "x-litellm-api-key: sk-demo-reader"
HTTP/1.1 403 Forbidden
www-authenticate: Bearer realm="litellm", error="insufficient_scope"

Create with the scoped admin key is 201 Created:

$ curl -s -i -X POST http://127.0.0.1:8099/scim/v2/Users -H "x-litellm-api-key: sk-demo-admin" \
    -H "Content-Type: application/scim+json" \
    -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"newhire@acme.example","displayName":"New Hire"}'
HTTP/1.1 201 Created

11. SAML SP (metadata + login redirect)

SP metadata endpoint serves the EntityDescriptor XML:

$ curl -s -i http://127.0.0.1:8097/auth/saml/metadata
HTTP/1.1 200 OK
content-type: application/samlmetadata+xml

$ curl -s http://127.0.0.1:8097/auth/saml/metadata | head -c 130
<ns0:EntityDescriptor xmlns:ns0="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ns1="urn:oasis:names:tc:SAML:metadata:algsupport" xml

Login produces a 303 to the IdP SSO URL with a SAMLRequest over the Redirect binding:

$ curl -s -i "http://127.0.0.1:8097/auth/saml/login"
HTTP/1.1 303 See Other
location: https://idp.demo.litellm.ai/sso?SAMLRequest=hVFdTwIxEPwr...

The signed-assertion ACS round-trip, which needs an IdP to mint a signed response, is covered end to end in tests/test_litellm/proxy/auth_v2/test_saml.py, which stands up a real pysaml2 IdP and verifies provisioning, the session cookie, the validated RelayState redirect, and rejection of tampered and unsigned assertions

12. ABAC authorizer (attribute-based decisions)

ABACEngine is additive and not wired to a live route yet, so this is a decide() transcript over the two example policies rather than a curl. The operator policy:

policies:
  - sub_rule: "'manager' in r_sub.roles"
    obj_rule: "r_obj.endpoint == '/v1/messages' and r_obj.model in ['claude-sonnet-4-6','gpt-4o']"
    act: "POST"
  - sub_rule: "'eng' in r_sub.teams"
    obj_rule: "r_obj.mcp_server == 'github' and r_obj.mcp_tool in ['search','read_file']"
    act: "POST|GET"

Driving ABACEngine(policy_path=...).decide(...) with a manager principal and an eng-team principal shows each attribute acting as an independent gate:

allow manager POST /v1/messages model=gpt-4o
deny  manager POST /v1/messages model=claude-opus-4-8
deny  manager GET  /v1/messages model=gpt-4o
allow eng     POST mcp github/search
deny  eng     POST mcp github/delete_repo

A manager is denied a model outside the allowlist and denied a non-POST verb, and the eng team is denied a tool outside its server allowlist. The unlisted-model deny is the exact case the Casbin CSV FileAdapter silently allowed, and it is pinned in tests/test_litellm/proxy/auth_v2/test_abac.py:

$ python -m pytest tests/test_litellm/proxy/auth_v2/test_abac.py -q
17 passed

Test suite

$ python -m pytest tests/test_litellm/proxy/auth_v2/ -q
178 passed

Type

🆕 New Feature

Changes

This adds litellm/proxy/auth_v2/, a standards-based authentication and identity module that consolidates the auth approaches an enterprise deployment needs behind one consistent seam. It lives under litellm/proxy because it is FastAPI-dependent and proxy-only. It is additive and unimported by the proxy today, so merging it changes no existing behavior; it gives later PRs a typed foundation to wire in.

Merge strategy

This PR is kept as a self-contained reference rather than something to merge wholesale. The module lands as litellm/proxy/auth_v2/ so the full design can be reviewed in one place, and the pieces are then ported into the existing litellm/proxy/auth/ gradually, so the tree never carries two parallel auth stacks. The target organization is by responsibility rather than by version: an authenticators/ package for credential verification across API key, HTTP basic, bearer JWT, OAuth2 introspection, OIDC, SAML and mTLS; a resolvers/ package holding the resolver, the Principal and its sub-models, the user, team, org, project and end-user resolution, and the request network context that gets stamped onto the Principal; an authorization/ package for RBAC and ABAC; a scim/ package for SCIM 2.0 provisioning; and a sessions/ package for session storage. The FastAPI routers for the SSO and SCIM endpoints live in the app layer and call into these packages, not inside the auth module. During the migration the existing entrypoints (user_api_key_auth, auth_checks) delegate into the cleaner internals until the old paths can be removed safely.

The module implements all five OpenAPI security scheme types as FastAPI Security() dependencies (apiKey, http covering both basic and bearer JWT, oauth2 with token introspection, openIdConnect, and mutualTLS), and every one of them resolves to a single normalized Principal carrying user, organization, teams, project and end-user identity. Wiring is dependency-injected through an AuthSecurity instance rather than a global installer: you construct AuthSecurity(config, resolver) and a route declares what it needs with Security(auth.principal, scopes=[...]), auth.require_roles(...), or auth.require_permission(obj, act), staying agnostic to how the caller authenticated; the OIDC, SAML, and SCIM routers are mounted via build_oidc_router(auth) / build_saml_router(auth) / build_scim_router(auth). Injecting the instance keeps the seam unit-testable without monkeypatching. Scheme matching follows a configured order and is fail-fast: the first scheme whose credential is present is the one that decides the request, so a present-but-invalid credential is rejected rather than falling through to a later scheme. Scope failures surface as RFC 6750 403 insufficient_scope and missing credentials as 401 with the proper WWW-Authenticate challenge.

The package is organized so each browser/provisioning protocol owns its own surface: oidc/, saml/, and scim/ are sub-packages carrying their config and router, while the shared core (authenticators, security, resolver, models, rbac, network, session) stays flat. Public imports are unchanged because the top-level package re-exports the protocol configs and routers.

Identity provisioning from OIDC, SAML, and SCIM all flow through the same resolver seam so downstream code sees one identity shape regardless of source. OIDC login uses Authlib. SAML is a full pysaml2 service provider with SP metadata, a login redirect over the Redirect binding, and an ACS endpoint that validates signed assertions, provisions the user, sets a session cookie, and redirects to a validated RelayState. SCIM 2.0 user provisioning uses scim2-models and every SCIM route is guarded by a scim:write scope check. RBAC is backed by Casbin: rbac.py wraps a casbin.Enforcer over an embedded RBAC model with a role hierarchy, so require_roles honors role inheritance and a new require_permission(obj, act) dependency does policy-based object/action authorization, with an optional operator CSV override via AuthConfig.casbin_policy_path. Network identity resolves the real client IP from X-Forwarded-For using a configured trusted-proxy CIDR allow-list with a right-to-left parse so untrusted hops cannot spoof the client address.

Dependencies added under the proxy extra: Authlib for OIDC, scim2-models for SCIM, pysaml2 for SAML, and casbin for RBAC (pure Python, no native build, locked at 1.43.0 with its simpleeval dep). PyJWT gains the [crypto] extra so RS256 JWKS verification works out of the box. 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, so a pyOpenSSL>=26 floor is pinned to stay compatible. scim2-models ships py.typed, but its generic alias-driven models report phantom call-arg errors under mypy that do not occur at runtime, so it is treated as untyped at the boundary in both litellm/mypy.ini and the root pyproject.toml mypy config; litellm/proxy/auth_v2 is its only consumer.

The authenticators and identity flows are hardened against impersonation and resource-exhaustion. A self-describing token can no longer escalate privilege: roles are filtered through a per-provider allowlist with a separate platform-role gate, and token group claims become teams only once the identity is provisioned rather than being trusted verbatim. OAuth2 introspection enforces both issuer and audience, and JWKS verification runs off the event loop with a bounded fetch (cache, lifespan, timeout). IdP URLs must be https except for loopback. HTTP Basic verifies the password through an injected verifier using PBKDF2 with constant-time comparison and never stores the credential on the principal, failing closed when no verifier is configured. The Casbin action matcher is anchored so a permissive pattern cannot be widened, forwarded mTLS subject DNs are only honored from a trusted peer, and the SAML SP marks its session cookie Secure and bounds both the session store and the pending login-request store with a TTL and a size cap so unauthenticated login traffic cannot exhaust memory.

Two deployment notes for whoever wires this into the proxy. SAML signature validation needs the xmlsec1 system binary present at runtime; it is a system package, not a pip dependency. And the trusted-proxy resolution expects uvicorn to run with its own proxy-headers middleware disabled (--no-proxy-headers) so the module sees the real socket peer and does the X-Forwarded-For work itself rather than uvicorn rewriting request.client first.

This PR also adds an attribute-based authorizer, ABACEngine, alongside the Casbin RBAC engine and implementing the same Authorizer protocol so it is swappable into AuthSecurity. Where RBAC decides on role plus path plus method, ABAC decides on subject attributes (roles, teams, org, scopes, claims) crossed with resource attributes (endpoint, method, model, mcp_server, mcp_tool), which lets a policy express what RBAC cannot, such as restricting a role to a set of models on a specific endpoint or a team to a set of tools on an MCP server. Policies are operator-supplied YAML loaded via AuthConfig.abac_policy_path and registered into an in-memory enforcer. The Casbin CSV FileAdapter is avoided on purpose because it retains the quotes around a comma-bearing expression and turns an eval'd condition into a truthy string literal that silently allows; YAML keeps list literals first-class and sidesteps it. Two properties hold for correctness: claims access yields None for a missing key so a single policy row referencing an absent claim cannot raise and poison the whole decision (Casbin evaluates every row in one matcher), and any rule-evaluation error fails closed to deny. Like the rest of the module it is additive and unimported by the proxy today; binding it to live endpoints, which means extracting the model and MCP tool from the request body at the right point in the request lifecycle and pinning the decision to the router-resolved model, is deferred to a follow-up

Deferred to follow-ups so this PR stays isolated: a database-backed resolver to replace the in-memory store, SAML single logout, multi-IdP selection, and SCIM filter expression support

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.
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.
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.
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.
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.
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.
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).
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.
@yassin-berriai
yassin-berriai requested a review from a team June 11, 2026 01:13
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces litellm/proxy/auth_v2/, a standards-based auth and identity module for the FastAPI proxy. It is additive and unimported by the proxy today, so merging changes no existing behavior. The module implements all five OpenAPI security scheme types as FastAPI Security() dependencies, resolving each to a normalized Principal, and pairs them with Casbin RBAC and ABAC authorizers, a full SAML SP, SCIM 2.0 provisioning, and OIDC login flows mounted through backend/auth/ routers.

  • litellm/proxy/auth_v2/: Core auth package — authenticators, resolvers, authorization engines (RBAC via Casbin, new ABAC via YAML policies), session stores (in-memory + Redis), network-identity utilities, and a DbIdentityStore that wires the proxy's existing Prisma tables into the SCIM provisioning interface.
  • backend/auth/routers/: Protocol-specific FastAPI routers (OIDC, SAML ACS/metadata, SCIM 2.0) that sit on top of the core; SAML and OIDC callbacks now properly set session cookies and validate relay state; SCIM discovery endpoints are public and write routes are guarded by scim:write.
  • Security hardening addressed: roles filtered through per-provider allowlists, group claims only become teams once provisioned, SAML assertion replay detection with TTL, PBKDF2 Basic auth with constant-time comparison, JWKS fetched off the event loop, trusted-proxy XFF parsed right-to-left, session cookies carry Secure and HttpOnly.

Confidence Score: 4/5

Safe to merge as an additive, unimported module; the bearer-carrier routing bug means OAuth2 introspection will silently not work once wired into live traffic.

The module is purely additive and unimported by the proxy today, so no existing flows are affected. However, the carrier routing logic in AuthSecurity causes OAuth2Authenticator to be permanently shadowed by OIDCAuthenticator: both register the same bearer carrier and setdefault keeps only the first. With the default scheme_order (OPENID_CONNECT before OAUTH2), any bearer token is handled by OIDCAuthenticator, which raises 'no issuer match' for opaque tokens rather than falling through to introspection. An operator who enables OAuth2 introspection would find it never triggers.

litellm/proxy/auth_v2/security.py — the _by_carrier construction and _authenticator_for routing

Important Files Changed

Filename Overview
litellm/proxy/auth_v2/security.py Core enforcement layer; contains a routing bug where OAuth2Authenticator's bearer carrier is permanently shadowed by OIDCAuthenticator via setdefault in _by_carrier.
litellm/proxy/auth_v2/resolvers.py DB identity store with SCIM provisioning; list_users and list_groups silently ignore filter_expr (acknowledged as deferred in PR description).
litellm/proxy/auth_v2/authenticators/utils.py JWT verification utilities; _discover_jwks uses blocking httpx.get in init which can stall the event loop during startup, but verification itself is correctly offloaded via run_in_threadpool.
litellm/proxy/auth_v2/authorization/abac.py ABAC policy engine; uses _SafeClaims to prevent KeyError from poisoning multi-row evaluation, and fails closed on rule errors.
litellm/proxy/auth_v2/authorization/rbac.py Casbin-backed RBAC engine with role hierarchy; has_any_role checks implicit roles via get_implicit_roles_for_user for inheritance-aware enforcement.
backend/auth/routers/oidc.py OIDC login/callback router; properly sets session cookie on callback, validates relay state, and handles PKCE code verifier.
backend/auth/routers/saml.py SAML SP router with assertion replay protection, role filtering, and secure session cookie (secure flag wired to session.secure which defaults to True).
backend/auth/routers/scim.py SCIM 2.0 router; discovery endpoints are public, write operations guarded by scim:write scope, and DELETE routes now return 404 for unknown resources.
backend/auth/services/saml.py SAML protocol store and SP client builder; SAMLProtocolStore now has TTL and size cap for both outstanding requests and assertion replay detection.

Reviews (7): Last reviewed commit: "feat(auth_v2): add project and end_user ..." | Re-trigger Greptile

Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
Comment thread litellm/proxy/auth_v2/resolvers/memory.py Outdated
Comment thread litellm/proxy/auth_v2/resolvers/memory.py Outdated
Comment thread litellm/proxy/auth_v2/saml.py Outdated
Comment thread litellm/proxy/auth_v2/oidc.py Outdated
Comment on lines +57 to +63
token = await client.authorize_access_token(request)
userinfo = token.get("userinfo")
if userinfo is None:
userinfo = await client.userinfo(token=token)
store: ProvisioningStore = request.app.state.auth_v2.resolver
stored = await store.upsert_user(_user_from_userinfo(dict(userinfo)))
return JSONResponse(content=stored.model_dump())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 OIDC callback completes provisioning but does not establish a session

callback upserts the user into the provisioning store and then returns a JSONResponse containing the stored user object. No session cookie is set, no access token is issued, and the caller has no usable credential from that point on. A browser completing an OIDC login flow would receive a JSON blob and be unable to make any subsequent authenticated request through get_current_principal. The response needs to either set a session cookie (similar to the SAML ACS handler) or redirect to a post-login page.

Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces litellm/auth_v2/, a new standards-based authentication and identity module implementing all five OpenAPI security schemes (API key, HTTP basic/bearer, OAuth2, OIDC, mTLS) plus SAML and SCIM 2.0. The module is additive and not yet wired into the proxy, so merging it does not change existing behavior.

  • FastAPI imports throughout litellm/auth_v2/: Every file in the package imports from FastAPI, which is a proxy-only dependency. The package should live under litellm/proxy/auth_v2/ to follow the project convention.
  • SAML session cookie missing secure=True: The cookie set in saml.py after ACS processing lacks the Secure flag, allowing it to travel over unencrypted HTTP.
  • SCIM discovery endpoints require scim:write: /ServiceProviderConfig, /ResourceTypes, and /Schemas are covered by the router-level scope dependency, violating RFC 7644 which mandates these endpoints be publicly accessible.

Confidence Score: 3/5

The module is additive and does not affect existing proxy behavior today, but it has architectural placement issues and real security defects that would affect any deployment that wires it in.

The FastAPI-outside-proxy rule is violated across every file in the new package — the entire module is in the wrong directory. The SAML cookie is missing secure=True, meaning session tokens travel in cleartext over HTTP. The SCIM router gates its RFC-mandated public discovery endpoints behind scim:write, which breaks standard SCIM clients. These three issues together make the module not ready to ship as-is even though it doesn't touch existing code paths.

litellm/auth_v2/saml.py (cookie security, unbounded session store), litellm/auth_v2/scim.py (discovery endpoint auth gate, DELETE 404 handling), and every file in litellm/auth_v2/ (FastAPI import placement)

Important Files Changed

Filename Overview
litellm/auth_v2/errors.py New error helpers — imports fastapi.HTTPException directly, violating the rule that FastAPI imports belong only in litellm/proxy/
litellm/auth_v2/authenticators.py Five authenticator implementations (API key, HTTP basic/bearer, OAuth2, OIDC, mTLS); imports FastAPI Request outside proxy/; JWT verification and issuer-selection logic looks sound
litellm/auth_v2/saml.py Full pysaml2 SP implementation; SAML session cookie is missing secure=True; in-memory session store has no TTL or size cap
litellm/auth_v2/scim.py SCIM 2.0 CRUD routes; discovery endpoints incorrectly gated by scim:write; DELETE returns 204 for non-existent resources instead of 404
litellm/auth_v2/security.py Core FastAPI dependency (get_current_principal) and install_auth wiring; fail-fast authenticator loop is correct; imports FastAPI outside proxy/
litellm/auth_v2/oidc.py OIDC login/callback routes via Authlib; callback returns raw ScimUser profile as JSON body with no session cookie or redirect, exposing PII
litellm/auth_v2/resolver.py In-memory identity resolver and provisioning store; SHA-256 key hashing without HMAC acceptable for demo store; no FastAPI imports
litellm/auth_v2/network.py Right-to-left X-Forwarded-For trusted-proxy resolution; logic is correct; imports FastAPI Request outside proxy/
litellm/auth_v2/config.py Pydantic config models for all auth schemes; well-structured with sensible defaults
litellm/auth_v2/models.py Core data models (Credential, Principal, NetworkContext, etc.); clean and well-typed
litellm/auth_v2/rbac.py Role enum and scope/role check helpers; imports fastapi.security.SecurityScopes outside proxy/
pyproject.toml Adds Authlib, scim2-models, pysaml2, pyOpenSSL>=26 floor under the proxy extra; mypy overrides for scim2_models are documented

Reviews (2): Last reviewed commit: "test(auth_v2): cover SCIM scim:write gua..." | Re-trigger Greptile

Comment thread litellm/proxy/auth_v2/errors.py
Comment thread litellm/proxy/auth_v2/saml/router.py Outdated
Comment thread litellm/proxy/auth_v2/scim.py Outdated
Comment thread litellm/proxy/auth_v2/scim.py Outdated
Comment thread litellm/proxy/auth_v2/saml.py Outdated
Comment thread litellm/proxy/auth_v2/oidc.py Outdated
Comment on lines +52 to +63
@router.get("/{provider}/callback", name="oidc_callback")
async def callback(provider: str, request: Request) -> JSONResponse:
client = oauth.create_client(provider)
if client is None:
raise HTTPException(status_code=404, detail="unknown provider")
token = await client.authorize_access_token(request)
userinfo = token.get("userinfo")
if userinfo is None:
userinfo = await client.userinfo(token=token)
store: ProvisioningStore = request.app.state.auth_v2.resolver
stored = await store.upsert_user(_user_from_userinfo(dict(userinfo)))
return JSONResponse(content=stored.model_dump())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OIDC callback returns raw user profile as the HTTP response body

After a successful OIDC authorization, callback returns stored.model_dump() — the full stored ScimUser record including email and other PII — as a plain JSON body with no session cookie, no access token, and no redirect. Returning the raw identity object exposes PII to browser history, referrer headers, and logging middleware without giving the caller a way to authenticate subsequent requests.

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).
@CLAassistant

CLAassistant commented Jun 11, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ yassin-berriai
❌ claude
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
Comment thread litellm/proxy/auth_v2/authenticators.py Outdated
@veria-ai

veria-ai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds an auth_v2 standards-based authentication and identity module. The touched authenticator code includes support for deriving identity from configured forwarded mTLS subject headers.

Most previously reported issues have been addressed, with one security concern still open. The remaining issue is in the forwarded mTLS subject flow, where certain proxy-header configurations could allow a client to spoof a trusted proxy address and be accepted as a supplied certificate subject. Because this can result in authentication as another mTLS identity when the feature is enabled in a vulnerable deployment setup, the PR still carries meaningful security risk until that path fails closed or validates trust at the correct layer.

Open issues (1)

Fixed/addressed: 6 · PR risk: 7/10

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.
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.
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.
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.
…LETE

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.
Comment thread litellm/proxy/auth_v2/saml.py Outdated
Comment thread litellm/proxy/auth_v2/saml/router.py Outdated
…y, 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.
… 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.
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.
@yassin-berriai
yassin-berriai marked this pull request as draft June 11, 2026 03:25
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.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review the latest commit (c9e7fd8). Your last review was the original head 7cf35cc; since then the module was relocated under litellm/proxy/auth_v2 with oidc/saml/scim sub-packages, RBAC moved to Casbin, and the full security wave landed (HTTP Basic verified via PBKDF2 with no credential leakage, OAuth2 introspection enforces iss+aud, JWKS off-loop, per-provider role allowlist on bearer/OIDC-login/SAML-SSO with groups authoritative only when provisioned, SCIM discovery public per RFC 7644 and DELETE 404, SAML cookies Secure with TTL+size-bounded session and pending-login stores, and forwarded mTLS preferring the verified cert and gating the forwarded DN on the trusted socket peer). Tests run in CI via a dedicated proxy-auth-v2 shard and the suite is at 173 passing.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai the review above scored the original snapshot (last reviewed commit 7cf35cc). It cites litellm/auth_v2/ paths and findings that were all resolved in later commits and no longer exist on the current head a7f9d5d; the module now lives under litellm/proxy/auth_v2 with oidc/saml/scim sub-packages. Each cited finding maps to the commit that fixed it:

Finding from the stale review Resolved in
Module imports FastAPI outside proxy/; should live under litellm/proxy/auth_v2/ 104a5e1 (relocated, with oidc/saml/scim sub-packages)
HTTP Basic password never verified; plaintext password in Principal.claims 71a189b (injected PBKDF2 verifier, hmac.compare_digest, password never stored on the principal)
SAML cookie missing Secure; session store unbounded 71a189b (Secure cookie + TTL/size-bounded session), fec8e0a (TTL/size-bounded pending-login map)
SCIM discovery endpoints gated by scim:write ca896ac (ServiceProviderConfig/ResourceTypes/Schemas made public per RFC 7644)
SCIM DELETE returns 204 for a missing resource ca896ac (returns 404)
OIDC callback returns raw profile JSON with no session fc6d51c plus the OIDC session work (sets a Secure session cookie and redirects)
Synchronous JWKS fetch blocks the event loop JWKS verification runs off-loop via run_in_threadpool with a bounded cached client

Hardening landed since that snapshot as well: AuthSecurity DI replaced install_auth (8302f55), RBAC moved to Casbin, a per-provider role allowlist now gates bearer, OIDC-login, and SAML-SSO roles (fc6d51c, 99efd31), OAuth2 introspection enforces iss and aud, and forwarded mTLS prefers the verified certificate and gates the forwarded DN on the trusted socket peer (c9e7fd8). Please re-review the current head a7f9d5d; the suite is at 175 passing and runs in CI via a dedicated proxy-auth-v2 shard.

Comment thread litellm/proxy/auth_v2/saml/config.py Outdated
Comment on lines +34 to +40
@model_validator(mode="after")
def _require_idp_metadata(self) -> "SAMLConfig":
if self.enabled and not self.idp_metadata.strip():
raise ValueError(
"SAML enabled but idp_metadata is empty (inline XML, local path, or URL)"
)
return self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security The idp_metadata field accepts a remote URL but has no HTTPS requirement. _metadata_source explicitly routes http://-prefixed strings to pysaml2's "remote" fetch, so an operator who supplies http://idp.example.com/metadata.xml causes the SP to download IdP metadata over plain HTTP. An attacker with MITM capability can intercept that fetch, substitute their own X.509 certificate in the metadata, and then forge SAML assertions signed with the corresponding private key — giving them arbitrary identity. Every other URL-bearing config in this PR (OIDCProviderConfig._issuer_https, OIDCProviderConfig._jwks_https, OAuth2IntrospectionConfig._endpoint_https) already calls require_secure_url; SAMLConfig should too.

Suggested change
@model_validator(mode="after")
def _require_idp_metadata(self) -> "SAMLConfig":
if self.enabled and not self.idp_metadata.strip():
raise ValueError(
"SAML enabled but idp_metadata is empty (inline XML, local path, or URL)"
)
return self
@model_validator(mode="after")
def _require_idp_metadata(self) -> "SAMLConfig":
if self.enabled and not self.idp_metadata.strip():
raise ValueError(
"SAML enabled but idp_metadata is empty (inline XML, local path, or URL)"
)
stripped = self.idp_metadata.strip()
if stripped.startswith("http://") or stripped.startswith("https://"):
from ..models import require_secure_url
require_secure_url(stripped)
return self

Rule Used: What: Fail any PR which may contains a security in... (source)

…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".
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.
…ment

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.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai the rbac.py default-policy matcher note from the 4/5 review is addressed in e989016: the Casbin matcher now uses keyMatch(r.obj, p.obj), the canonical trailing-wildcard route matcher, instead of keyMatch2, so obj patterns like /* and /scim/v2/* unambiguously span path separators. In pycasbin 1.43.0 keyMatch2 already spanned separators so no live 403 existed, but keyMatch removes the ambiguity the review flagged; the act matcher stays anchored as regexMatch(r.act, "^(" + p.act + ")$") so GET does not grant GETX. Pinned in 2fad79b: platform roles authorize multi-segment paths (/api/v1/models, /api/v1/x/y) and deep SCIM paths, org_viewer is denied a multi-segment write, and GETX is denied. The suite is at 178 passing and runs in CI via the dedicated proxy-auth-v2 shard. Please re-review head 2fad79b.

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
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).
…s.py

There was only ever one resolver, so the resolvers/ package (base, db,
utils) collapses into a single resolvers.py holding the protocols and
DbIdentityStore, plus a utils.py for the pure SCIM/role-mapping helpers.
Drops the unused roles_from_claims/public_claims helpers.
claude and others added 3 commits June 13, 2026 21:47
Principal carried user, organization and teams but omitted the project
and end-user attribution axes that the existing key path tracks. Add
ProjectIdentity and EndUserIdentity sub-models and the matching optional
fields, and project them off the key object in _principal_from_key:
project_id/project_alias map to ProjectIdentity, end_user_id to
EndUserIdentity. Both stay None when absent.

project_id is a column on the verification token, so it resolves from
the combined-view key object directly. end_user_id is request-scoped and
will be stamped at the seam, the same way network context is; the
resolver maps it whenever the key carries it.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment on lines +101 to +111
list(authenticators)
if authenticators is not None
else build_authenticators(config, basic_verifier=basic_verifier)
)
chain.append(SessionAuthenticator(config.session.cookie, self.session_store))
self.authenticators = chain
self._by_carrier: Dict[Carrier, Authenticator] = {}
for authenticator in chain:
for carrier in authenticator.carriers():
self._by_carrier.setdefault(carrier, authenticator)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 OAuth2Authenticator permanently shadowed by OIDCAuthenticator

Both OIDCAuthenticator and OAuth2Authenticator advertise Carrier(CredentialLocation.AUTHORIZATION_SCHEME, "bearer"). The _by_carrier.setdefault(carrier, authenticator) loop only registers the first authenticator per carrier, so OAuth2Authenticator is never stored in _by_carrier and can never be reached.

Because OPENID_CONNECT precedes OAUTH2 in the default scheme_order, OIDCAuthenticator always owns the bearer carrier — even when no OIDC providers are configured (self._verifiers == []), in which case OIDCAuthenticator.authenticate() raises errors.invalid_token("no issuer match") for every bearer token. An operator who configures OAuth2 introspection will find it never triggers, regardless of whether they put OAUTH2 earlier in scheme_order (as long as OPENID_CONNECT is also present, which it is by default).

…plate

Rename IdentityResolver -> Resolver and DbIdentityStore -> DbResolver so the
names stop colliding with authentication; the package these move to is
resolvers/, not identity/. Drop the IdentityStore union protocol, which was only
used as DbResolver's base; DbResolver now inherits Resolver and ProvisioningStore
directly. Drop the @runtime_checkable decorators on these protocols, which had no
isinstance callers. ProvisioningStore keeps its name.
…itellm_fix/auth-module

# Conflicts:
#	litellm/mypy.ini
#	pyproject.toml
#	uv.lock
…itellm_fix/auth-module

# Conflicts:
#	uv.lock
@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix/auth-module (64b59f4) with litellm_internal_staging (442fdc1)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants