* chore(hooks): enforce Conventional Commits and Conventional Branches
Adds opt-in local git hooks plus a CI PR-title check:
- .githooks/commit-msg validates commit subjects against Conventional
Commits 1.0.0 (feat|fix|docs|style|refactor|perf|test|build|ci|
chore|revert)(scope)!: subject. Merge/revert/fixup!/squash!/amend!
messages pass through; --no-verify still works.
- .githooks/pre-push validates branch names against Conventional
Branches (feature|bugfix|hotfix|release|chore)/desc. Bypasses
main, litellm_internal_staging, dependabot/*, gh-readonly-queue/*.
Tag pushes and deletions are skipped.
- scripts/install_git_hooks.sh sets core.hooksPath=.githooks and is
wired up as 'make install-hooks'. Opt-in — not chained into
install-dev.
- .github/workflows/conventional-commits.yml validates PR titles via
amannn/action-semantic-pull-request pinned to v6.1.1's SHA. This is
the actual gate since squash-merge uses the PR title as the commit
subject.
- tests/test_litellm/test_git_hooks.py exercises both hooks via
subprocess for accept / reject / bypass / git-generated-message
cases.
- CONTRIBUTING.md documents the conventions, the install step, the
bypass list, and the --no-verify escape hatch.
Resolves LIT-3306
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hooks): address Greptile review on PR #28703
Resolves two findings from the automated code review:
1. CONTRIBUTING.md: shrink the new Conventional Commits / Branches
section to a 2-line pointer at docs.litellm.ai. Per the team
convention, the full documentation lives in the litellm-docs
repo — see BerriAI/litellm-docs#208 for the companion change that
adds the section to docs/extras/contributing_code.md.
2. .githooks/commit-msg: tighten the subject regex to also reject an
uppercase first letter in the description. CI's subjectPattern is
^(?![A-Z]).+$ so the previous local hook would accept 'feat: Add
thing' which would then fail the PR-title check. The local hook is
now the strictly tighter of the two gates. Test cases extended to
cover both the new rejection and the digit/symbol-start cases that
remain allowed.
Resolves LIT-3306
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: trigger ci after branch rename
* fix(ci): rerun pr title check when bypass label changes
amannn/action-semantic-pull-request only honors ignoreLabels if the
workflow retriggers on labeled/unlabeled events; without them a red
check stays red after a maintainer applies the bypass label.
Also point the CONTRIBUTING.md workflow comments at the conventions
section, which now sits above the Development Workflow section.
---------
Co-authored-by: Yassin Kortam <yassinkortam@Yassins-MBP.localdomain>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
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.
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".
* feat(bedrock): support aws_bedrock_project_id for bedrock-mantle project association
Adds a litellm_params field to associate bedrock-mantle requests with an
Amazon Bedrock project, sent as the OpenAI-Project header on the
OpenAI-compatible chat and responses paths and as the anthropic-workspace
header on the Anthropic messages paths. This lets a single model entry opt
into a project-scoped data retention mode (e.g. provider_data_share for
Claude Fable 5) while the account-wide setting stays on default.
The param is carried via litellm_params only and is explicitly excluded
from optional_params so it can never leak into a request body.
Fixes#30070
* chore(ui): regenerate schema.d.ts for aws_bedrock_project_id
Generated with npm run gen:api after adding the field to LiteLLM_Params
* fix(proxy): ban client-supplied aws_bedrock_project_id in request bodies
The deployment pins aws_bedrock_project_id so the project's data
retention policy applies to its requests. Without this guard an
authenticated caller could supply the field in the request body and,
since client kwargs win the router merge, run requests under any
project reachable with the deployment's shared AWS credentials.
Adds the field to _BANNED_REQUEST_BODY_PARAMS so it is rejected at the
auth boundary by default while remaining available through the existing
admin opt-ins (allow_client_side_credentials proxy-wide or
configurable_clientside_auth_params per deployment).
* perf(realtime): eliminate redundant per-frame JSON work on OpenAI realtime relay
The GA realtime support added in #27110 made backend_to_client_send_messages
parse every backend frame up to three times for beta clients (OpenAI-Beta:
realtime=v1), build a discarded Pydantic object per frame for logging, and
re-serialize even frames that need no translation. For high-frequency
response.output_audio.delta frames carrying multi-KB base64 payloads, that
serialized CPU work on the hottest relay path drove the latency regression
between v1.83.14 and v1.88.1 for gpt-realtime-1.5 and gpt-realtime-2.
This parses each frame once via _parse_backend_event and threads the dict into
_handle_raw_backend_message, store_message, and _translate_event_to_beta;
short-circuits store_message before the Pydantic build for events not in the
logged set; returns the original event unchanged from _translate_event_to_beta
when no rename applies so the raw frame is forwarded without re-serialization;
and only json.dumps when the type is actually renamed.
* fix(realtime): widen store_message type hint to accept plain dict
The parse-once refactor passes the dict produced by _parse_backend_event into
store_message, but the parameter was typed as str | bytes | OpenAIRealtimeEvents
(a union of TypedDicts), which mypy does not consider compatible with a plain
dict. Add dict to the accepted union; the body already handles it.
---------
Co-authored-by: Miguel Armenta <maarmenta92@gmail.com>
* fix: coerce server_tool_use dict to ServerToolUse in Usage.__init__ (#26153)
* fix: coerce server_tool_use to ServerToolUse in stream_chunk_builder (#26153)
* fix: dict/pydantic-tolerant access in tool_call_cost_tracking (#26153)
* fix: dict/pydantic-tolerant access in anthropic cost_calculation (#26153)
* test: assert ServerToolUse type in existing stream_chunk_builder anthropic web search test
* test: regression test for #26153 (stream_chunk_builder server_tool_use type)
* test: dict/pydantic safety for tool_call_cost_tracking helper
* test: dict/pydantic safety for anthropic web_search cost
* refactor: consolidate _get_web_search_requests into shared cost-calc utils
* test(realtime): use gpt-realtime; openai retired gpt-4o-realtime-preview
OpenAI shut down the gpt-4o-realtime-preview family (incl. the undated
alias) on 2026-05-07, causing the live realtime test to fail with a
4000 invalid_request_error.invalid_model close. gpt-realtime is the GA
successor; switch the live-call tests to it, matching the base branch.
* refactor(types): drop redundant server_tool_use coercion in Usage.__init__
---------
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
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.
The forwarded subject-DN trust gate already compared the raw transport peer
(request.client.host / ASGI scope client) against trusted_proxy_cidrs, never the
XFF-resolved IP, so an attacker spoofing X-Forwarded-For cannot defeat it. Make
that ordering explicit and stronger: a genuinely verified client certificate from
the ASGI TLS extension is now preferred when present, and the spoofable
forwarded-header path is only consulted as a fallback, still gated on the direct
socket peer.
Adding tests/test_litellm/proxy/auth_v2 to the proxy-auth shard collided with proxy/client/test_models.py (both bare test_models, no package __init__), failing collection with an import file mismatch. Run the auth_v2 suite in its own job with the xmlsec1 apt packages instead; the base apt-packages input added earlier is reused.
The veria review-response fix gates IdP-asserted roles through the same
per-provider allowlist on every role-bearing path, not just JWT bearer tokens.
- rbac: filter_claim_roles (the shared gate) denies a self-asserted role by
default, filters to the allowlist, and only admits platform roles behind the
explicit allow_platform_roles flag
- saml: a signed SSO assertion asserting platform_admin yields a session whose
Principal has no roles by default, and is filtered to the allowlist when set
- oidc: the login callback's identity build (map userinfo -> gate roles ->
session) denies platform_admin by default and filters to the allowlist
Full auth_v2 suite: 173 passing.
The proxy/auth_v2 tests were not picked up by any shard because the proxy-auth path list matched the literal proxy/auth directory, not its sibling. Add the path and install xmlsec1 so the pysaml2 signing tests run rather than skip; the install is gated behind a new optional apt-packages input so the other shards are unchanged.
Regression coverage for the security review fixes (H1/M1/M3/S7):
- resolver: a deactivated SCIM user (active=False) is rejected 403; claims
whose keys start with "_" never surface on the Principal
- authenticators: H1 privilege escalation - a self-asserted token role grants
nothing without a per-provider allowlist, the allowlist filters roles, and
platform-level roles need an explicit allow_platform_roles gate
- rbac: the Casbin act matcher is anchored, so a "GET" policy does not grant
"GETX"
- scim: PATCH that targets the read-only id (replace, remove, no-path replace)
is rejected 400 with the record's id unchanged; unauthenticated/under-scoped
requests render a SCIM Error body (401/403); /Schemas is a ListResponse
envelope
- saml: a replayed signed assertion is rejected 401 (single-use), and an
unsolicited IdP-initiated response is rejected 401 when allow_unsolicited is
off (default secure)
Full auth_v2 suite: 165 passing.
The per-provider role allowlist + platform-role gate only covered the bearer-JWT
path, so a SAML IdP could still mint platform_admin (or any Role) through its
attribute->roles mapping. Extract the filter into a shared rbac.filter_claim_roles,
add allowed_roles/allow_platform_roles to SAMLConfig (default empty = no roles
from the assertion), and apply it in the ACS before the attributes become claims,
so SSO paths enforce the same role policy as token paths. The JWT path now reuses
the same helper.
The browser OIDC login/callback path accepted IdP-asserted roles straight into
the session, so a malicious or misconfigured IdP could assert platform_admin
over SSO and have it land in the Principal - the same escalation the bearer
token path already closes. The callback now runs the mapped claims through the
shared _apply_role_policy with the matched provider config before minting the
session, so roles outside allowed_roles are dropped and platform roles require
allow_platform_roles. With the defaults (empty allowlist, platform off) no
IdP-asserted role survives.
Follow the sub-package split (oidc/saml/scim/*) and the token-claim hardening:
- import config models (OIDCProviderConfig, SAMLConfig) from the top-level
package and the moved helpers from their concrete sub-modules
(saml.router, saml.config, oidc.router) so tests are stable against
__init__ re-export churn
- resolver: a token group claim is no longer authoritative on its own; it
becomes a TeamIdentity only when it resolves to a provisioned SCIM Group in
the store (split into provisioned vs not-provisioned cases)
Full auth_v2 suite green (153) and stable across repeated runs.
S7 is already handled self-contained by the SCIM router's route_class (renders
401/403 as a SCIM Error while preserving WWW-Authenticate), so remove the
redundant errors.scim_error_response and its AuthSecurity docstring note. Also
stop re-exporting private underscore helpers from the oidc/saml sub-package
__init__s; the public names (config + build_*_router) remain re-exported and
test code references the concrete modules for internals.
- Re-export the test/public helpers from the sub-package __init__s so existing
imports resolve: oidc exposes _provider_key/_user_from_userinfo, saml exposes
_map_attributes/_metadata_source/_user_from_mapped.
- S7: add errors.scim_error_response(exc) rendering the RFC 7644 SCIM Error schema;
a host registers it as the exception handler for the SCIM routes (noted on the
AuthSecurity docstring).
- veria-ai MEDIUM: bound the SAML outstanding-request map with a TTL (300s) and
max-size eviction, the same treatment as the session store, so unauthenticated
/auth/saml/login traffic can no longer accumulate login state unbounded.
- Silence a fastapi/starlette generic-Request override quirk on the SCIM route's
get_route_handler so mypy is clean at the freeze sha.
* fix(proxy): align /v1/model/info with router deployments
Return router model_list entries (including team-scoped models) with team
access metadata instead of wildcard-expanded names from get_complete_model_list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(proxy): gate v1 team filter and honor key allowlists
Only apply get_all_team_and_direct_access_models for admin or user-bound
keys, then intersect with key/team model restrictions to avoid empty lists
for service tokens and metadata leaks for restricted keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(proxy): skip v1 team filter when user row is missing
Require a DB-backed user before applying team-access filtering on
/v1/model/info, and skip the trailing filter in get_all_team_and_direct_access_models
when user context cannot be resolved.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "fix(proxy): skip v1 team filter when user row is missing"
This reverts commit 74e1fbd77a.
* fix(proxy): restore legacy v1 model access filtering
Keep /v1/model/info on key/team allowlists instead of DB team-membership
filtering, while still listing router deployments for team-scoped models.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(proxy): drop A2A agent entries from public /v1/model/info list
* fix(proxy): scope team BYOK rows on /v1/model/info to caller's teams
Listing the full router model_list let any authenticated key without
explicit model restrictions enumerate other teams' BYOK deployments
(public name, team_id, api_base) via /v1/model/info. Reuse the existing
_get_caller_byok_team_scope check so non-admin callers only see global
deployments plus their own team's BYOK rows; admins keep the full view.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
PATCH no longer lets a scim:write principal reassign the read-only id
attribute (RFC 7643): any operation whose path targets id, or a no-path value
object carrying id, is rejected with 400 instead of rewriting the record
identifier and clobbering another resource. Authentication failures on the
guarded SCIM routes now render the SCIM Error schema (RFC 7644) for 401 and
403 via a router-scoped route class, preserving the WWW-Authenticate
challenge, rather than the generic {detail} body.
Token-flows and credential-flows security review fixes:
- H1 (privilege escalation): a validly-signed token could self-assert
platform_admin and arbitrary teams via the roles/groups claims. Roles from a
token are now filtered to a per-provider allowlist (OIDCProviderConfig.allowed_roles,
default empty = none) with platform-level roles gated behind an explicit
allow_platform_roles flag; groups become authoritative TeamIdentity only when they
resolve to a provisioned SCIM Group in the store. Introspection responses carry no
roles (no per-provider policy applies).
- M1: enforce iss on introspection (OAuth2IntrospectionConfig.issuer) in addition to aud.
- M2: bound JWKS refetch with cache_jwk_set + a 300s lifespan and add a 10s PyJWKClient
timeout, so an unknown-kid stream can't amplify into per-request network fetches.
- M3: require https for issuer/jwks_uri/introspection_endpoint (loopback excepted for dev).
- rbac: anchor the Casbin act matcher (^(...)$) so a "GET" policy can't grant "GETX".
- basic auth: switch the reference store to pbkdf2_hmac-sha256 (600k iterations); the
verifier protocol still lets deployments plug argon2/bcrypt.
- LOW: generic invalid_token description instead of echoing PyJWT internals; guard the
introspection response.json() and require active to be boolean true.
Per the revised design §2, each protocol that carries its own config/routes
becomes a sub-package while the shared core stays flat. oidc.py -> oidc/router.py
with oidc/config.py (OIDCProviderConfig); saml.py -> saml/router.py with
saml/config.py (SAMLConfig + the attribute-map default); scim.py -> scim/router.py.
Each sub-package __init__ re-exports its public names so call sites read
from litellm.proxy.auth_v2.saml import SAMLConfig, build_saml_router. SessionConfig
moves next to the SessionStore it configures in session.py. git mv preserves
history; AuthConfig now composes the protocol configs from their sub-packages.
Repoint the whole test surface off install_auth/app.state onto the AuthSecurity
composition root: build AuthSecurity(config, store, ...) and declare routes with
Security(auth.principal[, scopes]), auth.require_roles, auth.require_permission;
mount routers via build_*_router(auth). Apply the PEP8 renames (JWTVerifier,
APIKeyAuthenticator, OIDCAuthenticator, MutualTLSAuthenticator, OIDCProviderConfig,
SAMLConfig, MutualTLSConfig, RBACEngine.has_any_role). SAML moves to the shared
SessionStore + "litellm_session" cookie and session.safe_relay_state; the
build_authenticators tests assert concrete types now that the scheme attribute is
gone. No coverage lost; 151 tests pass.
Note: routes use the default-value Security() style instead of Annotated[...]
because this module runs under future annotations, where an Annotated marker is
stringified and FastAPI re-evaluates it in module globals, which cannot see the
closure-local auth instance.
The OIDC callback now mints a server-side session and sets the shared session
cookie (httponly, secure, samesite=lax) before redirecting, so login yields an
authenticated session that the shared SessionAuthenticator resolves; previously
it returned the user record as JSON and left the caller unauthenticated. The
login flow owns its CSRF protection without Starlette SessionMiddleware: it
stores state, nonce and the PKCE (S256) verifier in the short-lived
oauth_txn_store keyed by a temporary cookie, then on callback consumes the
transaction one-time, checks the returned state, exchanges the code with the
verifier and validates the nonce against the id_token. A replayed or expired
state finds no transaction and returns 400.
SCIM moves onto the AuthSecurity DI surface: build_scim_router(auth) reads
auth.resolver and guards writes with Security(auth.principal, scopes=
["scim:write"]) instead of app.state and get_current_principal.
Per user direction, drop install_auth/AuthContext/app.state entirely; the
enforcement layer is now an AuthSecurity object whose bound methods are the
FastAPI Security() dependencies. The app constructs AuthSecurity(config, resolver)
once and passes auth.principal / auth.require_roles / auth.require_permission to
Security(); routers take the instance explicitly via build_*_router(auth) and read
auth.resolver/auth.config rather than request.app.state.
Browser sessions are unified behind a shared SessionStore + SessionAuthenticator
(session.py): one cookie, keyed on identity["method"], so SAML and the upcoming
OIDC login flow share one store. AuthSecurity owns the post-login session_store
and a short-TTL oauth_txn_store for OIDC state/nonce/PKCE; SessionConfig moves the
cookie/TTL/redirect settings off SAMLConfig. SAML keeps its protocol-specific
outstanding/replay state local.
Fold in the standing judge findings: collapse the triplicated http/oauth2/oidc
bearer paths into one _authenticate_bearer_jwt helper, inject the introspection
async-client via a factory instead of importing litellm inline, drop the dead
scheme attribute from the authenticators, and rename to PEP 8 acronym casing
(JWTVerifier, OIDCAuthenticator, OIDCProviderConfig, SAMLConfig, RBACEngine,
APIKeyAuthenticator, MutualTLSAuthenticator). __all__ now exports AuthSecurity,
Role and the resolver protocols.
scim.py and oidc.py move to build_*_router(auth) separately.
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.
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.
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.
* refactor(ui): consolidate dashboard to one shell in the (dashboard) layout
Moves the legacy ?page= switch page into the (dashboard) route group and
hoists Navbar, sidebar, ThemeProvider, and DebugWarningBanner into the
shared layout with real props, deleting the degraded duplicate shell that
wrapped migrated routes. The active page key now derives from the URL at
render time, so navigating between legacy and migrated pages no longer
remounts the shell.
useProxySettings becomes a React Query hook taking accessToken, shared by
the navbar, the AdminPanel arm, and migrated pages; this replaces the
lifted proxySettings state and the Navbar setProxySettings prop drilling.
The invitation onboarding flow (?invitation_id=) keeps rendering without
chrome via a layout escape hatch. Dead dark mode state and the no-op antd
ConfigProvider are removed.
* fix(ui): include accessToken in useProxySettings query key
The queryFn closes over accessToken, so the key must include it for the
cache to be honest about its inputs. Settings are instance-global today,
which made the omission harmless, but a token change while mounted would
have served the cached entry without refetching.
* test(ui): point CreateKeyPage test at the moved page
The page moved into the (dashboard) route group and no longer renders
the navbar (the layout owns chrome now), so the valid-token test asserts
the default page content (UserDashboard stub) instead.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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 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.
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.
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.
* fix(ui): serve migrated-page links unprefixed on the dev server
migratedHref and legacyPageHref always prepended /ui, which is where the
proxy mounts the static export but not where next dev serves the app
(basePath is empty; the app lives at the root on localhost:3000). Every
sidebar link to a migrated page and every ?page= bookmark redirect
therefore 404'd in dev, and would do so for each page cut over in the
App Router migration.
uiBase now returns the bare root under NODE_ENV=development. The check
is inlined at build time, so production output is unchanged for both
the default /ui mount and server_root_path deployments.
* test(ui): pin NODE_ENV in production-mode migratedPages tests
The production-mode describes relied on vitest defaulting NODE_ENV to
test; a developer with NODE_ENV=development exported in their shell
would see them fail. Stub it explicitly so the suite is deterministic
regardless of ambient environment.
create-release published every release with GitHub's default make_latest,
which is true, so any newly published stable release claimed the repo
"Latest" badge regardless of version. That let a backport like 1.84.6
overwrite a newer line like 1.88.1 as latest.
Compute make_latest explicitly: a stable release only claims latest when
its version is >= the current latest (via getLatestRelease), backports to
an older line publish with make_latest false, and prereleases never claim
latest. Version comparison accounts for the maintenance suffix (.postN and
legacy -stable.patch.N) so within-line ordering stays correct