"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit). v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data`` dict and builds the SP settings from the ``sso_config`` row. What the toolkit validates in strict mode (all covered by tests): XML schema, signature of the assertion and/or the message against the IdP certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``, ``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo`` against the AuthnRequest id we pass to ``process_response()`` — combined with the single-use ``sso_requests`` store that makes replay impossible. """ from __future__ import annotations import logging from dataclasses import dataclass, field from fastapi import Request logger = logging.getLogger(__name__) BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" #: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's #: NameID; every other value is matched against attribute Name / FriendlyName #: / URI local part (so ``email`` finds both ``email`` and #: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``). DEFAULT_SAML_MAPPING: dict[str, str] = { "login": "nameid", "email": "nameid", "full_name": "displayName", "avatar_url": "avatar", "groups": "groups", } class SAMLError(Exception): """SAML processing failure — ``message`` is user-facing, ``reason`` is logged.""" @dataclass class SAMLIdentity: """What a validated assertion tells us about the user.""" name_id: str name_id_format: str = "" session_index: str = "" attributes: dict[str, list[str]] = field(default_factory=dict) friendly_attributes: dict[str, list[str]] = field(default_factory=dict) def resolve(self, source: str) -> str: """Resolve one mapped source (``nameid`` or an attribute name) → first value.""" if not source or source == "nameid": return self.name_id or "" if source in self.attributes and self.attributes[source]: return (self.attributes[source][0] or "").strip() # FriendlyName match (case-insensitive) lower = {k.lower(): v for k, v in self.friendly_attributes.items()} if source.lower() in lower and lower[source.lower()]: return (lower[source.lower()][0] or "").strip() # URI local part match: ".../claims/emailaddress" ~ "emailaddress", and # a mapping of "email" must still find ".../claims/emailaddress". want = source.lower().lstrip("./") for name, values in self.attributes.items(): if not values: continue local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower() if local == want or local.endswith(want) or want.endswith(local): return (values[0] or "").strip() return "" def external_base_url(request: Request) -> str: """Scheme://host the user actually used (proxy-aware, like OAuth redirects).""" proto = request.headers.get("x-forwarded-proto", "") scheme = proto.split(",")[0].strip() or request.url.scheme or "http" fwd_host = request.headers.get("x-forwarded-host", "") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") return f"{scheme}://{host}" def saml_endpoints(request: Request) -> dict[str, str]: """SP entity id + ACS/SLO/metadata URLs derived from the incoming request.""" base = external_base_url(request) return { "entity_id": f"{base}/auth/saml/metadata", "acs": f"{base}/auth/saml/callback", "slo": f"{base}/auth/saml/logout", "metadata": f"{base}/auth/saml/metadata", } def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict: """python3-saml settings dict built from a ``sso_config`` row.""" sign_requests = bool(cfg.get("sign_requests")) sp: dict = { "entityId": endpoints["entity_id"], "assertionConsumerService": { "url": endpoints["acs"], "binding": BINDING_HTTP_POST, }, "singleLogoutService": { "url": endpoints["slo"], "binding": BINDING_HTTP_REDIRECT, }, "NameIDFormat": NAMEID_FORMAT_EMAIL, } if cfg.get("sp_private_key") and cfg.get("sp_certificate"): sp["privateKey"] = cfg["sp_private_key"] sp["x509cert"] = cfg["sp_certificate"] idp: dict = { "entityId": cfg.get("entity_id") or "", "singleSignOnService": { "url": cfg.get("sso_url") or "", "binding": BINDING_HTTP_REDIRECT, }, "x509cert": cfg.get("x509_certificate") or "", } if cfg.get("slo_url"): idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT} return { "strict": True, "debug": False, "sp": sp, "idp": idp, "security": { "authnRequestsSigned": sign_requests, "logoutRequestSigned": sign_requests, "logoutResponseSigned": False, "wantMessagesSigned": False, "wantAssertionsSigned": True, "wantNameIdEncrypted": False, "wantAssertionsEncrypted": False, "wantXmlValidation": True, "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", "rejectDeprecatedAlgorithm": True, # FlowDeck is self-hosted: LAN/homelab deploys commonly reach the # SP through single-label hosts (http://flowdeck/, docker service # names). python3-saml rejects those URLs unless this is on. "allowSingleLabelDomains": True, }, } def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict: """Flat request dict expected by ``OneLogin_Saml2_Auth``.""" https = "on" if external_base_url(request).startswith("https") else "off" return { "https": https, "http_host": request.headers.get("host", "localhost:8080"), "script_name": script_name, "request_uri": request.url.path, "query_string": str(request.url.query or ""), "get_data": dict(request.query_params), "post_data": post_data or {}, } def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None): from onelogin.saml2.auth import OneLogin_Saml2_Auth settings = build_settings(cfg, saml_endpoints(request)) try: return OneLogin_Saml2_Auth( _request_data(request, script_name, post_data=post_data), old_settings=settings ) except Exception as err: # malformed IdP/SP config (bad cert, missing URL…) raise SAMLError(f"Invalid SAML configuration: {err}") from err def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]: """Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``.""" auth = _auth(request, cfg, "/auth/saml/login") try: url = auth.login(return_to=relay_state) except Exception as err: raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err request_id = auth.get_last_request_id() or "" if not request_id: raise SAMLError("AuthnRequest was built without an id") return url, request_id def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity: """Validate the IdP's SAMLResponse and extract the identity. ``request_id`` is the id of the AuthnRequest we issued (from the single-use ``sso_requests`` row): the toolkit rejects any response whose ``InResponseTo`` does not match it. """ auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data) try: auth.process_response(request_id=request_id or None) except Exception as err: raise SAMLError(f"SAML response could not be processed: {err}") from err errors = auth.get_errors() if errors: raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}") if not auth.is_authenticated(): raise SAMLError("SAML response did not authenticate the user") name_id = auth.get_nameid() or "" if not name_id: raise SAMLError("SAML assertion carries no NameID") return SAMLIdentity( name_id=name_id, name_id_format=auth.get_nameid_format() or "", session_index=auth.get_session_index() or "", attributes=auth.get_attributes() or {}, friendly_attributes=auth.get_friendlyname_attributes() or {}, ) def metadata_xml(request: Request, cfg: dict) -> str: """SP metadata XML (for the IdP configuration screen).""" from onelogin.saml2.settings import OneLogin_Saml2_Settings settings = OneLogin_Saml2_Settings( build_settings(cfg, saml_endpoints(request)), custom_base_path=None ) try: xml = settings.get_sp_metadata() except Exception as err: raise SAMLError(f"Could not build the SP metadata: {err}") from err if isinstance(xml, bytes): xml = xml.decode("utf-8") return xml def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str: """SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP).""" auth = _auth(request, cfg, "/auth/saml/logout") if not cfg.get("slo_url"): raise SAMLError("The IdP has no Single Logout URL configured") try: return auth.logout( return_to=return_to, name_id=name_id or None, session_index=session_index or None, ) except Exception as err: raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]: """Process a LogoutRequest / LogoutResponse received from the IdP. ``form`` holds the POSTed fields, ``query`` the GET parameters (the HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there). Returns ``(redirect_url, errors)``. """ from onelogin.saml2.auth import OneLogin_Saml2_Auth settings = build_settings(cfg, saml_endpoints(request)) https = "on" if external_base_url(request).startswith("https") else "off" post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")} if not post_data: post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {} req_data = { "https": https, "http_host": request.headers.get("host", "localhost:8080"), "script_name": "/auth/saml/logout", "request_uri": request.url.path, "query_string": str(request.url.query or ""), "get_data": dict(query), "post_data": post_data, } auth = OneLogin_Saml2_Auth(req_data, old_settings=settings) try: url = auth.process_slo(keep_local_session=True) except Exception as err: raise SAMLError(f"SAML logout could not be processed: {err}") from err return url, auth.get_errors()