Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
2bbf688
build(auth_v2): add Authlib and scim2-models for the auth_v2 module
yassin-berriai Jun 11, 2026
a0a59a2
feat(auth_v2): add standards-based auth and identity module
yassin-berriai Jun 11, 2026
0b74ffa
feat(auth_v2): implement full SAML 2.0 SP via pysaml2
yassin-berriai Jun 11, 2026
677762b
refactor(auth_v2): align SamlConfig with the revised design doc
yassin-berriai Jun 11, 2026
da1a088
feat(auth_v2): redirect after SAML ACS with validated RelayState
yassin-berriai Jun 11, 2026
03facce
test(auth_v2): add test suite for the standards-based auth module
yassin-berriai Jun 11, 2026
d3878ee
fix(auth_v2): require scim:write auth on all SCIM routes
yassin-berriai Jun 11, 2026
7cf35cc
test(auth_v2): cover SCIM scim:write guard and SAML RelayState redirect
yassin-berriai Jun 11, 2026
e309003
feat(auth_v2): back RBAC with Casbin
yassin-berriai Jun 11, 2026
3dc660a
test(auth_v2): cover OAuth2 token introspection over the cached async…
yassin-berriai Jun 11, 2026
55a332b
test(auth_v2): cover Casbin-backed RBAC hierarchy and permissions
yassin-berriai Jun 11, 2026
104a5e1
refactor(auth_v2): move module under litellm/proxy/auth_v2
yassin-berriai Jun 11, 2026
2dd336a
test(auth_v2): move tests under proxy/ to mirror the module relocation
yassin-berriai Jun 11, 2026
71a189b
fix(auth_v2): harden HTTP basic, SAML sessions, and JWKS fetch
yassin-berriai Jun 11, 2026
ca896ac
fix(auth_v2): make SCIM discovery public and return 404 on missing DE…
yassin-berriai Jun 11, 2026
6f3fc5e
fix(auth_v2): close mTLS spoofing, introspection audience, SAML repla…
yassin-berriai Jun 11, 2026
4503499
fix(auth_v2): honor nested SCIM patch paths, align /Schemas, unshadow…
yassin-berriai Jun 11, 2026
c512a49
test(auth_v2): pin the hardened auth behaviors from the security fixes
yassin-berriai Jun 11, 2026
8302f55
refactor(auth_v2): replace install_auth with AuthSecurity DI
yassin-berriai Jun 11, 2026
4ec4830
docs(auth_v2): one-line docstrings on AuthSecurity Security() entrypo…
yassin-berriai Jun 11, 2026
5f02c88
fix(auth_v2): authenticate OIDC login sessions and adapt SCIM to Auth…
yassin-berriai Jun 11, 2026
0ee4397
test(auth_v2): adapt the suite to the AuthSecurity refactor and renames
yassin-berriai Jun 11, 2026
9b9cc60
refactor(auth_v2): split oidc/saml/scim into sub-packages
yassin-berriai Jun 11, 2026
422c4df
fix(auth_v2): close token-claim privilege escalation and related hard…
yassin-berriai Jun 11, 2026
7550968
fix(auth_v2): reject SCIM id mutation and SCIM-shape auth errors
yassin-berriai Jun 11, 2026
fec8e0a
fix(auth_v2): complete the freeze batch (re-exports, SCIM error helpe…
yassin-berriai Jun 11, 2026
a9be3d2
refactor(auth_v2): drop duplicate SCIM error helper and private re-ex…
yassin-berriai Jun 11, 2026
341e75a
test(auth_v2): repoint to frozen sub-package layout and pin H1 group …
yassin-berriai Jun 11, 2026
fc6d51c
fix(auth_v2): gate OIDC login roles through the provider allowlist
yassin-berriai Jun 11, 2026
99efd31
fix(auth_v2): enforce the role allowlist on the SAML SSO path too
yassin-berriai Jun 11, 2026
200f674
test(auth_v2): pin the token-claim hardening and provisioning securit…
yassin-berriai Jun 11, 2026
44ac504
ci(auth_v2): run the proxy auth_v2 suite with xmlsec1 in the proxy-au…
yassin-berriai Jun 11, 2026
ac971d7
test(auth_v2): pin the role allowlist on the OIDC-login and SAML-SSO …
yassin-berriai Jun 11, 2026
f158a6c
ci(auth_v2): run auth_v2 tests in a dedicated shard to avoid test_mod…
yassin-berriai Jun 11, 2026
c9e7fd8
fix(auth_v2): prefer verified mTLS cert and confirm forwarded-DN gate…
yassin-berriai Jun 11, 2026
a7f9d5d
test(auth_v2): pin the mTLS forwarded-DN gate against XFF spoofing
yassin-berriai Jun 11, 2026
e989016
fix(auth_v2): match request paths with keyMatch so obj patterns span …
yassin-berriai Jun 11, 2026
2fad79b
test(auth_v2): pin multi-segment path authorization (keyMatch)
yassin-berriai Jun 11, 2026
343909d
test(auth_v2): cover deep SCIM path under keyMatch and drop stale com…
yassin-berriai Jun 11, 2026
c71291f
refactor: authv2
yassin-berriai Jun 11, 2026
4ebb7bb
fix: refactor
yassin-berriai Jun 11, 2026
8caa4da
fix: token exchange support
yassin-berriai Jun 12, 2026
c883abf
feat(auth_v2): add Casbin ABAC authorizer
yassin-berriai Jun 12, 2026
fad066c
refactor(auth_v2): move has_required_scopes onto Principal
yassin-berriai Jun 12, 2026
12d08df
docs(auth_v2): add README explaining the request auth flow
yassin-berriai Jun 12, 2026
97186ba
fix: cleanup
yassin-berriai Jun 13, 2026
2e999ee
refactor(auth_v2): flatten resolvers package into resolvers.py + util…
yassin-berriai Jun 13, 2026
6e51b3f
style(auth_v2): apply black formatting to satisfy lint
claude Jun 13, 2026
ffbcb8b
fix(auth_v2): resolve mypy arg-type errors in security and jwt creden…
claude Jun 13, 2026
03f1c11
feat(auth_v2): add project and end_user identity to Principal
yassin-berriai Jun 17, 2026
770ae59
refactor(auth_v2): rename resolver classes, drop dead protocol boiler…
yassin-berriai Jun 19, 2026
7eb9cbf
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
yassin-berriai Jul 7, 2026
b9859e0
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
yassin-berriai Jul 8, 2026
c0f6c9c
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
yassin-berriai Jul 15, 2026
64b59f4
Merge branch 'litellm_internal_staging' into litellm_fix/auth-module
yassin-berriai Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/_test-unit-base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
description: "Unique name for the coverage artifact (must be unique per run)"
required: true
type: string
apt-packages:
description: "Optional space-separated apt packages installed before dependencies (e.g. xmlsec1 libxmlsec1-dev for the SAML signing tests)"
required: false
type: string
default: ""

permissions:
contents: read
Expand Down Expand Up @@ -77,6 +82,12 @@
restore-keys: |
${{ runner.os }}-uv-

- name: Install system packages
if: ${{ inputs.apt-packages != '' }}
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${{ inputs.apt-packages }}

Check failure on line 89 in .github/workflows/_test-unit-base.yml

View workflow job for this annotation

GitHub Actions / zizmor

template-injection

_test-unit-base.yml:89: code injection via template expansion: may expand into attacker-controllable code

- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/test-unit-proxy-auth-v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: "Unit Tests: Proxy auth_v2"

on:
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_branch
- "litellm_**"

permissions:
contents: read
id-token: write
pull-requests: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
proxy-auth-v2:
uses: ./.github/workflows/_test-unit-base.yml
with:
test-path: "tests/test_litellm/proxy/auth_v2"
apt-packages: "xmlsec1 libxmlsec1-dev"
workers: 2
reruns: 2
artifact-name: proxy-auth-v2
5 changes: 5 additions & 0 deletions backend/auth/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .oidc import router as oidc_router
from .saml import router as saml_router
from .scim import router as scim_router

__all__ = ["oidc_router", "saml_router", "scim_router"]
49 changes: 49 additions & 0 deletions backend/auth/routers/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from typing import Tuple, cast

from authlib.integrations.starlette_client import OAuth
from fastapi import Request
from fastapi.security import SecurityScopes
from saml2.client import Saml2Client

from litellm.proxy.auth_v2.models import Principal
from litellm.proxy.auth_v2.resolvers import ProvisioningStore
from litellm.proxy.auth_v2.security import AuthSecurity

from ..services.oidc import build_oauth_registry
from ..services.saml import SAMLProtocolStore, build_sp_client


def get_auth(request: Request) -> AuthSecurity:
return request.app.state.auth_v2


def get_oauth_registry(request: Request) -> OAuth:
cached = getattr(request.app.state, "oidc_oauth", None)
if cached is None:
cached = build_oauth_registry(get_auth(request).config.oidc_providers)
request.app.state.oidc_oauth = cached
return cached


def get_saml_runtime(request: Request) -> Tuple[Saml2Client, SAMLProtocolStore]:
state = request.app.state
client = getattr(state, "saml_client", None)
if client is None:
auth = get_auth(request)
config = auth.config.saml
assert config is not None
client = build_sp_client(config)
state.saml_client = client
state.saml_protocol = SAMLProtocolStore(auth.config.session.ttl_seconds)
return client, state.saml_protocol


async def scim_principal(request: Request) -> Principal:
auth = get_auth(request)
return await auth.principal(SecurityScopes(scopes=["scim:write"]), request)


def scim_store(request: Request) -> ProvisioningStore:
return cast(ProvisioningStore, get_auth(request).resolver)
125 changes: 125 additions & 0 deletions backend/auth/routers/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from __future__ import annotations

import secrets
from typing import cast

from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse

from litellm.proxy.auth_v2 import errors
from litellm.proxy.auth_v2.authenticators import apply_role_policy
from litellm.proxy.auth_v2.models import AuthMethod
from ..services.redirects import safe_relay_state
from litellm.proxy.auth_v2.resolvers import ProvisioningStore
from litellm.proxy.auth_v2.security import AuthSecurity
from litellm.proxy.auth_v2.sessions.types import OAuthTransaction, SessionState

from ..services.oidc import mapped_claims, providers_by_key, user_from_userinfo
from .dependencies import get_auth, get_oauth_registry

router = APIRouter(prefix="/auth/oidc", tags=["oidc"])


@router.get("/{provider}/login")
async def login(
provider: str,
request: Request,
auth: AuthSecurity = Depends(get_auth),
oauth: OAuth = Depends(get_oauth_registry),
) -> RedirectResponse:
session = auth.config.session
client = oauth.create_client(provider)
if client is None:
raise errors.unknown_provider()
redirect_uri = str(request.url_for("oidc_callback", provider=provider))
relay = safe_relay_state(request.query_params.get("next"), session.default_redirect_path)
authorization = await client.create_authorization_url(redirect_uri)
txn_id = secrets.token_urlsafe(32)
await auth.oauth_txn_store.set(
txn_id,
OAuthTransaction(
provider=provider,
state=authorization["state"],
redirect_uri=redirect_uri,
relay=relay,
nonce=authorization.get("nonce"),
code_verifier=authorization.get("code_verifier"),
),
)
response = RedirectResponse(authorization["url"], status_code=303)
response.set_cookie(
session.login_cookie,
txn_id,
httponly=True,
samesite="lax",
secure=session.secure,
max_age=session.login_state_ttl,
)
return response


@router.get("/{provider}/callback", name="oidc_callback")
async def callback(
provider: str,
request: Request,
auth: AuthSecurity = Depends(get_auth),
oauth: OAuth = Depends(get_oauth_registry),
) -> RedirectResponse:
session = auth.config.session
client = oauth.create_client(provider)
if client is None:
raise errors.unknown_provider()
txn_id = request.cookies.get(session.login_cookie)
txn = await auth.oauth_txn_store.pop(txn_id) if txn_id else None
if txn is None or txn["provider"] != provider:
raise errors.invalid_login_state()
returned_state = request.query_params.get("state")
if not returned_state or returned_state != txn["state"]:
raise errors.state_mismatch()
error = request.query_params.get("error")
if error:
raise errors.oidc_provider_error(error)
code = request.query_params.get("code")
if not code:
raise errors.missing_authorization_code()

token = await client.fetch_access_token(
redirect_uri=txn["redirect_uri"],
code=code,
code_verifier=txn["code_verifier"],
state=txn["state"],
)
if token.get("id_token"):
userinfo = await client.parse_id_token(token, nonce=txn["nonce"])
else:
userinfo = await client.userinfo(token=token)

info = dict(userinfo)
provider_config = providers_by_key(auth.config.oidc_providers)[provider]

store = cast(ProvisioningStore, auth.resolver)
await store.upsert_user(user_from_userinfo(info))

claims = mapped_claims(info)
apply_role_policy(claims, provider_config)
session_id = secrets.token_urlsafe(32)
await auth.session_store.set(
session_id,
SessionState(
method=AuthMethod.OIDC.value,
subject=info.get("sub", ""),
issuer=info.get("iss") or provider_config.issuer,
claims=claims,
),
)
target = safe_relay_state(txn["relay"], session.default_redirect_path)
response = RedirectResponse(target, status_code=303)
response.set_cookie(
session.cookie,
session_id,
httponly=True,
samesite="lax",
secure=session.secure,
)
return response
121 changes: 121 additions & 0 deletions backend/auth/routers/saml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations

import secrets
from typing import Tuple, cast

from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse, Response
from saml2 import BINDING_HTTP_POST
from saml2.client import Saml2Client
from saml2.metadata import entity_descriptor

from litellm.proxy.auth_v2 import errors
from litellm.proxy.auth_v2.models import AuthMethod
from litellm.proxy.auth_v2.authorization import filter_claim_roles
from ..services.redirects import safe_relay_state
from litellm.proxy.auth_v2.resolvers import ProvisioningStore
from litellm.proxy.auth_v2.security import AuthSecurity
from litellm.proxy.auth_v2.sessions.types import SessionState

from ..services.saml import (
SAMLProtocolStore,
claims_from_mapped,
map_attributes,
user_from_mapped,
)
from .dependencies import get_auth, get_saml_runtime

router = APIRouter(prefix="/auth/saml", tags=["saml"])


@router.get("/metadata")
async def metadata(
runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime),
) -> Response:
client, _ = runtime
return Response(
content=str(entity_descriptor(client.config)),
media_type="application/samlmetadata+xml",
)


@router.get("/login")
async def login(
request: Request,
auth: AuthSecurity = Depends(get_auth),
runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime),
) -> RedirectResponse:
session = auth.config.session
client, protocol = runtime
relay_state = safe_relay_state(request.query_params.get("next"), session.default_redirect_path)
request_id, info = client.prepare_for_authenticate(relay_state=relay_state)
protocol.remember_request(request_id, relay_state)
location = dict(info["headers"]).get("Location")
if not location:
raise errors.saml_redirect_failed()
return RedirectResponse(location, status_code=303)


@router.post("/acs")
async def assertion_consumer_service(
request: Request,
auth: AuthSecurity = Depends(get_auth),
runtime: Tuple[Saml2Client, SAMLProtocolStore] = Depends(get_saml_runtime),
) -> Response:
config = auth.config.saml
assert config is not None
session = auth.config.session
client, protocol = runtime

form = await request.form()
saml_response = form.get("SAMLResponse")
if not isinstance(saml_response, str):
raise errors.missing_saml_response()
try:
authn_response = client.parse_authn_request_response(
saml_response,
BINDING_HTTP_POST,
outstanding=protocol.outstanding_relays() or None,
)
except Exception as exc:
raise errors.invalid_saml_response() from exc
if authn_response is None:
raise errors.invalid_saml_response()

in_response_to = getattr(authn_response, "in_response_to", None)
bound_relay = protocol.consume_request(in_response_to) if in_response_to else None

assertion = getattr(authn_response, "assertion", None)
assertion_id = getattr(assertion, "id", None)
if assertion_id and not protocol.consume_assertion(assertion_id):
raise errors.saml_assertion_replay()

name_id = authn_response.get_subject().text
ava = authn_response.get_identity() or {}
mapped = map_attributes(ava, config.attribute_map)
mapped["roles"] = filter_claim_roles(mapped.get("roles"), config.allowed_roles, config.allow_platform_roles)
user = user_from_mapped(name_id, mapped)

store = cast(ProvisioningStore, auth.resolver)
await store.upsert_user(user)

session_id = secrets.token_urlsafe(32)
await auth.session_store.set(
session_id,
SessionState(
method=AuthMethod.SAML.value,
subject=name_id,
issuer=authn_response.issuer(),
claims=claims_from_mapped(mapped),
),
)
target = safe_relay_state(bound_relay, session.default_redirect_path)
response = RedirectResponse(target, status_code=303)
response.set_cookie(
session.cookie,
session_id,
httponly=True,
samesite="lax",
secure=session.secure,
)
return response
Loading
Loading