diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml
index 92230fc8892..7fd66e3325e 100644
--- a/.github/workflows/_test-unit-base.yml
+++ b/.github/workflows/_test-unit-base.yml
@@ -80,7 +80,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
- .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
+ .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml
- name: Generate Prisma client
if: steps.changes.outputs.decision != 'skip'
diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml
index 6684952b998..da4fe073a6a 100644
--- a/.github/workflows/mutation-test.yml
+++ b/.github/workflows/mutation-test.yml
@@ -55,7 +55,7 @@ jobs:
- name: Install dependencies
run: |
- .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
+ .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml
- name: Generate Prisma client
env:
diff --git a/Dockerfile b/Dockerfile
index 9977ebb82d7..a127cdabd59 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -64,6 +64,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3
# Copy full source tree
@@ -84,6 +85,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database
index 34c9c606991..9ee076ce825 100644
--- a/docker/Dockerfile.database
+++ b/docker/Dockerfile.database
@@ -62,6 +62,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3
# Copy full source tree
@@ -82,6 +83,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index 8e05f312ba0..946b4de6f5e 100644
--- a/docker/Dockerfile.non_root
+++ b/docker/Dockerfile.non_root
@@ -68,6 +68,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3
# Copy full source tree
@@ -94,6 +95,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3 \
--no-sources-package litellm-proxy-extras; \
else \
@@ -102,6 +104,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
+ --extra saml \
--python python3; \
fi
diff --git a/litellm/proxy/config_resolvers/sso.py b/litellm/proxy/config_resolvers/sso.py
index 3d83c06dd62..97c42106018 100644
--- a/litellm/proxy/config_resolvers/sso.py
+++ b/litellm/proxy/config_resolvers/sso.py
@@ -36,6 +36,10 @@ SSO_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"),
FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"),
FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"),
+ FieldDescriptor("saml_idp_metadata_url", "saml_idp_metadata_url", "SAML_IDP_METADATA_URL"),
+ FieldDescriptor("saml_idp_metadata_xml", "saml_idp_metadata_xml", "SAML_IDP_METADATA_XML"),
+ FieldDescriptor("saml_sp_entity_id", "saml_sp_entity_id", "SAML_SP_ENTITY_ID"),
+ FieldDescriptor("saml_allow_unsolicited", "saml_allow_unsolicited", "SAML_ALLOW_UNSOLICITED"),
FieldDescriptor("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"),
)
diff --git a/litellm/proxy/management_endpoints/sso/saml_sso.py b/litellm/proxy/management_endpoints/sso/saml_sso.py
new file mode 100644
index 00000000000..37b641ca123
--- /dev/null
+++ b/litellm/proxy/management_endpoints/sso/saml_sso.py
@@ -0,0 +1,493 @@
+"""
+SAML 2.0 SSO for the LiteLLM proxy admin UI.
+
+Supports both SP-initiated and IdP-initiated login via the HTTP-POST binding,
+using the OneLogin python3-saml toolkit for signature, audience and time
+validation. The IdP is configured from its metadata (``SAML_IDP_METADATA_URL``
+or inline ``SAML_IDP_METADATA_XML``); a successful login is mapped to a
+``CustomOpenID`` and handed to the shared post-login path used by every other
+SSO provider.
+
+python3-saml pulls in the native ``xmlsec``/``libxml2`` libraries, so it is an
+optional dependency. When it is not installed the SAML routes return a clear
+error instead of breaking proxy startup.
+"""
+
+# python3-saml ships no type stubs, so the type checker sees every onelogin call
+# as Unknown and the guarded optional import as possibly-unbound. Values crossing
+# that boundary are cast() to concrete types at each use site; these directives
+# silence only the unavoidable noise from the untyped dependency in this module.
+# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
+# pyright: reportUnknownArgumentType=false, reportUnknownParameterType=false
+# pyright: reportMissingTypeStubs=false, reportPossiblyUnboundVariable=false
+# pyright: reportConstantRedefinition=false
+
+import asyncio
+import hashlib
+import os
+import secrets
+import time
+from typing import cast
+from urllib.parse import parse_qsl
+
+from fastapi import HTTPException, Request, status
+from fastapi.responses import RedirectResponse
+from pydantic import ValidationError
+
+from litellm._logging import verbose_proxy_logger
+from litellm.caching.dual_cache import DualCache
+from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
+from litellm.proxy.utils import get_custom_url
+
+try:
+ from onelogin.saml2.auth import OneLogin_Saml2_Auth
+ from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
+ from onelogin.saml2.settings import OneLogin_Saml2_Settings
+ from onelogin.saml2.xml_utils import OneLogin_Saml2_XML
+
+ SAML_AVAILABLE = True
+except ImportError:
+ SAML_AVAILABLE = False
+
+SAML_LOGIN_ROUTE = "sso/saml/login"
+SAML_CALLBACK_ROUTE = "sso/saml/callback"
+SAML_METADATA_ROUTE = "sso/saml/metadata"
+
+_SAML_AUTHN_STATE_COOKIE = "litellm_saml_authn"
+_SAML_IDP_SETTINGS_CACHE_PREFIX = "saml_idp_settings"
+_SAML_AUTHN_REQUEST_CACHE_PREFIX = "saml_authn_request"
+_SAML_CONSUMED_ASSERTION_CACHE_PREFIX = "saml_consumed_assertion"
+_SAML_AUTHN_REQUEST_TTL_SECONDS = 600
+_SAML_IDP_METADATA_TTL_SECONDS = 3600
+_SAML_METADATA_FETCH_TIMEOUT_SECONDS = 10
+_SAML_MAX_POST_BYTES = 5 * 1024 * 1024
+# The replay guard tracks each assertion's NotOnOrAfter so it spans the full
+# validity window; the floor covers IdPs that issue hour-long assertions or omit
+# the timestamp, and the cap bounds cache growth.
+_SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS = 3600
+_SAML_REPLAY_GUARD_MAX_TTL_SECONDS = 86400
+
+_EMAIL_ATTRIBUTE_CANDIDATES = (
+ "urn:oid:0.9.2342.19200300.100.1.3",
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+ "email",
+ "emailAddress",
+ "mail",
+ "Email",
+)
+_FIRST_NAME_ATTRIBUTE_CANDIDATES = (
+ "urn:oid:2.5.4.42",
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
+ "givenName",
+ "first_name",
+ "firstName",
+)
+_LAST_NAME_ATTRIBUTE_CANDIDATES = (
+ "urn:oid:2.5.4.4",
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
+ "sn",
+ "surname",
+ "last_name",
+ "lastName",
+)
+_ROLE_ATTRIBUTE_CANDIDATES = ("role", "roles", "litellm_role")
+_TEAM_IDS_ATTRIBUTE_CANDIDATES = ("teams", "team_ids", "groups")
+
+
+def _saml_unavailable_error() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
+ detail=(
+ "SAML SSO requires the optional 'python3-saml' dependency, which is "
+ "not installed. Re-install litellm with the saml extra: "
+ "'pip install litellm[saml]'. The saml extra bundles the native "
+ "xmlsec/libxml2 libraries, so no system packages are required."
+ ),
+ )
+
+
+class SAMLAuthHandler:
+ """SP- and IdP-initiated SAML 2.0 login for the admin UI."""
+
+ @staticmethod
+ def _env(name: str, default: str | None = None) -> str | None:
+ return os.getenv(name, default)
+
+ @staticmethod
+ def is_saml_configured() -> bool:
+ return bool(SAMLAuthHandler._env("SAML_IDP_METADATA_URL") or SAMLAuthHandler._env("SAML_IDP_METADATA_XML"))
+
+ @staticmethod
+ def _bool_env(name: str, default: bool) -> bool:
+ raw = SAMLAuthHandler._env(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() in ("true", "1", "yes", "on")
+
+ @staticmethod
+ def _base_url(request: Request) -> str:
+ base = get_custom_url(request_base_url=str(request.base_url))
+ return base if base.endswith("/") else base + "/"
+
+ @staticmethod
+ def _is_https(request: Request) -> bool:
+ return SAMLAuthHandler._base_url(request).startswith("https")
+
+ @staticmethod
+ def _acs_url(request: Request) -> str:
+ return SAMLAuthHandler._base_url(request) + SAML_CALLBACK_ROUTE
+
+ @staticmethod
+ def _metadata_url(request: Request) -> str:
+ return SAMLAuthHandler._base_url(request) + SAML_METADATA_ROUTE
+
+ @staticmethod
+ def _sp_entity_id(request: Request) -> str:
+ return SAMLAuthHandler._env("SAML_SP_ENTITY_ID") or SAMLAuthHandler._metadata_url(request)
+
+ @staticmethod
+ async def _load_idp_settings(cache: DualCache) -> dict[str, object]:
+ metadata_url = SAMLAuthHandler._env("SAML_IDP_METADATA_URL")
+ metadata_xml = SAMLAuthHandler._env("SAML_IDP_METADATA_XML")
+ source = metadata_url or metadata_xml
+ if source is None:
+ raise HTTPException(
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
+ detail="SAML SSO is not configured. Set SAML_IDP_METADATA_URL or SAML_IDP_METADATA_XML.",
+ )
+
+ cache_key = f"{_SAML_IDP_SETTINGS_CACHE_PREFIX}:{hashlib.sha256(source.encode()).hexdigest()}"
+ cached = cache.get_cache(key=cache_key)
+ if isinstance(cached, dict):
+ return cast(dict[str, object], cached) # cast-ok: untyped python3-saml
+
+ if metadata_url is not None:
+ parsed = await asyncio.to_thread(
+ OneLogin_Saml2_IdPMetadataParser.parse_remote,
+ metadata_url,
+ validate_cert=SAMLAuthHandler._bool_env("SAML_IDP_METADATA_VALIDATE_CERT", True),
+ timeout=_SAML_METADATA_FETCH_TIMEOUT_SECONDS,
+ )
+ else:
+ parsed = OneLogin_Saml2_IdPMetadataParser.parse(cast(str, metadata_xml)) # cast-ok: untyped python3-saml
+
+ idp_settings = cast(dict[str, object], parsed) # cast-ok: untyped python3-saml
+ if not idp_settings.get("idp"):
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Could not parse an IdP entityID/SSO URL/certificate from the SAML metadata.",
+ )
+ cache.set_cache(key=cache_key, value=idp_settings, ttl=_SAML_IDP_METADATA_TTL_SECONDS)
+ return idp_settings
+
+ @staticmethod
+ def _build_settings(request: Request, idp_settings: dict[str, object]) -> dict[str, object]:
+ sp_settings: dict[str, object] = {
+ "strict": SAMLAuthHandler._bool_env("SAML_STRICT", True),
+ "debug": False,
+ "sp": {
+ "entityId": SAMLAuthHandler._sp_entity_id(request),
+ "assertionConsumerService": {
+ "url": SAMLAuthHandler._acs_url(request),
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
+ },
+ "NameIDFormat": SAMLAuthHandler._env(
+ "SAML_SP_NAME_ID_FORMAT",
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ ),
+ },
+ "security": {
+ "wantAssertionsSigned": SAMLAuthHandler._bool_env("SAML_WANT_ASSERTIONS_SIGNED", True),
+ "wantMessagesSigned": SAMLAuthHandler._bool_env("SAML_WANT_MESSAGES_SIGNED", False),
+ "authnRequestsSigned": SAMLAuthHandler._bool_env("SAML_AUTHN_REQUESTS_SIGNED", False),
+ "wantNameId": True,
+ "requestedAuthnContext": False,
+ "rejectUnsolicitedResponsesWithInResponseTo": False,
+ },
+ }
+ return OneLogin_Saml2_IdPMetadataParser.merge_settings(sp_settings, idp_settings)
+
+ @staticmethod
+ def _prepare_request_data(request: Request, post_data: dict[str, str] | None = None) -> dict[str, object]:
+ base = SAMLAuthHandler._base_url(request)
+ scheme, _, host_part = base.partition("://")
+ host = host_part.split("/", 1)[0]
+ return {
+ "https": "on" if scheme == "https" else "off",
+ "http_host": host,
+ "script_name": "/" + SAML_CALLBACK_ROUTE,
+ "get_data": dict(request.query_params),
+ "post_data": post_data or {},
+ }
+
+ @staticmethod
+ async def _build_auth(
+ request: Request,
+ cache: DualCache,
+ post_data: dict[str, str] | None = None,
+ ) -> "OneLogin_Saml2_Auth":
+ if not SAML_AVAILABLE:
+ raise _saml_unavailable_error()
+ idp_settings = await SAMLAuthHandler._load_idp_settings(cache)
+ settings = SAMLAuthHandler._build_settings(request, idp_settings)
+ request_data = SAMLAuthHandler._prepare_request_data(request, post_data)
+ try:
+ return OneLogin_Saml2_Auth(request_data, old_settings=settings)
+ except Exception as e: # noqa: BLE001 - toolkit exposes no common exception base; fail closed
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Invalid SAML configuration: {e}",
+ )
+
+ @staticmethod
+ async def build_login_redirect(
+ request: Request, cache: DualCache, relay_state: str | None = None
+ ) -> RedirectResponse:
+ auth = await SAMLAuthHandler._build_auth(request, cache)
+ redirect_url = cast(str, auth.login(return_to=relay_state)) # cast-ok: untyped python3-saml
+ response = RedirectResponse(url=redirect_url, status_code=303)
+ request_id = cast(str | None, auth.get_last_request_id()) # cast-ok: untyped python3-saml
+ if request_id is not None:
+ cache.set_cache(
+ key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}",
+ value="1",
+ ttl=_SAML_AUTHN_REQUEST_TTL_SECONDS,
+ )
+ secure = SAMLAuthHandler._is_https(request)
+ response.set_cookie(
+ key=_SAML_AUTHN_STATE_COOKIE,
+ value=request_id,
+ max_age=_SAML_AUTHN_REQUEST_TTL_SECONDS,
+ httponly=True,
+ secure=secure,
+ samesite="none" if secure else "lax",
+ )
+ return response
+
+ @staticmethod
+ async def build_sp_metadata(request: Request, cache: DualCache) -> str:
+ if not SAML_AVAILABLE:
+ raise _saml_unavailable_error()
+ idp_settings = await SAMLAuthHandler._load_idp_settings(cache)
+ settings = SAMLAuthHandler._build_settings(request, idp_settings)
+ saml_settings = OneLogin_Saml2_Settings(settings, sp_validation_only=True)
+ metadata = cast(str, saml_settings.get_sp_metadata()) # cast-ok: untyped python3-saml
+ errors = cast(list[str], saml_settings.validate_metadata(metadata)) # cast-ok: untyped python3-saml
+ if errors:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Invalid SP metadata: {', '.join(errors)}",
+ )
+ return metadata
+
+ @staticmethod
+ async def read_acs_post_data(request: Request) -> dict[str, str]:
+ """Read the ACS POST form under a hard size cap before any base64/XML decoding.
+
+ Bounds both Content-Length-declared and chunked requests so an unauthenticated
+ caller cannot force unbounded buffering while decoding the SAMLResponse."""
+ declared = request.headers.get("content-length")
+ if declared is not None and declared.isdigit() and int(declared) > _SAML_MAX_POST_BYTES:
+ raise HTTPException(
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
+ detail="SAML response exceeds the maximum allowed size.",
+ )
+
+ body = bytearray()
+ async for chunk in request.stream():
+ body += chunk
+ if len(body) > _SAML_MAX_POST_BYTES:
+ raise HTTPException(
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
+ detail="SAML response exceeds the maximum allowed size.",
+ )
+
+ return dict(parse_qsl(body.decode("utf-8", "replace")))
+
+ @staticmethod
+ async def handle_acs(request: Request, cache: DualCache, post_data: dict[str, str]) -> CustomOpenID:
+ auth = await SAMLAuthHandler._build_auth(request, cache, post_data=post_data)
+ browser_request_id = request.cookies.get(_SAML_AUTHN_STATE_COOKIE)
+ try:
+ auth.process_response(request_id=browser_request_id)
+ except Exception as e: # noqa: BLE001 - toolkit exposes no common exception base; fail closed
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"Could not process SAML response: {e}",
+ )
+
+ errors = cast(list[str], auth.get_errors()) # cast-ok: untyped python3-saml
+ if errors or not auth.is_authenticated():
+ reason = auth.get_last_error_reason()
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"SAML authentication failed: {reason or ', '.join(errors)}",
+ )
+
+ await SAMLAuthHandler._enforce_response_binding(auth, cache, browser_request_id)
+ return SAMLAuthHandler._result_from_auth(auth)
+
+ @staticmethod
+ def _replay_guard_ttl(auth: "OneLogin_Saml2_Auth") -> int:
+ not_on_or_after = auth.get_last_assertion_not_on_or_after()
+ if not isinstance(not_on_or_after, int):
+ return _SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS
+ remaining = not_on_or_after - int(time.time())
+ return min(
+ max(remaining, _SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS),
+ _SAML_REPLAY_GUARD_MAX_TTL_SECONDS,
+ )
+
+ @staticmethod
+ def _response_in_response_to(auth: "OneLogin_Saml2_Auth") -> str | None:
+ """The request id this response answers, read from the Response element or, when the
+ IdP only stamps it on the bearer SubjectConfirmationData, from there. A non-None value
+ marks the response as solicited (SP-initiated) and so requiring browser binding."""
+ value = cast(str | None, auth.get_last_response_in_response_to()) # cast-ok: untyped python3-saml
+ if value:
+ return value
+ xml = cast(bytes | None, auth.get_last_response_xml()) # cast-ok: untyped python3-saml
+ if not xml:
+ return None
+ root = OneLogin_Saml2_XML.to_etree(xml)
+ for node in OneLogin_Saml2_XML.query(root, "//saml:SubjectConfirmationData[@InResponseTo]"):
+ irt = cast(str | None, node.get("InResponseTo")) # cast-ok: untyped python3-saml
+ if irt:
+ return irt
+ return None
+
+ @staticmethod
+ async def _enforce_response_binding(
+ auth: "OneLogin_Saml2_Auth",
+ cache: DualCache,
+ browser_request_id: str | None,
+ ) -> None:
+ in_response_to = SAMLAuthHandler._response_in_response_to(auth)
+
+ if in_response_to is not None:
+ authn_key = f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{in_response_to}"
+ if cache.get_cache(key=authn_key) is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML response references an unknown or already-used login request.",
+ )
+ if browser_request_id is None or not secrets.compare_digest(browser_request_id, in_response_to):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML response is not bound to this browser's login request.",
+ )
+ elif browser_request_id is not None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML response is not bound to this browser's login request.",
+ )
+ elif not SAMLAuthHandler._bool_env("SAML_ALLOW_UNSOLICITED", False):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Unsolicited (IdP-initiated) SAML responses are disabled.",
+ )
+ elif cache.redis_cache is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=(
+ "Unsolicited (IdP-initiated) SAML responses require a shared Redis cache "
+ "so the replay guard is enforced across every worker."
+ ),
+ )
+
+ assertion_id = cast(str | None, auth.get_last_assertion_id()) # cast-ok: untyped python3-saml
+ if assertion_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML assertion is missing the required ID attribute.",
+ )
+ consumed_key = f"{_SAML_CONSUMED_ASSERTION_CACHE_PREFIX}:{assertion_id}"
+ consumed_count = await cache.async_increment_cache(
+ key=consumed_key, value=1, ttl=SAMLAuthHandler._replay_guard_ttl(auth)
+ )
+ if consumed_count is not None and consumed_count > 1:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML assertion has already been used (replay detected).",
+ )
+
+ @staticmethod
+ def _result_from_auth(auth: "OneLogin_Saml2_Auth") -> CustomOpenID:
+ attributes = cast(dict[str, list[str]], auth.get_attributes()) # cast-ok: untyped python3-saml
+ name_id = cast(str | None, auth.get_nameid()) # cast-ok: untyped python3-saml
+
+ email = SAMLAuthHandler._attribute_value(attributes, "SAML_ATTRIBUTE_EMAIL", _EMAIL_ATTRIBUTE_CANDIDATES)
+ if email is None and name_id is not None and "@" in name_id:
+ email = name_id
+
+ if email is None and SAMLAuthHandler._env("ALLOWED_EMAIL_DOMAINS") is not None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=(
+ "SAML assertion did not contain an email address, but ALLOWED_EMAIL_DOMAINS "
+ "restricts sign-in by email domain."
+ ),
+ )
+
+ user_id = SAMLAuthHandler._attribute_value(attributes, "SAML_ATTRIBUTE_USER_ID", ()) or name_id or email
+ if user_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="SAML assertion did not contain a usable subject (NameID) or email.",
+ )
+
+ first_name = SAMLAuthHandler._attribute_value(
+ attributes, "SAML_ATTRIBUTE_FIRST_NAME", _FIRST_NAME_ATTRIBUTE_CANDIDATES
+ )
+ last_name = SAMLAuthHandler._attribute_value(
+ attributes, "SAML_ATTRIBUTE_LAST_NAME", _LAST_NAME_ATTRIBUTE_CANDIDATES
+ )
+ role_value = SAMLAuthHandler._attribute_value(attributes, "SAML_ATTRIBUTE_ROLE", _ROLE_ATTRIBUTE_CANDIDATES)
+ team_ids = SAMLAuthHandler._attribute_values(
+ attributes, "SAML_ATTRIBUTE_TEAM_IDS", _TEAM_IDS_ATTRIBUTE_CANDIDATES
+ )
+
+ display_name = " ".join(part for part in (first_name, last_name) if part) or email
+
+ verbose_proxy_logger.info(f"SAML login: subject={user_id}, email={email}, attributes={list(attributes.keys())}")
+
+ try:
+ return CustomOpenID(
+ id=user_id,
+ email=email,
+ first_name=first_name,
+ last_name=last_name,
+ display_name=display_name,
+ picture=None,
+ provider="saml",
+ team_ids=team_ids,
+ user_role=get_litellm_user_role(role_value) if role_value else None,
+ )
+ except ValidationError as e:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"SAML assertion contained an invalid subject or email: {e}",
+ )
+
+ @staticmethod
+ def _attribute_value(
+ attributes: dict[str, list[str]],
+ env_override: str,
+ candidates: tuple[str, ...],
+ ) -> str | None:
+ values = SAMLAuthHandler._attribute_values(attributes, env_override, candidates)
+ return values[0] if values else None
+
+ @staticmethod
+ def _attribute_values(
+ attributes: dict[str, list[str]],
+ env_override: str,
+ candidates: tuple[str, ...],
+ ) -> list[str]:
+ override = SAMLAuthHandler._env(env_override)
+ keys = (override, *candidates) if override else candidates
+ for key in keys:
+ values = attributes.get(key)
+ if values:
+ return [v for v in values if v]
+ return []
diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py
index 31b98bf20e4..8682b61f910 100644
--- a/litellm/proxy/management_endpoints/ui_sso.py
+++ b/litellm/proxy/management_endpoints/ui_sso.py
@@ -100,6 +100,7 @@ from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO
+from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler
from litellm.proxy.management_endpoints.sso_helper_utils import (
check_is_admin_only_access,
has_admin_ui_access,
@@ -857,6 +858,27 @@ def process_sso_jwt_access_token(
return None
+async def _raise_if_sso_exceeds_free_user_limit(premium_user: bool, prisma_client: PrismaClient | None) -> None:
+ """Free tier allows SSO for up to 5 billable users; beyond that requires an Enterprise license."""
+ if premium_user is True:
+ return
+ if prisma_client is None:
+ raise ProxyException(
+ message=CommonProxyErrors.db_not_connected_error.value,
+ type=ProxyErrorTypes.auth_error,
+ param="premium_user",
+ code=status.HTTP_403_FORBIDDEN,
+ )
+ billable_users = await UserRepository(prisma_client).count_billable_users()
+ if billable_users and billable_users > 5:
+ raise ProxyException(
+ message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You configured SSO (one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, `GENERIC_CLIENT_ID`, or SAML) in your env. Please unset it",
+ type=ProxyErrorTypes.auth_error,
+ param="premium_user",
+ code=status.HTTP_403_FORBIDDEN,
+ )
+
+
@router.get("/sso/key/generate", tags=["experimental"], include_in_schema=False)
async def google_login(
request: Request,
@@ -876,6 +898,7 @@ async def google_login(
general_settings,
premium_user,
prisma_client,
+ user_api_key_cache,
user_custom_ui_sso_sign_in_handler,
)
@@ -891,25 +914,13 @@ async def google_login(
return admin_ui_disabled()
####### Check if user is a Enterprise / Premium User #######
- if microsoft_client_id is not None or google_client_id is not None or generic_client_id is not None:
- if premium_user is not True:
- # Check if under 'free SSO user' limit
- if prisma_client is not None:
- billable_users = await UserRepository(prisma_client).count_billable_users()
- if billable_users and billable_users > 5:
- raise ProxyException(
- message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this",
- type=ProxyErrorTypes.auth_error,
- param="premium_user",
- code=status.HTTP_403_FORBIDDEN,
- )
- else:
- raise ProxyException(
- message=CommonProxyErrors.db_not_connected_error.value,
- type=ProxyErrorTypes.auth_error,
- param="premium_user",
- code=status.HTTP_403_FORBIDDEN,
- )
+ if (
+ microsoft_client_id is not None
+ or google_client_id is not None
+ or generic_client_id is not None
+ or SAMLAuthHandler.is_saml_configured()
+ ):
+ await _raise_if_sso_exceeds_free_user_limit(premium_user, prisma_client)
####### Detect DB + MASTER KEY in .env #######
missing_env_vars = show_missing_vars_in_env()
@@ -947,6 +958,19 @@ async def google_login(
"Enterprise features are not available. Custom UI SSO sign-in requires LiteLLM Enterprise."
)
+ if (
+ microsoft_client_id is None
+ and google_client_id is None
+ and generic_client_id is None
+ and SAMLAuthHandler.is_saml_configured()
+ ):
+ verbose_proxy_logger.info("Redirecting to SAML SSO login")
+ return await SAMLAuthHandler.build_login_redirect(
+ request=request,
+ cache=user_api_key_cache,
+ relay_state=return_to,
+ )
+
# Check if we should use SSO handler
if (
SSOAuthenticationHandler.should_use_sso_handler(
@@ -1913,6 +1937,81 @@ async def auth_callback(request: Request, state: Optional[str] = None):
)
+@router.get("/sso/saml/login", tags=["experimental"], include_in_schema=False)
+async def saml_login(request: Request, return_to: str | None = None):
+ """SP-initiated SAML login. Redirects the user to the configured IdP."""
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ _disable_ui_flag = os.getenv("DISABLE_ADMIN_UI")
+ if _disable_ui_flag is not None and str_to_bool(value=_disable_ui_flag):
+ return admin_ui_disabled()
+
+ return await SAMLAuthHandler.build_login_redirect(request=request, cache=user_api_key_cache, relay_state=return_to)
+
+
+@router.get("/sso/saml/metadata", tags=["experimental"], include_in_schema=False)
+async def saml_metadata(request: Request):
+ """Service Provider metadata XML, for registering this proxy at the IdP."""
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ metadata = await SAMLAuthHandler.build_sp_metadata(request=request, cache=user_api_key_cache)
+ return Response(content=metadata, media_type="application/xml")
+
+
+@router.post("/sso/saml/callback", tags=["experimental"], include_in_schema=False)
+async def saml_callback(request: Request):
+ """Assertion Consumer Service. Validates the IdP assertion and issues a UI session."""
+ from litellm.proxy.proxy_server import (
+ general_settings,
+ jwt_handler,
+ master_key,
+ premium_user,
+ prisma_client,
+ user_api_key_cache,
+ )
+
+ _disable_ui_flag = os.getenv("DISABLE_ADMIN_UI")
+ if _disable_ui_flag is not None and str_to_bool(value=_disable_ui_flag):
+ return admin_ui_disabled()
+
+ if prisma_client is None:
+ raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
+ if master_key is None:
+ raise ProxyException(
+ message="Master Key not set for Proxy. Set `LITELLM_MASTER_KEY` in .env or general_settings:master_key in config.yaml.",
+ type=ProxyErrorTypes.auth_error,
+ param="master_key",
+ code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ post_data = await SAMLAuthHandler.read_acs_post_data(request)
+ if "SAMLResponse" not in post_data:
+ raise HTTPException(status_code=400, detail="Missing SAMLResponse in callback request.")
+
+ result = await SAMLAuthHandler.handle_acs(request=request, cache=user_api_key_cache, post_data=post_data)
+
+ await _raise_if_sso_exceeds_free_user_limit(premium_user, prisma_client)
+
+ ui_access_mode = general_settings.get("ui_access_mode", None)
+ relay_state = post_data.get("RelayState")
+ cp_return_to: str | None = (
+ relay_state
+ if isinstance(relay_state, str) and SSOAuthenticationHandler._validate_return_to(relay_state)
+ else None
+ )
+
+ return await SSOAuthenticationHandler.get_redirect_response_from_openid(
+ result=result,
+ request=request,
+ received_response=None,
+ generic_client_id=None,
+ ui_access_mode=ui_access_mode,
+ access_token_payload=None,
+ jwt_handler=jwt_handler,
+ return_to=cp_return_to,
+ )
+
+
async def _build_cli_sso_user_defined_values(
result: Union[OpenID, dict],
parsed_openid_result: ParsedOpenIDResult,
diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py
index 742e0f7818f..d4b1d98f957 100644
--- a/litellm/types/proxy/management_endpoints/ui_sso.py
+++ b/litellm/types/proxy/management_endpoints/ui_sso.py
@@ -153,6 +153,24 @@ class SSOConfig(LiteLLMPydanticObjectBase):
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
)
+ # SAML SSO
+ saml_idp_metadata_url: Optional[str] = Field(
+ default=None,
+ description="URL of the SAML IdP metadata to fetch and parse for SSO authentication",
+ )
+ saml_idp_metadata_xml: Optional[str] = Field(
+ default=None,
+ description="Inline SAML IdP metadata XML, used when a metadata URL is not available",
+ )
+ saml_sp_entity_id: Optional[str] = Field(
+ default=None,
+ description="SAML Service Provider entityID; defaults to the proxy's /sso/saml/metadata URL",
+ )
+ saml_allow_unsolicited: Optional[str] = Field(
+ default=None,
+ description="'true' to accept IdP-initiated (unsolicited) SAML responses, which cannot be browser-bound against login CSRF",
+ )
+
# Common settings
proxy_base_url: Optional[str] = Field(
default=None,
diff --git a/pyproject.toml b/pyproject.toml
index a448ab042b8..44c1967ad9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,6 +99,10 @@ utils = [
"numpydoc>=1.8.0,<2.0",
]
caching = ["diskcache>=5.6.3,<6.0"]
+# SAML SSO for the admin UI. python3-saml pulls in xmlsec/lxml, whose wheels
+# bundle the native libxmlsec1/libxml2 libraries, so no system packages are
+# required. Kept out of the base `proxy` extra so it stays optional.
+saml = ["python3-saml>=1.16.0,<2.0"]
semantic-router = [
"semantic-router>=0.1.15,<1.0; python_version < '3.14'",
"aurelio-sdk>=0.0.19,<1.0; python_version < '3.14'",
diff --git a/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py b/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py
index 20bea98351f..9f91d9ee2c9 100644
--- a/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py
+++ b/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py
@@ -63,6 +63,27 @@ def test_sso_descriptor_mapping_is_single_sourced():
)
+def test_sso_descriptor_mapping_covers_saml_fields():
+ # SAML config is stored and read through the same descriptor table as the
+ # OAuth providers; the login path reads these env vars, so the save path must
+ # map every SAML field to its uppercase env var.
+ assert SSO_FIELD_ENV_VARS["saml_idp_metadata_url"] == "SAML_IDP_METADATA_URL"
+ assert SSO_FIELD_ENV_VARS["saml_idp_metadata_xml"] == "SAML_IDP_METADATA_XML"
+ assert SSO_FIELD_ENV_VARS["saml_sp_entity_id"] == "SAML_SP_ENTITY_ID"
+ assert SSO_FIELD_ENV_VARS["saml_allow_unsolicited"] == "SAML_ALLOW_UNSOLICITED"
+
+
+def test_resolve_sso_config_resolves_saml_fields():
+ resolved = resolve_sso_config(
+ {"saml_idp_metadata_url": "https://idp.example.com/metadata"},
+ {"SAML_ALLOW_UNSOLICITED": "true"},
+ )
+ assert resolved.config.saml_idp_metadata_url == "https://idp.example.com/metadata"
+ assert resolved.provenance["saml_idp_metadata_url"] == "db"
+ assert resolved.config.saml_allow_unsolicited == "true"
+ assert resolved.provenance["saml_allow_unsolicited"] == "env"
+
+
def test_resolve_sso_config_returns_unmasked_secret_and_provenance():
# The resolver hands back plaintext; masking is the endpoint's job. If the
# resolver masked, the login path would consume a masked secret and fail.
diff --git a/tests/test_litellm/proxy/management_endpoints/test_saml_sso.py b/tests/test_litellm/proxy/management_endpoints/test_saml_sso.py
new file mode 100644
index 00000000000..57decc7d458
--- /dev/null
+++ b/tests/test_litellm/proxy/management_endpoints/test_saml_sso.py
@@ -0,0 +1,644 @@
+"""
+Regression tests for SAML 2.0 SSO (SP- and IdP-initiated) on the admin UI.
+
+These exercise the real OneLogin python3-saml validation by generating signed
+SAML responses with a freshly minted IdP keypair, so a mutation that weakens
+signature, signing-requirement, expiry, replay or attribute-mapping handling
+makes a test fail.
+"""
+
+import base64
+import datetime
+import time
+
+import pytest
+from fastapi import HTTPException, Request
+
+pytest.importorskip(
+ "onelogin", reason="python3-saml (saml extra) is required for SAML SSO tests"
+)
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+from onelogin.saml2.utils import OneLogin_Saml2_Utils
+from starlette.datastructures import URL
+
+from typing import cast
+
+from litellm.caching.dual_cache import DualCache
+from litellm.caching.in_memory_cache import InMemoryCache
+from litellm.caching.redis_cache import RedisCache
+from litellm.proxy._types import LitellmUserRoles
+from litellm.proxy.management_endpoints.sso.saml_sso import (
+ _SAML_AUTHN_REQUEST_CACHE_PREFIX,
+ _SAML_AUTHN_STATE_COOKIE,
+ _SAML_MAX_POST_BYTES,
+ _SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS,
+ _SAML_REPLAY_GUARD_MAX_TTL_SECONDS,
+ SAMLAuthHandler,
+)
+
+
+def _shared_cache(store=None):
+ """A DualCache whose replay guard is backed by a shared, atomic store.
+
+ An InMemoryCache instance stands in for Redis; passing the same instance to
+ two DualCaches simulates two workers sharing one atomic backend."""
+ return DualCache(redis_cache=cast(RedisCache, store or InMemoryCache()))
+
+IDP_ENTITY = "https://idp.example.com/metadata"
+SP_ENTITY = "https://proxy.example.com/sso/saml/metadata"
+ACS = "https://proxy.example.com/sso/saml/callback"
+SSO_URL = "https://idp.example.com/sso"
+PROXY_BASE_URL = "https://proxy.example.com"
+
+
+def _make_idp_keypair():
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "idp.example.com")])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1))
+ .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
+ .sign(key, hashes.SHA256())
+ )
+ key_pem = key.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.TraditionalOpenSSL,
+ serialization.NoEncryption(),
+ ).decode()
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
+ return key_pem, cert_pem
+
+
+def _idp_metadata_xml(cert_pem):
+ cert_body = "".join(
+ line for line in cert_pem.splitlines() if "CERTIFICATE" not in line
+ )
+ return (
+ ''
+ f''
+ ''
+ ''
+ f"{cert_body}"
+ ""
+ ''
+ ""
+ )
+
+
+def _saml_time(delta_seconds):
+ t = datetime.datetime.utcnow() + datetime.timedelta(seconds=delta_seconds)
+ return t.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _build_signed_response(
+ key_pem,
+ cert_pem,
+ *,
+ in_response_to=None,
+ response_level_in_response_to=True,
+ email="alice@example.com",
+ attributes=None,
+ not_before_delta=-60,
+ not_on_or_after_delta=300,
+ sign=True,
+):
+ if attributes is None:
+ attributes = {
+ "email": [email],
+ "givenName": ["Alice"],
+ "sn": ["Smith"],
+ "role": ["internal_user"],
+ }
+ assertion_id = "_assertion_" + OneLogin_Saml2_Utils.generate_unique_id()
+ response_id = "_response_" + OneLogin_Saml2_Utils.generate_unique_id()
+ not_before = _saml_time(not_before_delta)
+ not_on_or_after = _saml_time(not_on_or_after_delta)
+ issue_instant = _saml_time(-1)
+ irt = f'InResponseTo="{in_response_to}"' if in_response_to else ""
+ response_irt = irt if response_level_in_response_to else ""
+
+ attr_xml = "".join(
+ f''
+ + "".join(f"{v}" for v in values)
+ + ""
+ for name, values in attributes.items()
+ )
+
+ assertion = (
+ ''
+ f"{IDP_ENTITY}"
+ ""
+ ''
+ f"{email}"
+ ''
+ f''
+ ""
+ f''
+ f"{SP_ENTITY}"
+ ""
+ f''
+ ""
+ "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
+ ""
+ f"{attr_xml}"
+ ""
+ )
+
+ if sign:
+ signed = OneLogin_Saml2_Utils.add_sign(assertion, key_pem, cert_pem)
+ assertion = (signed.decode() if isinstance(signed, bytes) else signed).replace(
+ '', ""
+ )
+
+ return (
+ ''
+ ''
+ f"{IDP_ENTITY}"
+ ''
+ ""
+ f"{assertion}"
+ )
+
+
+def _b64(xml):
+ return base64.b64encode(xml.encode()).decode()
+
+
+def _fake_request(cookies=None):
+ return type(
+ "Req",
+ (),
+ {
+ "base_url": URL(PROXY_BASE_URL + "/"),
+ "query_params": {},
+ "cookies": cookies or {},
+ },
+ )()
+
+
+async def _acs(b64, cache, cookies=None):
+ return await SAMLAuthHandler.handle_acs(
+ _fake_request(cookies), cache, {"SAMLResponse": b64}
+ )
+
+
+@pytest.fixture
+def saml_env(monkeypatch):
+ key_pem, cert_pem = _make_idp_keypair()
+ monkeypatch.setenv("SAML_IDP_METADATA_XML", _idp_metadata_xml(cert_pem))
+ monkeypatch.setenv("SAML_SP_ENTITY_ID", SP_ENTITY)
+ monkeypatch.setenv("PROXY_BASE_URL", PROXY_BASE_URL)
+ for var in (
+ "SAML_IDP_METADATA_URL",
+ "SAML_ATTRIBUTE_EMAIL",
+ "SAML_ATTRIBUTE_TEAM_IDS",
+ "SAML_ALLOW_UNSOLICITED",
+ "ALLOWED_EMAIL_DOMAINS",
+ ):
+ monkeypatch.delenv(var, raising=False)
+ return key_pem, cert_pem
+
+
+@pytest.fixture
+def saml_env_idp_initiated(saml_env, monkeypatch):
+ monkeypatch.setenv("SAML_ALLOW_UNSOLICITED", "true")
+ return saml_env
+
+
+@pytest.mark.asyncio
+async def test_valid_idp_initiated_login_maps_assertion_to_user(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ resp = _build_signed_response(key_pem, cert_pem)
+
+ result = await _acs(_b64(resp), _shared_cache())
+
+ assert result.email == "alice@example.com"
+ assert result.id == "alice@example.com"
+ assert result.first_name == "Alice"
+ assert result.last_name == "Smith"
+ assert result.user_role == LitellmUserRoles.INTERNAL_USER
+ assert result.provider == "saml"
+
+
+@pytest.mark.asyncio
+async def test_tampered_assertion_is_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ resp = _build_signed_response(key_pem, cert_pem)
+ tampered = resp.replace("alice@example.com", "attacker@example.com")
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(tampered), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_unsigned_assertion_is_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ resp = _build_signed_response(key_pem, cert_pem, sign=False)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_signature_from_untrusted_key_is_rejected(saml_env):
+ _, cert_pem = saml_env
+ attacker_key, attacker_cert = _make_idp_keypair()
+ resp = _build_signed_response(attacker_key, attacker_cert)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_expired_assertion_is_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ resp = _build_signed_response(
+ key_pem, cert_pem, not_before_delta=-7200, not_on_or_after_delta=-3600
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_sp_initiated_unknown_in_response_to_is_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ resp = _build_signed_response(key_pem, cert_pem, in_response_to="_never_issued")
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_sp_initiated_known_request_succeeds_once_then_replay_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ cache = DualCache()
+ request_id = "_authn_req_known"
+ cache.set_cache(
+ key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
+ )
+ resp = _build_signed_response(key_pem, cert_pem, in_response_to=request_id)
+ cookies = {_SAML_AUTHN_STATE_COOKIE: request_id}
+
+ result = await _acs(_b64(resp), cache, cookies=cookies)
+ assert result.email == "alice@example.com"
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), cache, cookies=cookies)
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_sp_initiated_response_not_bound_to_browser_is_rejected(saml_env):
+ key_pem, cert_pem = saml_env
+ cache = DualCache()
+ request_id = "_authn_req_known"
+ cache.set_cache(
+ key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
+ )
+ resp = _build_signed_response(key_pem, cert_pem, in_response_to=request_id)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), cache)
+ assert exc.value.status_code == 401
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(
+ _b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: "_attacker_request"}
+ )
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_subjectconfirmation_only_in_response_to_without_cookie_is_rejected(
+ saml_env_idp_initiated,
+):
+ """An IdP that stamps InResponseTo only on the SubjectConfirmationData (not the
+ Response element) is still solicited and must be browser-bound: with unsolicited
+ explicitly allowed, a missing cookie must still 401 rather than slip through."""
+ key_pem, cert_pem = saml_env_idp_initiated
+ cache = DualCache()
+ request_id = "_authn_req_known"
+ cache.set_cache(
+ key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
+ )
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ in_response_to=request_id,
+ response_level_in_response_to=False,
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), cache)
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_subjectconfirmation_only_in_response_to_with_cookie_succeeds(saml_env):
+ key_pem, cert_pem = saml_env
+ cache = DualCache()
+ request_id = "_authn_req_known"
+ cache.set_cache(
+ key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
+ )
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ in_response_to=request_id,
+ response_level_in_response_to=False,
+ )
+
+ result = await _acs(
+ _b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: request_id}
+ )
+ assert result.email == "alice@example.com"
+
+
+@pytest.mark.asyncio
+async def test_unsolicited_response_rejected_by_default(saml_env):
+ key_pem, cert_pem = saml_env
+ resp = _build_signed_response(key_pem, cert_pem)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_idp_initiated_assertion_replay_is_rejected(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ cache = _shared_cache()
+ resp = _build_signed_response(key_pem, cert_pem, email="bob@example.com")
+
+ first = await _acs(_b64(resp), cache)
+ assert first.email == "bob@example.com"
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), cache)
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_assertion_without_id_is_rejected(saml_env_idp_initiated):
+ """An assertion with no ID attribute has no stable replay key. On the unsolicited
+ path there is no browser binding, so the consumed-assertion guard is the only replay
+ defense; a missing ID must be rejected rather than silently skipping the guard."""
+
+ class _AuthNoAssertionId:
+ def get_last_response_in_response_to(self):
+ return None
+
+ def get_last_response_xml(self):
+ return None
+
+ def get_last_assertion_id(self):
+ return None
+
+ with pytest.raises(HTTPException) as exc:
+ await SAMLAuthHandler._enforce_response_binding(
+ _AuthNoAssertionId(), _shared_cache(), None
+ )
+ assert exc.value.status_code == 401
+ assert "ID" in exc.value.detail
+
+
+@pytest.mark.asyncio
+async def test_unsolicited_response_rejected_when_disabled(saml_env, monkeypatch):
+ key_pem, cert_pem = saml_env
+ monkeypatch.setenv("SAML_ALLOW_UNSOLICITED", "false")
+ resp = _build_signed_response(key_pem, cert_pem)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_invalid_email_in_assertion_is_rejected_cleanly(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ email="not-an-email",
+ attributes={"email": ["not-an-email"], "givenName": ["X"]},
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), _shared_cache())
+ assert exc.value.status_code == 401
+ assert "invalid subject or email" in exc.value.detail
+
+
+@pytest.mark.asyncio
+async def test_email_less_assertion_rejected_when_domain_restriction_configured(
+ saml_env_idp_initiated, monkeypatch
+):
+ key_pem, cert_pem = saml_env_idp_initiated
+ monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "example.com")
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ email="opaque-persistent-id-123",
+ attributes={"givenName": ["Alice"]},
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), _shared_cache())
+ assert exc.value.status_code == 401
+ assert "ALLOWED_EMAIL_DOMAINS" in exc.value.detail
+
+
+@pytest.mark.asyncio
+async def test_email_less_assertion_allowed_without_domain_restriction(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ email="opaque-persistent-id-123",
+ attributes={"givenName": ["Alice"]},
+ )
+
+ result = await _acs(_b64(resp), _shared_cache())
+ assert result.email is None
+ assert result.id == "opaque-persistent-id-123"
+
+
+@pytest.mark.asyncio
+async def test_custom_email_attribute_override(saml_env_idp_initiated, monkeypatch):
+ key_pem, cert_pem = saml_env_idp_initiated
+ monkeypatch.setenv("SAML_ATTRIBUTE_EMAIL", "corpMail")
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ email="ignored@example.com",
+ attributes={
+ "corpMail": ["real@corp.example.com"],
+ "givenName": ["Real"],
+ },
+ )
+
+ result = await _acs(_b64(resp), _shared_cache())
+ assert result.email == "real@corp.example.com"
+
+
+@pytest.mark.asyncio
+async def test_team_ids_extracted_from_groups_attribute(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ resp = _build_signed_response(
+ key_pem,
+ cert_pem,
+ attributes={
+ "email": ["carol@example.com"],
+ "groups": ["team-a", "team-b"],
+ },
+ )
+
+ result = await _acs(_b64(resp), _shared_cache())
+ assert result.team_ids == ["team-a", "team-b"]
+
+
+@pytest.mark.asyncio
+async def test_build_login_redirect_targets_idp_and_caches_request_id(saml_env):
+ cache = DualCache()
+ redirect = await SAMLAuthHandler.build_login_redirect(_fake_request(), cache)
+
+ location = redirect.headers["location"]
+ assert location.startswith(SSO_URL)
+ assert "SAMLRequest=" in location
+ cached = [
+ k
+ for k in cache.in_memory_cache.cache_dict
+ if k.startswith(_SAML_AUTHN_REQUEST_CACHE_PREFIX)
+ ]
+ assert len(cached) == 1
+
+ request_id = cached[0].split(":", 1)[1]
+ set_cookie = redirect.headers["set-cookie"]
+ assert f"{_SAML_AUTHN_STATE_COOKIE}={request_id}" in set_cookie
+ assert "httponly" in set_cookie.lower()
+
+
+@pytest.mark.asyncio
+async def test_sp_metadata_contains_acs_and_entity_id(saml_env):
+ metadata = await SAMLAuthHandler.build_sp_metadata(_fake_request(), DualCache())
+ assert ACS in metadata
+ assert SP_ENTITY in metadata
+ assert "AssertionConsumerService" in metadata
+
+
+def test_replay_guard_ttl_tracks_assertion_validity():
+ class _Auth:
+ def __init__(self, not_on_or_after):
+ self._not_on_or_after = not_on_or_after
+
+ def get_last_assertion_not_on_or_after(self):
+ return self._not_on_or_after
+
+ now = int(time.time())
+
+ long_lived = SAMLAuthHandler._replay_guard_ttl(_Auth(now + 7200))
+ assert long_lived >= 7200
+
+ short_lived = SAMLAuthHandler._replay_guard_ttl(_Auth(now + 60))
+ assert short_lived == _SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS
+
+ missing = SAMLAuthHandler._replay_guard_ttl(_Auth(None))
+ assert missing == _SAML_REPLAY_GUARD_DEFAULT_TTL_SECONDS
+
+ capped = SAMLAuthHandler._replay_guard_ttl(_Auth(now + 10 * 86400))
+ assert capped == _SAML_REPLAY_GUARD_MAX_TTL_SECONDS
+
+
+def test_is_saml_configured_reflects_env(monkeypatch):
+ monkeypatch.delenv("SAML_IDP_METADATA_URL", raising=False)
+ monkeypatch.delenv("SAML_IDP_METADATA_XML", raising=False)
+ assert SAMLAuthHandler.is_saml_configured() is False
+
+ monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata.xml")
+ assert SAMLAuthHandler.is_saml_configured() is True
+
+
+@pytest.mark.asyncio
+async def test_idp_initiated_rejected_without_shared_cache(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ resp = _build_signed_response(key_pem, cert_pem)
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), DualCache())
+ assert exc.value.status_code == 401
+ assert "shared Redis cache" in exc.value.detail
+
+
+@pytest.mark.asyncio
+async def test_idp_initiated_replay_rejected_across_workers(saml_env_idp_initiated):
+ key_pem, cert_pem = saml_env_idp_initiated
+ shared_store = InMemoryCache()
+ worker_one = _shared_cache(shared_store)
+ worker_two = _shared_cache(shared_store)
+ resp = _build_signed_response(key_pem, cert_pem, email="bob@example.com")
+
+ first = await _acs(_b64(resp), worker_one)
+ assert first.email == "bob@example.com"
+
+ with pytest.raises(HTTPException) as exc:
+ await _acs(_b64(resp), worker_two)
+ assert exc.value.status_code == 401
+
+
+class _FakeChunkedRequest:
+ def __init__(self, chunks, content_length=None):
+ self._chunks = chunks
+ self.headers = {} if content_length is None else {"content-length": content_length}
+
+ async def stream(self):
+ for chunk in self._chunks:
+ yield chunk
+
+
+@pytest.mark.asyncio
+async def test_read_acs_post_data_parses_form():
+ body = b"SAMLResponse=abc123&RelayState=%2Fui%2F"
+ request = _FakeChunkedRequest([body], content_length=str(len(body)))
+
+ post_data = await SAMLAuthHandler.read_acs_post_data(cast(Request, request))
+
+ assert post_data == {"SAMLResponse": "abc123", "RelayState": "/ui/"}
+
+
+@pytest.mark.asyncio
+async def test_read_acs_post_data_rejects_oversized_content_length():
+ request = _FakeChunkedRequest([b""], content_length=str(_SAML_MAX_POST_BYTES + 1))
+
+ with pytest.raises(HTTPException) as exc:
+ await SAMLAuthHandler.read_acs_post_data(cast(Request, request))
+ assert exc.value.status_code == 413
+
+
+@pytest.mark.asyncio
+async def test_read_acs_post_data_rejects_oversized_stream_without_content_length():
+ chunk = b"a" * (1024 * 1024)
+ chunk_count = _SAML_MAX_POST_BYTES // len(chunk) + 2
+ request = _FakeChunkedRequest([chunk] * chunk_count)
+
+ with pytest.raises(HTTPException) as exc:
+ await SAMLAuthHandler.read_acs_post_data(cast(Request, request))
+ assert exc.value.status_code == 413
diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
index 63a47428780..795b7cd5a9e 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
@@ -7391,6 +7391,71 @@ async def test_legacy_login_page_hides_credentials_hint_via_general_settings():
assert "MASTER_KEY" not in body
+@pytest.mark.asyncio
+async def test_saml_callback_blocked_when_admin_ui_disabled():
+ """An IdP-initiated assertion must not mint a UI session when the admin UI is
+ disabled; the ACS enforces DISABLE_ADMIN_UI like the SP-initiated login route."""
+ from litellm.proxy.management_endpoints.ui_sso import saml_callback
+
+ with patch.dict(os.environ, {"DISABLE_ADMIN_UI": "true"}):
+ response = await saml_callback(SimpleNamespace(cookies={}))
+
+ assert response.status_code == 200
+ assert "Admin UI is Disabled" in response.body.decode()
+
+
+@pytest.mark.asyncio
+async def test_saml_callback_enforces_free_sso_user_limit_after_validation():
+ """An IdP-initiated assertion must not bypass the >5 free-SSO-user Enterprise gate
+ that /sso/key/generate enforces; the ACS re-checks it after validating the assertion,
+ so the entitlement DB query never runs on unvalidated input."""
+ from litellm.proxy._types import ProxyException
+ from litellm.proxy.management_endpoints.ui_sso import saml_callback
+ from litellm.proxy.management_endpoints.types import CustomOpenID
+
+ call_order: list[str] = []
+
+ async def _fake_handle_acs(**kwargs):
+ call_order.append("validate")
+ return CustomOpenID(
+ id="dana@litellm.ai",
+ email="dana@litellm.ai",
+ first_name=None,
+ last_name=None,
+ display_name="dana",
+ picture=None,
+ provider="saml",
+ team_ids=[],
+ user_role=None,
+ )
+
+ async def _fake_count_billable_users():
+ call_order.append("count")
+ return 6
+
+ async def _stream():
+ yield b"SAMLResponse=signed-response"
+
+ request_double = SimpleNamespace(cookies={}, headers={}, stream=_stream)
+
+ with patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}), patch(
+ "litellm.proxy.proxy_server.premium_user", False
+ ), patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch(
+ "litellm.proxy.proxy_server.master_key", "sk-1234"
+ ), patch(
+ "litellm.proxy.management_endpoints.sso.saml_sso.SAMLAuthHandler.handle_acs",
+ new=_fake_handle_acs,
+ ), patch(
+ "litellm.repositories.user_repository.UserRepository.count_billable_users",
+ new=AsyncMock(side_effect=_fake_count_billable_users),
+ ):
+ with pytest.raises(ProxyException) as exc:
+ await saml_callback(request_double)
+
+ assert str(exc.value.code) == "403"
+ assert call_order == ["validate", "count"]
+
+
@pytest.mark.asyncio
async def test_cli_poll_key_tolerates_missing_user_row():
"""The CLI poll must still mint the JWT when the user lookup raises,
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index 85dbf70b452..20451f5d0ac 100644
--- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -617,6 +617,75 @@ class TestProxySettingEndpoints:
create_sso_settings = json.loads(create_data["sso_settings"])
assert create_sso_settings["google_client_id"] == "new_google_client_id"
+ def test_update_sso_settings_maps_saml_fields_to_env_vars(
+ self, mock_proxy_config, mock_auth, monkeypatch
+ ):
+ """SAML settings entered in the admin UI must be applied as the SAML_* env
+ vars the SAML handler reads, and the allow-unsolicited toggle must map to
+ the 'true'/'false' string the handler expects."""
+ import json
+ import os
+ from unittest.mock import AsyncMock, MagicMock
+
+ monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
+ monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
+ mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
+ mock_prisma.db.litellm_config = MagicMock()
+ mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
+ mock_prisma.db.litellm_config.update = AsyncMock()
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
+
+ from litellm.proxy.proxy_server import proxy_config
+
+ monkeypatch.setattr(
+ proxy_config,
+ "_encrypt_env_variables",
+ lambda environment_variables: environment_variables,
+ )
+
+ for var in (
+ "SAML_IDP_METADATA_URL",
+ "SAML_IDP_METADATA_XML",
+ "SAML_SP_ENTITY_ID",
+ "SAML_ALLOW_UNSOLICITED",
+ ):
+ monkeypatch.delenv(var, raising=False)
+
+ new_sso_settings = {
+ "saml_idp_metadata_url": "https://idp.example.com/metadata",
+ "saml_sp_entity_id": "https://proxy.example.com/sso/saml/metadata",
+ "saml_allow_unsolicited": "true",
+ "proxy_base_url": "https://proxy.example.com",
+ "user_email": "admin@example.com",
+ }
+
+ try:
+ response = client.patch("/update/sso_settings", json=new_sso_settings)
+
+ assert response.status_code == 200
+
+ assert os.environ.get("SAML_IDP_METADATA_URL") == "https://idp.example.com/metadata"
+ assert os.environ.get("SAML_SP_ENTITY_ID") == "https://proxy.example.com/sso/saml/metadata"
+ assert os.environ.get("SAML_ALLOW_UNSOLICITED") == "true"
+ assert "SAML_IDP_METADATA_XML" not in os.environ
+
+ stored = json.loads(
+ mock_prisma.db.litellm_ssoconfig.upsert.call_args.kwargs["data"]["create"]["sso_settings"]
+ )
+ assert stored["saml_idp_metadata_url"] == "https://idp.example.com/metadata"
+ assert stored["saml_allow_unsolicited"] == "true"
+ finally:
+ for var in (
+ "SAML_IDP_METADATA_URL",
+ "SAML_IDP_METADATA_XML",
+ "SAML_SP_ENTITY_ID",
+ "SAML_ALLOW_UNSOLICITED",
+ ):
+ os.environ.pop(var, None)
+
def test_update_sso_settings_audits_when_env_cleanup_fails(
self, mock_proxy_config, mock_auth, monkeypatch
):
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts
index 1a02e363de9..83847261fe8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts
@@ -24,6 +24,10 @@ export interface SSOSettingsValues {
generic_authorization_endpoint: string | null;
generic_token_endpoint: string | null;
generic_userinfo_endpoint: string | null;
+ saml_idp_metadata_url: string | null;
+ saml_idp_metadata_xml: string | null;
+ saml_sp_entity_id: string | null;
+ saml_allow_unsolicited: string | null;
generic_scope: string | null;
proxy_base_url: string | null;
user_email: string | null;
diff --git a/ui/litellm-dashboard/src/components/SSOModals.test.tsx b/ui/litellm-dashboard/src/components/SSOModals.test.tsx
index c5da4ee7064..0fcfe60ffe8 100644
--- a/ui/litellm-dashboard/src/components/SSOModals.test.tsx
+++ b/ui/litellm-dashboard/src/components/SSOModals.test.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { Form } from "antd";
+import { Form, type FormInstance } from "antd";
import { describe, expect, it, vi } from "vitest";
import SSOModals from "./SSOModals";
@@ -412,6 +412,76 @@ describe("SSOModals", () => {
expect(mockHandleShowInstructions).toHaveBeenCalled();
});
+ it("should submit SAML settings with the unsolicited toggle mapped to a 'true'/'false' string", async () => {
+ const mockHandleShowInstructions = vi.fn();
+ vi.mocked(updateSSOSettings).mockResolvedValue({});
+ vi.mocked(getSSOSettings).mockResolvedValue({ values: {} });
+
+ let formInstance: FormInstance | null = null;
+
+ const TestWrapper = () => {
+ const [form] = Form.useForm();
+ formInstance = form;
+
+ return (
+ {}}
+ handleAddSSOCancel={() => {}}
+ handleShowInstructions={mockHandleShowInstructions}
+ handleInstructionsOk={() => {}}
+ handleInstructionsCancel={() => {}}
+ form={form}
+ accessToken="test-token"
+ ssoConfigured={false}
+ />
+ );
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(getSSOSettings).toHaveBeenCalledWith("test-token");
+ });
+
+ formInstance?.setFieldsValue({ sso_provider: "saml" });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("IdP Metadata URL")).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByLabelText("Proxy Admin Email"), {
+ target: { value: "admin@example.com" },
+ });
+ fireEvent.change(screen.getByLabelText("Proxy Base URL"), {
+ target: { value: "https://proxy.example.com" },
+ });
+ fireEvent.change(screen.getByLabelText("IdP Metadata URL"), {
+ target: { value: "https://idp.example.com/metadata" },
+ });
+ fireEvent.change(screen.getByLabelText("SP Entity ID"), {
+ target: { value: "https://proxy.example.com/sso/saml/metadata" },
+ });
+ fireEvent.click(screen.getByLabelText("Allow IdP-initiated (unsolicited) responses"));
+
+ fireEvent.click(screen.getByText("Save"));
+
+ await waitFor(() => {
+ expect(updateSSOSettings).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ sso_provider: "saml",
+ saml_idp_metadata_url: "https://idp.example.com/metadata",
+ saml_sp_entity_id: "https://proxy.example.com/sso/saml/metadata",
+ saml_allow_unsolicited: "true",
+ }),
+ );
+ });
+
+ expect(mockHandleShowInstructions).toHaveBeenCalled();
+ });
+
it("should show Clear button and clear SSO settings when configured", async () => {
const mockHandleAddSSOOk = vi.fn();
(updateSSOSettings as any).mockResolvedValue({});
@@ -462,6 +532,10 @@ describe("SSOModals", () => {
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
+ saml_idp_metadata_url: null,
+ saml_idp_metadata_xml: null,
+ saml_sp_entity_id: null,
+ saml_allow_unsolicited: null,
generic_scope: null,
proxy_base_url: null,
user_email: null,
diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx
index 637abbf4a81..be57e14ff60 100644
--- a/ui/litellm-dashboard/src/components/SSOModals.tsx
+++ b/ui/litellm-dashboard/src/components/SSOModals.tsx
@@ -21,6 +21,17 @@ interface SSOModalsProps {
ssoConfigured?: boolean; // Add optional prop to indicate if SSO is configured
}
+const detectSSOProvider = (values: Record): string | null => {
+ if (values.google_client_id) return "google";
+ if (values.microsoft_client_id) return "microsoft";
+ if (values.generic_client_id) {
+ const authEndpoint =
+ typeof values.generic_authorization_endpoint === "string" ? values.generic_authorization_endpoint : "";
+ return authEndpoint.includes("okta") || authEndpoint.includes("auth0") ? "okta" : "generic";
+ }
+ if (values.saml_idp_metadata_url || values.saml_idp_metadata_xml) return "saml";
+ return null;
+};
const SSOModals: React.FC = ({
isAddSSOModalVisible,
isInstructionsModalVisible,
@@ -43,22 +54,7 @@ const SSOModals: React.FC = ({
const ssoData = await getSSOSettings(accessToken);
if (ssoData && ssoData.values) {
// Determine which SSO provider is configured
- let selectedProvider = null;
- if (ssoData.values.google_client_id) {
- selectedProvider = "google";
- } else if (ssoData.values.microsoft_client_id) {
- selectedProvider = "microsoft";
- } else if (ssoData.values.generic_client_id) {
- // Check if it looks like Okta based on endpoints
- if (
- ssoData.values.generic_authorization_endpoint?.includes("okta") ||
- ssoData.values.generic_authorization_endpoint?.includes("auth0")
- ) {
- selectedProvider = "okta";
- } else {
- selectedProvider = "generic";
- }
- }
+ const selectedProvider = detectSSOProvider(ssoData.values);
// Extract role mappings if they exist
let roleMappingFields = {};
@@ -89,6 +85,7 @@ const SSOModals: React.FC = ({
user_email: ssoData.values.user_email,
...ssoData.values,
...roleMappingFields,
+ saml_allow_unsolicited: ssoData.values.saml_allow_unsolicited === "true",
};
// Clear form first, then set values with a small delay to ensure proper initialization
@@ -129,6 +126,10 @@ const SSOModals: React.FC = ({
...rest,
};
+ if (typeof payload.saml_allow_unsolicited === "boolean") {
+ payload.saml_allow_unsolicited = payload.saml_allow_unsolicited ? "true" : "false";
+ }
+
// Add role mappings if use_role_mappings is checked
if (use_role_mappings) {
// Helper function to split comma-separated string into array
@@ -191,6 +192,10 @@ const SSOModals: React.FC = ({
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
+ saml_idp_metadata_url: null,
+ saml_idp_metadata_xml: null,
+ saml_sp_entity_id: null,
+ saml_allow_unsolicited: null,
generic_scope: null,
proxy_base_url: null,
user_email: null,
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
index caa6ff4f1e8..7deac9cbbbc 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
@@ -19,6 +19,7 @@ export interface SSOProviderConfig {
name: string;
placeholder?: string;
required?: boolean;
+ type?: "password" | "textarea" | "checkbox";
}>;
}
@@ -90,6 +91,41 @@ export const ssoProviderConfigs: Record = {
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
],
},
+ saml: {
+ envVarMap: {
+ saml_idp_metadata_url: "SAML_IDP_METADATA_URL",
+ saml_idp_metadata_xml: "SAML_IDP_METADATA_XML",
+ saml_sp_entity_id: "SAML_SP_ENTITY_ID",
+ saml_allow_unsolicited: "SAML_ALLOW_UNSOLICITED",
+ },
+ fields: [
+ {
+ label: "IdP Metadata URL",
+ name: "saml_idp_metadata_url",
+ required: false,
+ placeholder: "https://idp.example.com/metadata (use this or the metadata XML below)",
+ },
+ {
+ label: "IdP Metadata XML",
+ name: "saml_idp_metadata_xml",
+ required: false,
+ type: "textarea",
+ placeholder: "Paste the IdP metadata XML here if you do not have a metadata URL",
+ },
+ {
+ label: "SP Entity ID",
+ name: "saml_sp_entity_id",
+ required: false,
+ placeholder: "Defaults to /sso/saml/metadata",
+ },
+ {
+ label: "Allow IdP-initiated (unsolicited) responses",
+ name: "saml_allow_unsolicited",
+ required: false,
+ type: "checkbox",
+ },
+ ],
+ },
};
// Helper function to render provider fields
@@ -97,16 +133,31 @@ export const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
- return config.fields.map((field) => (
-
- {field.name.includes("client") ? : }
-
- ));
+ return config.fields.map((field) => {
+ const isRequired = field.required !== false;
+ const rules = isRequired ? [{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }] : [];
+ let control: React.ReactNode;
+ if (field.type === "checkbox") {
+ control = ;
+ } else if (field.type === "textarea") {
+ control = ;
+ } else if (field.type === "password" || field.name.includes("client")) {
+ control = ;
+ } else {
+ control = ;
+ }
+ return (
+
+ {control}
+
+ );
+ });
};
const BaseSSOSettingsForm: React.FC = ({ form, onFormSubmit }) => {
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx
index 2656c861aa8..cbb55be6c1f 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx
@@ -29,6 +29,10 @@ const DeleteSSOSettingsModal: React.FC = ({ isVisib
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
+ saml_idp_metadata_url: null,
+ saml_idp_metadata_xml: null,
+ saml_sp_entity_id: null,
+ saml_allow_unsolicited: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
index d2d54033395..7415683af83 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
@@ -181,7 +181,8 @@ vi.mock("@/components/shared/errorUtils", () => ({
parseErrorMessage: vi.fn(),
}));
-vi.mock("../utils", () => ({
+vi.mock("../utils", async (importOriginal) => ({
+ ...(await importOriginal()),
processSSOSettingsPayload: vi.fn(),
}));
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
index 6c341c42fb7..97fbc31ce2f 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
@@ -5,7 +5,7 @@ import React, { useEffect } from "react";
import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
-import { processSSOSettingsPayload } from "../utils";
+import { detectSSOProvider, processSSOSettingsPayload } from "../utils";
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
@@ -26,22 +26,7 @@ const EditSSOSettingsModal: React.FC = ({ isVisible,
const ssoData = ssoSettings.data;
// Determine which SSO provider is configured
- let selectedProvider = null;
- if (ssoData.values.google_client_id) {
- selectedProvider = "google";
- } else if (ssoData.values.microsoft_client_id) {
- selectedProvider = "microsoft";
- } else if (ssoData.values.generic_client_id) {
- // Check if it looks like Okta based on endpoints
- if (
- ssoData.values.generic_authorization_endpoint?.includes("okta") ||
- ssoData.values.generic_authorization_endpoint?.includes("auth0")
- ) {
- selectedProvider = "okta";
- } else {
- selectedProvider = "generic";
- }
- }
+ const selectedProvider = detectSSOProvider(ssoData.values);
// Extract role mappings if they exist
let roleMappingFields = {};
@@ -81,6 +66,9 @@ const EditSSOSettingsModal: React.FC = ({ isVisible,
...ssoData.values,
...roleMappingFields,
...teamMappingFields,
+ ...(ssoData.values.saml_allow_unsolicited != null
+ ? { saml_allow_unsolicited: ssoData.values.saml_allow_unsolicited === "true" }
+ : {}),
};
// Clear form first, then set values with a small delay to ensure proper initialization
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx
index e585bec4fd5..ed54549a40d 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.test.tsx
@@ -48,6 +48,28 @@ const googleConfiguredValues = {
team_mappings: null,
};
+const samlConfiguredValues = {
+ google_client_id: null,
+ google_client_secret: null,
+ microsoft_client_id: null,
+ microsoft_client_secret: null,
+ microsoft_tenant: null,
+ generic_client_id: null,
+ generic_client_secret: null,
+ generic_authorization_endpoint: null,
+ generic_token_endpoint: null,
+ generic_userinfo_endpoint: null,
+ proxy_base_url: "https://proxy.example.com",
+ user_email: null,
+ ui_access_mode: null,
+ role_mappings: null,
+ team_mappings: null,
+ saml_idp_metadata_url: null,
+ saml_idp_metadata_xml: "",
+ saml_sp_entity_id: "https://proxy.example.com/sso/saml/metadata",
+ saml_allow_unsolicited: "true",
+};
+
describe("SSOSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -77,4 +99,20 @@ describe("SSOSettings", () => {
const logo = screen.getByAltText("Google SSO logo");
expect(logo).toHaveAttribute("src", expect.stringContaining("google.svg"));
});
+
+ it("renders a SAML configuration as configured instead of the empty placeholder", () => {
+ mockUseSSOSettings.mockReturnValue({
+ data: { values: samlConfiguredValues },
+ isLoading: false,
+ refetch: vi.fn(),
+ });
+
+ renderSSOSettings();
+
+ expect(screen.queryByText("No SSO Configuration Found")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Edit SSO Settings/i })).toBeInTheDocument();
+ expect(screen.getByText("SAML SSO")).toBeInTheDocument();
+ expect(screen.getByText("https://proxy.example.com/sso/saml/metadata")).toBeInTheDocument();
+ expect(screen.getByText("Enabled")).toBeInTheDocument();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx
index 849921c1076..0c83994cde6 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx
@@ -22,10 +22,13 @@ export default function SSOSettings() {
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
- const isSSOConfigured =
- Boolean(ssoSettings?.values.google_client_id) ||
- Boolean(ssoSettings?.values.microsoft_client_id) ||
- Boolean(ssoSettings?.values.generic_client_id);
+ const isSSOConfigured = [
+ ssoSettings?.values.google_client_id,
+ ssoSettings?.values.microsoft_client_id,
+ ssoSettings?.values.generic_client_id,
+ ssoSettings?.values.saml_idp_metadata_url,
+ ssoSettings?.values.saml_idp_metadata_xml,
+ ].some(Boolean);
const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null;
const isRoleMappingsEnabled = Boolean(ssoSettings?.values.role_mappings);
@@ -154,6 +157,37 @@ export default function SSOSettings() {
: null,
],
},
+ saml: {
+ providerText: ssoProviderDisplayNames.saml,
+ fields: [
+ {
+ label: "IdP Metadata URL",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_idp_metadata_url),
+ },
+ {
+ label: "IdP Metadata XML",
+ render: (values: SSOSettingsValues) =>
+ values.saml_idp_metadata_xml ? (
+ Provided
+ ) : (
+ Not configured
+ ),
+ },
+ {
+ label: "SP Entity ID",
+ render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_sp_entity_id),
+ },
+ {
+ label: "Allow IdP-initiated (unsolicited) responses",
+ render: (values: SSOSettingsValues) => (
+
+ {values.saml_allow_unsolicited === "true" ? "Enabled" : "Disabled"}
+
+ ),
+ },
+ { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
+ ],
+ },
};
const renderSSOSettings = () => {
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts
index b5f5ccb1b8c..19a64a59ca1 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts
@@ -7,6 +7,7 @@ export const ssoProviderLogoMap: Record = {
microsoft: microsoftAzureLogo.src,
okta: "https://www.okta.com/sites/default/files/Okta_Logo_BrightBlue_Medium.png",
generic: "",
+ saml: "",
};
// SSO Provider display names (consistent between select dropdown and table)
@@ -15,6 +16,7 @@ export const ssoProviderDisplayNames: Record = {
microsoft: "Microsoft SSO",
okta: "Okta / Auth0 SSO",
generic: "Generic SSO",
+ saml: "SAML SSO",
};
export const defaultRoleDisplayNames: Record = {
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts
index 722d52d64f9..b280f5e92ec 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts
@@ -1,5 +1,6 @@
-import { processSSOSettingsPayload } from "./utils";
+import { detectSSOProvider, processSSOSettingsPayload } from "./utils";
import { describe, it, expect } from "vitest";
+import type { SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
describe("processSSOSettingsPayload", () => {
describe("without role mappings", () => {
@@ -428,3 +429,26 @@ describe("processSSOSettingsPayload", () => {
});
});
});
+
+describe("detectSSOProvider with SAML", () => {
+ it("returns saml when a SAML IdP metadata URL is configured", () => {
+ expect(detectSSOProvider({ saml_idp_metadata_url: "https://idp.example.com/metadata" } as SSOSettingsValues)).toBe(
+ "saml",
+ );
+ });
+
+ it("returns saml when only inline SAML metadata XML is configured", () => {
+ expect(detectSSOProvider({ saml_idp_metadata_xml: "" } as SSOSettingsValues)).toBe("saml");
+ });
+});
+
+describe("processSSOSettingsPayload with SAML", () => {
+ it("maps the boolean allow-unsolicited toggle to a 'true'/'false' string", () => {
+ expect(
+ processSSOSettingsPayload({ sso_provider: "saml", saml_allow_unsolicited: true }).saml_allow_unsolicited,
+ ).toBe("true");
+ expect(
+ processSSOSettingsPayload({ sso_provider: "saml", saml_allow_unsolicited: false }).saml_allow_unsolicited,
+ ).toBe("false");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts
index 948ed4d2bfe..768fc7e8e0e 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts
@@ -22,6 +22,10 @@ export const processSSOSettingsPayload = (formValues: Record): Reco
...rest,
};
+ if (typeof payload.saml_allow_unsolicited === "boolean") {
+ payload.saml_allow_unsolicited = payload.saml_allow_unsolicited ? "true" : "false";
+ }
+
// Add role mappings only if use_role_mappings is checked AND provider supports role mappings
const provider = rest.sso_provider;
const supportsRoleMappings = provider === "okta" || provider === "generic";
@@ -81,5 +85,6 @@ export const detectSSOProvider = (values: SSOSettingsValues): string | null => {
}
return "generic";
}
+ if (values.saml_idp_metadata_url || values.saml_idp_metadata_xml) return "saml";
return null;
};
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 825d06d6a38..aafab811b83 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -12808,6 +12808,66 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/sso/saml/callback": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Saml Callback
+ * @description Assertion Consumer Service. Validates the IdP assertion and issues a UI session.
+ */
+ post: operations["saml_callback_sso_saml_callback_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/sso/saml/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Saml Login
+ * @description SP-initiated SAML login. Redirects the user to the configured IdP.
+ */
+ get: operations["saml_login_sso_saml_login_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/sso/saml/metadata": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Saml Metadata
+ * @description Service Provider metadata XML, for registering this proxy at the IdP.
+ */
+ get: operations["saml_metadata_sso_saml_metadata_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/tag/daily/activity": {
parameters: {
query?: never;
@@ -30918,6 +30978,26 @@ export interface components {
proxy_base_url?: string | null;
/** @description Configuration for mapping SSO groups to LiteLLM roles based on group claims in the SSO token */
role_mappings?: components["schemas"]["RoleMappings"] | null;
+ /**
+ * Saml Allow Unsolicited
+ * @description 'true' to accept IdP-initiated (unsolicited) SAML responses, which cannot be browser-bound against login CSRF
+ */
+ saml_allow_unsolicited?: string | null;
+ /**
+ * Saml Idp Metadata Url
+ * @description URL of the SAML IdP metadata to fetch and parse for SSO authentication
+ */
+ saml_idp_metadata_url?: string | null;
+ /**
+ * Saml Idp Metadata Xml
+ * @description Inline SAML IdP metadata XML, used when a metadata URL is not available
+ */
+ saml_idp_metadata_xml?: string | null;
+ /**
+ * Saml Sp Entity Id
+ * @description SAML Service Provider entityID; defaults to the proxy's /sso/saml/metadata URL
+ */
+ saml_sp_entity_id?: string | null;
/** @description Configuration for mapping SSO JWT fields to team IDs. Takes precedence over config file settings. */
team_mappings?: components["schemas"]["TeamMappings"] | null;
/**
@@ -49766,6 +49846,77 @@ export interface operations {
};
};
};
+ saml_callback_sso_saml_callback_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ saml_login_sso_saml_login_get: {
+ parameters: {
+ query?: {
+ return_to?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ saml_metadata_sso_saml_metadata_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
get_tag_daily_activity_tag_daily_activity_get: {
parameters: {
query?: {
diff --git a/uv.lock b/uv.lock
index 9c60ca1ee48..70cae50838c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4218,6 +4218,9 @@ proxy-runtime = [
{ name = "pypdf" },
{ name = "sentry-sdk" },
]
+saml = [
+ { name = "python3-saml" },
+]
semantic-router = [
{ name = "aurelio-sdk", marker = "python_full_version < '3.14'" },
{ name = "semantic-router", marker = "python_full_version < '3.14'" },
@@ -4389,6 +4392,7 @@ requires-dist = [
{ name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0,<2.0" },
{ name = "python-multipart", marker = "extra == 'proxy'", specifier = ">=0.0.27,<1.0" },
+ { name = "python3-saml", marker = "extra == 'saml'", specifier = ">=1.16.0,<2.0" },
{ name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0.3,<7.0" },
{ name = "pyyaml", marker = "extra == 'proxy'", specifier = ">=6.0.3,<7.0" },
{ name = "redisvl", marker = "extra == 'extra-proxy'", specifier = ">=0.4.1,<1.0" },
@@ -4409,7 +4413,7 @@ requires-dist = [
{ name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.21.0,<1.0" },
{ name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" },
]
-provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
+provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "saml", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
[package.metadata.requires-dev]
ci = [
@@ -4619,6 +4623,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/cf/e4e016820516c1f0c85549e356865c2319cf6afba8ab386611b1d03cf2b6/lunary-1.4.37-py3-none-any.whl", hash = "sha256:7289330e851a481404c92213ce480c0c7be9bfa56982a1385e08e3665abefe05", size = 25581, upload-time = "2026-02-12T08:15:03.892Z" },
]
+[[package]]
+name = "lxml"
+version = "6.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" },
+ { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" },
+ { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" },
+ { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" },
+ { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" },
+ { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" },
+ { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
+ { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
+ { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
+ { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
+ { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
+ { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
+ { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
+ { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
+ { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
+ { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
+ { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
+ { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
+ { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
+ { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
+ { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
+ { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
+ { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
+ { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
+ { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
+ { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" },
+ { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" },
+ { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" },
+]
+
[[package]]
name = "mako"
version = "1.3.12"
@@ -7561,6 +7683,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" },
]
+[[package]]
+name = "python3-saml"
+version = "1.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "isodate" },
+ { name = "lxml" },
+ { name = "xmlsec" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5d/98/6e0268c3a9893af3d4c5cf670183e0314cd6b5cb034a612d6a7cc5060df8/python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133", size = 83468, upload-time = "2023-10-09T10:37:43.128Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/14/49d9828443b58bd5cc80a454c91b0f867fbf36a24975d501945e6cb9e32f/python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d", size = 76155, upload-time = "2023-10-09T10:40:34.001Z" },
+]
+
[[package]]
name = "pytz"
version = "2026.2"
@@ -9863,6 +9999,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]
+[[package]]
+name = "xmlsec"
+version = "1.3.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/14/538b75379e6ab8f688f14d8663e2ab138d9c778bac4999d155b5f33c71c1/xmlsec-1.3.17.tar.gz", hash = "sha256:f3fac9ae679f66585925cc00c5f6839ae36c1d03157619571dee18acc05b9c01", size = 115637, upload-time = "2025-11-11T16:20:46.019Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/4d/eff78f7bfb15d02db69fc33709040a37a81b0f187995a4a0263b76f60047/xmlsec-1.3.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d43d4f68ac6b11f6e1e69bcb389495f54da77bf1168b4de08f4a7785e47bbb", size = 3450575, upload-time = "2025-11-11T16:19:15.67Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/06/dd2864ae242477dcca8ee1173d2cdaa97357f5e80b93eb16318b69a68957/xmlsec-1.3.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea2d65749c4c6a35a3ba138debda2f910713a9f4f06b4647510c184c284d7c62", size = 3846698, upload-time = "2025-11-11T16:19:19.675Z" },
+ { url = "https://files.pythonhosted.org/packages/48/36/de21872ada14e45290979d9aff07f950f90ab8ab4d42baa53002097f5b11/xmlsec-1.3.17-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d586bb09f146235f82de624ff05fbca76f8aadc627eb9a072df1899317a1a9eb", size = 4420263, upload-time = "2025-11-11T16:19:22.766Z" },
+ { url = "https://files.pythonhosted.org/packages/af/26/80c23e5ad0643489c5af5a011415880616c48b59bb4a05646cb1a9a8cb40/xmlsec-1.3.17-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99b5b4b6fe232f2234bcec2bdd533b7ab7030b3ce6cfb8bd7153bf02441c8520", size = 4160109, upload-time = "2025-11-11T16:19:24.17Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a6/92f203f394d39236e5e3e96a8dc5a9c3a1b84a4ac51580249ea33d15afd0/xmlsec-1.3.17-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4533780d91f547b841f2522a419da9f2cee2f906dbd4aa58083bc944526a45c", size = 3872742, upload-time = "2025-11-11T16:19:25.76Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/f6/52ff78b99c94286ec945a9250207e465f8af293173ec415903add6cbd6db/xmlsec-1.3.17-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:320bf7162e2c442638233da9826af1476049999da1b474b5fe07c60952610131", size = 4458748, upload-time = "2025-11-11T16:19:28.259Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1c/3aea63ceaeb862d2d5dada67928779e980baf3d6a86189ddaae74a98d5c8/xmlsec-1.3.17-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc8d0a75a43a45349b37186ecce5ae028325ede47cbc217802ff3ef4db3f3cb9", size = 4206919, upload-time = "2025-11-11T16:19:29.669Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/22/cb81039dc7bc2cbcf04497261d263726331417898c3e1eaec8281c878e42/xmlsec-1.3.17-cp310-cp310-win_amd64.whl", hash = "sha256:5c6d4b2ece9d109591d08128a1656b458e24d9eba6c02c32e93573e14eee2447", size = 2445928, upload-time = "2025-11-11T16:19:31.298Z" },
+ { url = "https://files.pythonhosted.org/packages/87/04/d97825e99a8bb1ab29ff59ce249f93d97dcd22a3f2ce624dd21a4e8bdf50/xmlsec-1.3.17-cp310-cp310-win_arm64.whl", hash = "sha256:2aa5081e1e05dcb6029660ddad795c7daebb3c5771001f60850ab24a16a9cf5e", size = 2261486, upload-time = "2025-11-11T16:19:32.835Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e4/970614d892749da00df253c370230fd24143028268923a1c35651fb3f962/xmlsec-1.3.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4a7ee007c6b55f7621330aee8330ef2dafa4225fce554064571ca826beafe7e", size = 3450577, upload-time = "2025-11-11T16:19:34.159Z" },
+ { url = "https://files.pythonhosted.org/packages/50/4a/2f48ad48fecbd49dbbc6f2a5b540cd65277089fd5b8b5d8c7e816c3625c2/xmlsec-1.3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef656421d01851618d0fe5518e57469159c14a48e05125f7bd3225631952f9", size = 3846698, upload-time = "2025-11-11T16:19:35.408Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/07/0130e0b711f7443d0abdec403ea5128392cd5b241bb53f4ec41d144d94db/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80fff2251d0e73714435b5860ce200990dffe85466dd91d08d75c4d64ee9967d", size = 4423233, upload-time = "2025-11-11T16:19:37.129Z" },
+ { url = "https://files.pythonhosted.org/packages/00/f7/a4e588d61f602f25a51b6004be9a162e36e746fa1cbeb12248794a96766b/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2bf6bbf04f8a912483d268b4c2727d400d1806d054624da13bee4b9f6fa28a", size = 4163716, upload-time = "2025-11-11T16:19:38.365Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a2/f8c019445134dfc59afb5874d1fc4fe212ec2dc45a8c33806a15b5c0c119/xmlsec-1.3.17-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a603584ceee175036e1bccdbe65d551c0fff67343fd506bfa6cec52bc64d9a75", size = 3875404, upload-time = "2025-11-11T16:19:40.008Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c3/90c0e26bb9f95799c64874ebee0b43eaf7e5b5ba912bcd87ed4cc46ea514/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26cc3d81437b51839946d2e93d09371dfd73ed2831dc7e37eff0fb52fc33747c", size = 4460640, upload-time = "2025-11-11T16:19:41.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/be/7b85b0ff4281779293d93a8bbef70a6b72ba60d8a80d15653bd4967d0c07/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d862f023f56a49c06576be41dfaf213c9ac77e7a344e7f204278c365bb36d00e", size = 4209625, upload-time = "2025-11-11T16:19:43.289Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/6d/028472e523c2f667a4634881b65acfa939bc4902ed37e1e9fe1d55d45ec0/xmlsec-1.3.17-cp311-cp311-win_amd64.whl", hash = "sha256:9877303e8c72d7aa2467d1af12e56d67b8fb50d324eda5848e0ec5ee2176aac5", size = 2445935, upload-time = "2025-11-11T16:19:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/01/d36fd82b837167546951e7e088dbd2f0dacf553157d256b2a25802d28a95/xmlsec-1.3.17-cp311-cp311-win_arm64.whl", hash = "sha256:b3f306f5aef47336b8299d8dbee31fa0b2eba4579f9f41396070f7a97d0dcd49", size = 2261485, upload-time = "2025-11-11T16:19:46.212Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/a5/d91216f7dbb85cb65cb7249fcc894f5389a8a4843857aff678646cab77fa/xmlsec-1.3.17-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df4a8d7fef3ffe90e572400d47392ea480120e339c292f802830ed09d449e622", size = 3450960, upload-time = "2025-11-11T16:19:47.794Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/38/c37bd4e164259e0b271fe4d17d054f31c7287a1e4c47d24ef77d723b3493/xmlsec-1.3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed63cbd87dd69ebcf3a9f82d87b67818c9a7d656325dd4fb34d6c4dfbaa84017", size = 3846774, upload-time = "2025-11-11T16:19:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ff/83430c5df33c6ad402728a681998c5b2872c090b556a558d02f8cf1d2f24/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c3008b32a15d24b6c9da39bf6ede8dc3122570a640a73795d763aea55a2193e", size = 4425910, upload-time = "2025-11-11T16:19:50.95Z" },
+ { url = "https://files.pythonhosted.org/packages/02/41/bb94c7a97ea613b3860f6152bb7efcf5be524d135592e094ecc64ff79228/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0b9a1dcda547e0340eefa6f4a04b87dbd9e40cd514487f347934f94fd559ab", size = 4169038, upload-time = "2025-11-11T16:19:52.217Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/4c/852ba0805df27b7bd1e88e9524d9573b076c3a126e936b1f18c6f22fb968/xmlsec-1.3.17-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a53c14d4bc40b0f0fcc6d7908b88f3cbbcf36e25c392f796d88aee7dee5beea", size = 3876430, upload-time = "2025-11-11T16:19:53.388Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/f0/08fec6adc65f6911b49b4fa71e920c8f6434f44fdc427c71360e6dd9e9ce/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5346616e1fe1015f7800698c15225c7902f45db199e217af2039a21989aff7e9", size = 4464419, upload-time = "2025-11-11T16:19:54.777Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ce/84789ba3929715806deae88f10bc31e1ff904aa735059ee3855c104a142d/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64c1184d51c8a67e3d1eb3ac477e307a07e2b40fd03cd0c8084b147ea0f342db", size = 4215080, upload-time = "2025-11-11T16:19:56.293Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6e/57b5054187cd2b42e5310dc1f6d209fced456f93dae25345a422b3a290ef/xmlsec-1.3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d360d4adfb53d3adeca398c225cb7e2a73a2246414455937082a1fa19bd8572b", size = 2445872, upload-time = "2025-11-11T16:19:57.713Z" },
+ { url = "https://files.pythonhosted.org/packages/04/7b/f64c95df054dd793ae1925f04248abd359b1c26cc2320d67407e7fd26e4d/xmlsec-1.3.17-cp312-cp312-win_arm64.whl", hash = "sha256:eee89c268a35f8a08a8e9abef6f466b97577e94f5cac8bf32c25e97cd5020097", size = 2261464, upload-time = "2025-11-11T16:19:58.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/d0c03351bbf776f2272d602272ca9d759d48f0f4e90707987098abb48e14/xmlsec-1.3.17-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:672e41dc7962da4ce84b67aa1c3a008338e3b88332f5484b9911b91cee0997ed", size = 3450899, upload-time = "2025-11-11T16:20:00.29Z" },
+ { url = "https://files.pythonhosted.org/packages/50/6e/00db758c40d42ae2d43603552262b1027c02bbac934be26425e820c63c0f/xmlsec-1.3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72fc6d336dd68d62822c6536ff4b2453fda94ea652eddb4a958ac97b16ac7001", size = 3846790, upload-time = "2025-11-11T16:20:01.515Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/91/00cd12243f5f8cccec23e0d9946379861b954bf98c52d3f68b9eb565ba76/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae88c3aaab5704adfdbce913b3a18db1eb96c49c970657cc01c0d1c420ffdec3", size = 4427662, upload-time = "2025-11-11T16:20:02.931Z" },
+ { url = "https://files.pythonhosted.org/packages/77/64/d198a8109c11124b01abbd34167dd951896b12392ccfc3f12c40eb3f0c35/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79b471fdd1d3a92b80907828eaa809f6e34023583488b1b8dc3f951529e7a2f8", size = 4170229, upload-time = "2025-11-11T16:20:04.244Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a9/3e061f10d0d921102a55b4c0442c8c5af4e01e175ea1584774eeef2e50aa/xmlsec-1.3.17-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:040f28a7aacfdb467df46d423e4af05569e9376bc8c7f6416b0761e16a0e3d0b", size = 3877622, upload-time = "2025-11-11T16:20:05.593Z" },
+ { url = "https://files.pythonhosted.org/packages/37/1a/b8a71915bf1d59944d815c92e77a06e9c2dc4dc855a44a3127c86b0dd7f2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67717fe5151df68987a1387cba11ba28ce19b3bb9a2d10d650277cd910e510e7", size = 4464934, upload-time = "2025-11-11T16:20:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/0f/4b9057c6049137256bb972d114d2858fc8b24e72c97e05e26a00d2db8ed2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9bb6faa4ae0268204cfa6b0c0de1c9121eb606eea8c66c7d7ce62e89a17f9efa", size = 4215150, upload-time = "2025-11-11T16:20:10.008Z" },
+ { url = "https://files.pythonhosted.org/packages/95/6b/a2e8bc2f94b90c2904007663c8162423fadd3cd98b7ca1632b66dcdc31cb/xmlsec-1.3.17-cp313-cp313-win_amd64.whl", hash = "sha256:66fe5aaccf68fb85fe0b64277e3f594d6b01ddefb98ef1ceb0a666652d6ec580", size = 2445890, upload-time = "2025-11-11T16:20:11.575Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/df/27210baa675eb9e5d80ed43e80d865be8fbf6148ea464d2b4d4ad1ba9f01/xmlsec-1.3.17-cp313-cp313-win_arm64.whl", hash = "sha256:5319d0bdaf9e597a0ba8dfb3840c4ae57e51f462e7620953f32b07df6267f2ba", size = 2261424, upload-time = "2025-11-11T16:20:12.88Z" },
+ { url = "https://files.pythonhosted.org/packages/77/2c/0169a383769d563f6582d5b3a2ccf7f612f4bf98cbd417a27287443b63c5/xmlsec-1.3.17-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d0e69291f90b28e9442d8e0e69d3e06cede8a3c44e856413fd284de81ce2888", size = 3450932, upload-time = "2025-11-11T16:20:14.334Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/be65923c5aa3097f422af3d917ffda15590ab0f4c9a5a5d78d520ae7fc9a/xmlsec-1.3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5616ad5016794b0dd41d03eef5b721e31bb306353226b25fc88fedb7d4f7c37e", size = 3847248, upload-time = "2025-11-11T16:20:15.71Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/58/24e047e6a5f0c266e949c7c03c2770163038e7abd322c95bfbae021f9477/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4be73fbde421d6188300e02ad92d2d5435c708a35ede8124ebdf6b00330d7cb", size = 4428590, upload-time = "2025-11-11T16:20:18.012Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/23/e5212147d227da638311287045c90a47bb560b0552cc7daca0919a870220/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3961102a6ba8250670814bd1086139fb918e03bf146ef85dc8b6084a9b027d1", size = 4169645, upload-time = "2025-11-11T16:20:19.646Z" },
+ { url = "https://files.pythonhosted.org/packages/68/5d/ed1f6d18f7c10dc61f791aade218b2271b4fc3092dd499036bc391a32945/xmlsec-1.3.17-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:728058a1623a620811a3cdf2dd4894b5d9413ede20c8ddddf98fdea5eafe9529", size = 3878531, upload-time = "2025-11-11T16:20:20.964Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/eb/09050fd1dc109ebe5bfefd0eab0829cab4fae51b3a244949e31dccf144e1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:593264c192d1836162d75478c8b1cb5874f3b69dcc5bdfac642a0933abefa93a", size = 4464490, upload-time = "2025-11-11T16:20:22.369Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/2e/52e9ef2b5c8ef2470e1e3ae3ef89f7ac45eecd267c7b3bab8a7ad7d68af1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d1fc1fbe2e8585a3f468cf4154d0ec36cd95a15e68429ad8cc8ccd7c04e84ae", size = 4214358, upload-time = "2025-11-11T16:20:24.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cd/5e9061027a203fd083b6058c2948ee1a16bd909d3a0e331e054362ca550e/xmlsec-1.3.17-cp314-cp314-win_amd64.whl", hash = "sha256:e2bf1d07c4f97afeb957f626b8c3ebb8cef300efa0cb95599e936c69a66a1b17", size = 2513252, upload-time = "2025-11-11T16:20:25.738Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e9/b2f4b9092434b854bcae0d901c10a7e96d2a12d03cc35dbf7a7b2c91502b/xmlsec-1.3.17-cp314-cp314-win_arm64.whl", hash = "sha256:3a6ced8c7744e896cb5a9fd0156d204df3143a62bae11be91cab8e9743d40eec", size = 2328451, upload-time = "2025-11-11T16:20:27.247Z" },
+]
+
[[package]]
name = "xxhash"
version = "3.7.0"