feat(auth_v2): standards-based auth and identity module - #30171
feat(auth_v2): standards-based auth and identity module#30171yassin-berriai wants to merge 55 commits into
Conversation
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.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR introduces
Confidence Score: 4/5Safe 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
|
| 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
| 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()) |
There was a problem hiding this comment.
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.
Greptile SummaryThis PR introduces
Confidence Score: 3/5The 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 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)
|
| 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
| @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()) |
There was a problem hiding this comment.
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).
|
|
PR overviewThis PR adds an 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.
…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.
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.
|
@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. |
|
@greptileai the review above scored the original snapshot (last reviewed commit 7cf35cc). It cites
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. |
| @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 |
There was a problem hiding this comment.
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.
| @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.
|
@greptileai the rbac.py default-policy matcher note from the 4/5 review is addressed in e989016: the Casbin matcher now uses |
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.
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.
| 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) | ||
|
|
There was a problem hiding this comment.
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
Relevant issues
None
Linear ticket
None
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
make test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays 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)
Branch creation CI run
Link: https://github.com/BerriAI/litellm/pull/30171/checks
CI run for the last commit
Link: https://github.com/BerriAI/litellm/pull/30171/checks?sha=c883abfc56915ea11a7794b3b9009c696372320f
Merge / cherry-pick CI run
Links:
Screenshots / Proof of Fix
Every command below was run against a live FastAPI instance that builds an
AuthSecurityover an in-memory identity store and is exercised with curl. The demo seeds two API keys (sk-demo-admincarriesORG_ADMINplus themodels:readandscim:writescopes;sk-demo-readercarries onlymodels:read), a PBKDF2 basic-auth verifier, an OIDC provider whose JWKS is served on a second port withallowed_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 withbuild_scim_router(auth). A/dev/mintendpoint 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:
Wrong key never resolves to a different principal; it is a clean 401 with an RFC 6750 challenge:
2. Missing credential
No credential at all is a 401 with a
WWW-Authenticatechallenge advertising the enabled schemes (here the api-key and http authenticators both contribute, so Bearer and Basic are offered):3. HTTP Basic (http scheme, verified)
Correct credentials resolve through the injected PBKDF2 verifier to the seeded service-account principal:
A wrong password fails closed; the password is verified with
hmac.compare_digestand is never copied onto the principal:4. Bearer JWT (OIDC provider, local JWKS)
Mint a token whose
groupsare provisioned as SCIM Groups; those groups resolve to teams:Expired and wrong-audience tokens are rejected against the real RS256 signature,
exp, andaudchecks (PyJWT reports a generic failure so the endpoint does not leak which check 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:
A token asserting
platform_admingets it stripped by the provider role allowlist (allowed_roles=["org_admin"], platform roles off), so only the allowlisted role survives:6. Scope enforcement (RFC 6750 insufficient_scope)
The reader principal lacks
chat:write, so/v1/securereturns 403 with theinsufficient_scopechallenge:7. Role enforcement
Admin key carries
ORG_ADMINand passesauth.require_roles(Role.ORG_ADMIN):Reader key does not, so the same route is a 403:
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: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:With an
X-Forwarded-Forfrom the trusted loopback peer, the right-to-left parse skips the trusted hop and reports the real client:The inverse, a spoofed
X-Forwarded-Forfrom an untrusted peer being ignored, cannot be shown from a loopback client because the peer is always trusted here; it is pinned intests/test_litellm/proxy/auth_v2/test_network.py::test_spoofed_xff_from_untrusted_peer_is_ignored10. 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:
The user and group routes are gated by the
scim:writescope. Unauthenticated is 401:Authenticated but missing the scope (reader key) is 403 insufficient_scope:
Create with the scoped admin key is 201 Created:
11. SAML SP (metadata + login redirect)
SP metadata endpoint serves the EntityDescriptor XML:
Login produces a 303 to the IdP SSO URL with a
SAMLRequestover the Redirect binding: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 assertions12. ABAC authorizer (attribute-based decisions)
ABACEngineis additive and not wired to a live route yet, so this is adecide()transcript over the two example policies rather than a curl. The operator policy:Driving
ABACEngine(policy_path=...).decide(...)with a manager principal and an eng-team principal shows each attribute acting as an independent gate: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:Test suite
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 underlitellm/proxybecause 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 existinglitellm/proxy/auth/gradually, so the tree never carries two parallel auth stacks. The target organization is by responsibility rather than by version: anauthenticators/package for credential verification across API key, HTTP basic, bearer JWT, OAuth2 introspection, OIDC, SAML and mTLS; aresolvers/package holding the resolver, thePrincipaland its sub-models, the user, team, org, project and end-user resolution, and the request network context that gets stamped onto thePrincipal; anauthorization/package for RBAC and ABAC; ascim/package for SCIM 2.0 provisioning; and asessions/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 normalizedPrincipalcarrying user, organization, teams, project and end-user identity. Wiring is dependency-injected through anAuthSecurityinstance rather than a global installer: you constructAuthSecurity(config, resolver)and a route declares what it needs withSecurity(auth.principal, scopes=[...]),auth.require_roles(...), orauth.require_permission(obj, act), staying agnostic to how the caller authenticated; the OIDC, SAML, and SCIM routers are mounted viabuild_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 6750403 insufficient_scopeand missing credentials as401with the properWWW-Authenticatechallenge.The package is organized so each browser/provisioning protocol owns its own surface:
oidc/,saml/, andscim/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:writescope check. RBAC is backed by Casbin: rbac.py wraps acasbin.Enforcerover an embedded RBAC model with a role hierarchy, sorequire_roleshonors role inheritance and a newrequire_permission(obj, act)dependency does policy-based object/action authorization, with an optional operator CSV override viaAuthConfig.casbin_policy_path. Network identity resolves the real client IP fromX-Forwarded-Forusing 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
proxyextra: 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).PyJWTgains the[crypto]extra so RS256 JWKS verification works out of the box. pysaml2 pullspyOpenSSLtransitively without pinning it, and older pyOpenSSL capscryptographybelow 46 and breaks at import against the version this proxy already requires, so apyOpenSSL>=26floor is pinned to stay compatible. scim2-models shipspy.typed, but its generic alias-driven models report phantomcall-argerrors under mypy that do not occur at runtime, so it is treated as untyped at the boundary in bothlitellm/mypy.iniand the rootpyproject.tomlmypy config;litellm/proxy/auth_v2is 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
Secureand 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
xmlsec1system 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 theX-Forwarded-Forwork itself rather than uvicorn rewritingrequest.clientfirst.This PR also adds an attribute-based authorizer,
ABACEngine, alongside the Casbin RBAC engine and implementing the sameAuthorizerprotocol so it is swappable intoAuthSecurity. 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 viaAuthConfig.abac_policy_pathand 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-upDeferred 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