mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(proxy): add SAML 2.0 SSO for the admin UI (#31429)
litellm already supports Google, Microsoft and generic OIDC SSO through fastapi-sso, which has no SAML support; AuthMethod.SAML existed only as an unused enum value. This adds real SAML 2.0 single sign-on for the admin UI. A new SAMLAuthHandler validates signed assertions with the OneLogin python3-saml toolkit and maps them onto a CustomOpenID, then reuses the shared post-login path every other provider goes through, so provisioning, role/team mapping and the UI session JWT are unchanged. Both SP-initiated and IdP-initiated HTTP-POST flows are supported. SP-initiated logins are bound to the browser that started them via an HttpOnly state cookie plus a cached AuthnRequest id, and the ACS rejects any response whose InResponseTo doesn't match; unsolicited (IdP-initiated) responses cannot be browser-bound so they are rejected unless SAML_ALLOW_UNSOLICITED=true. Replays are rejected by a consumed-assertion guard whose lifetime tracks each assertion's NotOnOrAfter, and both the replay guard and the login-state binding go through the proxy's shared in-memory + Redis cache for multi-instance deployments. The ACS honors DISABLE_ADMIN_UI and re-applies the free-SSO-user Enterprise gate after the assertion is validated, so an unvalidated POST can no longer drive the billable-user count query. SAML is configurable from the admin UI SSO settings (IdP metadata URL or inline XML, SP entity ID, and an allow-unsolicited toggle), which persists the SAML_* environment variables the handler reads, exactly like the Google, Microsoft and generic OIDC providers. python3-saml is kept as an optional saml extra; its xmlsec and lxml wheels bundle the native libraries so no system packages are required, and the import is guarded so the proxy still starts without the package with the SAML routes returning a clear 501. Resolves LIT-4016
This commit is contained in:
parent
2ef64acf6c
commit
35dc982692
28 changed files with 2069 additions and 72 deletions
2
.github/workflows/_test-unit-base.yml
vendored
2
.github/workflows/_test-unit-base.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
2
.github/workflows/mutation-test.yml
vendored
2
.github/workflows/mutation-test.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
493
litellm/proxy/management_endpoints/sso/saml_sso.py
Normal file
493
litellm/proxy/management_endpoints/sso/saml_sso.py
Normal file
|
|
@ -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 []
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
644
tests/test_litellm/proxy/management_endpoints/test_saml_sso.py
Normal file
644
tests/test_litellm/proxy/management_endpoints/test_saml_sso.py
Normal file
|
|
@ -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 (
|
||||
'<?xml version="1.0"?>'
|
||||
f'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{IDP_ENTITY}">'
|
||||
'<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">'
|
||||
'<KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
f"<X509Data><X509Certificate>{cert_body}</X509Certificate></X509Data>"
|
||||
"</KeyInfo></KeyDescriptor>"
|
||||
'<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" '
|
||||
f'Location="{SSO_URL}"/>'
|
||||
"</IDPSSODescriptor></EntityDescriptor>"
|
||||
)
|
||||
|
||||
|
||||
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'<saml:Attribute Name="{name}">'
|
||||
+ "".join(f"<saml:AttributeValue>{v}</saml:AttributeValue>" for v in values)
|
||||
+ "</saml:Attribute>"
|
||||
for name, values in attributes.items()
|
||||
)
|
||||
|
||||
assertion = (
|
||||
'<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" '
|
||||
f'ID="{assertion_id}" Version="2.0" IssueInstant="{issue_instant}">'
|
||||
f"<saml:Issuer>{IDP_ENTITY}</saml:Issuer>"
|
||||
"<saml:Subject>"
|
||||
'<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">'
|
||||
f"{email}</saml:NameID>"
|
||||
'<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">'
|
||||
f'<saml:SubjectConfirmationData {irt} NotOnOrAfter="{not_on_or_after}" Recipient="{ACS}"/>'
|
||||
"</saml:SubjectConfirmation></saml:Subject>"
|
||||
f'<saml:Conditions NotBefore="{not_before}" NotOnOrAfter="{not_on_or_after}">'
|
||||
f"<saml:AudienceRestriction><saml:Audience>{SP_ENTITY}</saml:Audience>"
|
||||
"</saml:AudienceRestriction></saml:Conditions>"
|
||||
f'<saml:AuthnStatement AuthnInstant="{issue_instant}" SessionIndex="_session">'
|
||||
"<saml:AuthnContext><saml:AuthnContextClassRef>"
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
|
||||
"</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>"
|
||||
f"<saml:AttributeStatement>{attr_xml}</saml:AttributeStatement>"
|
||||
"</saml:Assertion>"
|
||||
)
|
||||
|
||||
if sign:
|
||||
signed = OneLogin_Saml2_Utils.add_sign(assertion, key_pem, cert_pem)
|
||||
assertion = (signed.decode() if isinstance(signed, bytes) else signed).replace(
|
||||
'<?xml version="1.0"?>', ""
|
||||
)
|
||||
|
||||
return (
|
||||
'<?xml version="1.0"?>'
|
||||
'<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" '
|
||||
'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" '
|
||||
f'ID="{response_id}" Version="2.0" IssueInstant="{issue_instant}" '
|
||||
f'Destination="{ACS}" {response_irt}>'
|
||||
f"<saml:Issuer>{IDP_ENTITY}</saml:Issuer>"
|
||||
'<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>'
|
||||
"</samlp:Status>"
|
||||
f"{assertion}</samlp:Response>"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SSOModals
|
||||
isAddSSOModalVisible={true}
|
||||
isInstructionsModalVisible={false}
|
||||
handleAddSSOOk={() => {}}
|
||||
handleAddSSOCancel={() => {}}
|
||||
handleShowInstructions={mockHandleShowInstructions}
|
||||
handleInstructionsOk={() => {}}
|
||||
handleInstructionsCancel={() => {}}
|
||||
form={form}
|
||||
accessToken="test-token"
|
||||
ssoConfigured={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ interface SSOModalsProps {
|
|||
ssoConfigured?: boolean; // Add optional prop to indicate if SSO is configured
|
||||
}
|
||||
|
||||
const detectSSOProvider = (values: Record<string, unknown>): 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<SSOModalsProps> = ({
|
||||
isAddSSOModalVisible,
|
||||
isInstructionsModalVisible,
|
||||
|
|
@ -43,22 +54,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
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<SSOModalsProps> = ({
|
|||
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<SSOModalsProps> = ({
|
|||
...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<SSOModalsProps> = ({
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, SSOProviderConfig> = {
|
|||
{ 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 <proxy base url>/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) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
rules={[{ required: field.required !== false, message: `Please enter the ${field.label.toLowerCase()}` }]}
|
||||
>
|
||||
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
|
||||
</Form.Item>
|
||||
));
|
||||
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 = <Checkbox />;
|
||||
} else if (field.type === "textarea") {
|
||||
control = <Input.TextArea rows={4} placeholder={field.placeholder} />;
|
||||
} else if (field.type === "password" || field.name.includes("client")) {
|
||||
control = <Input.Password />;
|
||||
} else {
|
||||
control = <TextInput placeholder={field.placeholder} />;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
rules={rules}
|
||||
valuePropName={field.type === "checkbox" ? "checked" : undefined}
|
||||
>
|
||||
{control}
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = ({ form, onFormSubmit }) => {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ const DeleteSSOSettingsModal: React.FC<DeleteSSOSettingsModalProps> = ({ 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,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ vi.mock("@/components/shared/errorUtils", () => ({
|
|||
parseErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils", () => ({
|
||||
vi.mock("../utils", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../utils")>()),
|
||||
processSSOSettingsPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<EditSSOSettingsModalProps> = ({ 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<EditSSOSettingsModalProps> = ({ 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
|
||||
|
|
|
|||
|
|
@ -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: "<EntityDescriptor/>",
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<Tag>Provided</Tag>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Not configured</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "SP Entity ID",
|
||||
render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_sp_entity_id),
|
||||
},
|
||||
{
|
||||
label: "Allow IdP-initiated (unsolicited) responses",
|
||||
render: (values: SSOSettingsValues) => (
|
||||
<Tag color={values.saml_allow_unsolicited === "true" ? "green" : "default"}>
|
||||
{values.saml_allow_unsolicited === "true" ? "Enabled" : "Disabled"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const renderSSOSettings = () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const ssoProviderLogoMap: Record<string, string> = {
|
|||
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<string, string> = {
|
|||
microsoft: "Microsoft SSO",
|
||||
okta: "Okta / Auth0 SSO",
|
||||
generic: "Generic SSO",
|
||||
saml: "SAML SSO",
|
||||
};
|
||||
|
||||
export const defaultRoleDisplayNames: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -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: "<EntityDescriptor/>" } 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export const processSSOSettingsPayload = (formValues: Record<string, any>): 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;
|
||||
};
|
||||
|
|
|
|||
151
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
151
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
194
uv.lock
generated
194
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue