From fd6bf9fd05234aa2cf0d4e82cdac611562f8b890 Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 10:48:50 -0500 Subject: [PATCH] providers/saml: make unified saml endpoint (cherry-pick #20026 to version-2026.5) (#22187) providers/saml: make unified saml endpoint (#20026) * providers/saml: make unified saml endpoint Co-authored-by: Connor Peshek --- authentik/providers/saml/api/providers.py | 37 ++++++ authentik/providers/saml/models.py | 2 +- .../providers/saml/processors/metadata.py | 57 +++------ authentik/providers/saml/urls.py | 13 +- authentik/providers/saml/views/unified.py | 118 ++++++++++++++++++ packages/client-ts/src/models/SAMLProvider.ts | 18 +++ schema.yml | 10 ++ .../providers/saml/SAMLProviderViewPage.ts | 50 ++------ 8 files changed, 221 insertions(+), 84 deletions(-) create mode 100644 authentik/providers/saml/views/unified.py diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py index 72f8a43e50..bac8ce1a5a 100644 --- a/authentik/providers/saml/api/providers.py +++ b/authentik/providers/saml/api/providers.py @@ -61,6 +61,11 @@ class SAMLProviderSerializer(ProviderSerializer): url_download_metadata = SerializerMethodField() url_issuer = SerializerMethodField() + # Unified SAML endpoint (primary) + url_unified = SerializerMethodField() + url_unified_init = SerializerMethodField() + + # Legacy endpoints (for backward compatibility) url_sso_post = SerializerMethodField() url_sso_redirect = SerializerMethodField() url_sso_init = SerializerMethodField() @@ -107,6 +112,36 @@ class SAMLProviderSerializer(ProviderSerializer): except Provider.application.RelatedObjectDoesNotExist: return DEFAULT_ISSUER + def get_url_unified(self, instance: SAMLProvider) -> str: + """Get unified SAML endpoint URL (handles SSO and SLO)""" + if "request" not in self._context: + return "" + request: HttpRequest = self._context["request"]._request + try: + return request.build_absolute_uri( + reverse( + "authentik_providers_saml:base", + kwargs={"application_slug": instance.application.slug}, + ) + ) + except Provider.application.RelatedObjectDoesNotExist: + return "-" + + def get_url_unified_init(self, instance: SAMLProvider) -> str: + """Get IdP-initiated SAML URL""" + if "request" not in self._context: + return "" + request: HttpRequest = self._context["request"]._request + try: + return request.build_absolute_uri( + reverse( + "authentik_providers_saml:init", + kwargs={"application_slug": instance.application.slug}, + ) + ) + except Provider.application.RelatedObjectDoesNotExist: + return "-" + def get_url_sso_post(self, instance: SAMLProvider) -> str: """Get SSO Post URL""" if "request" not in self._context: @@ -243,6 +278,8 @@ class SAMLProviderSerializer(ProviderSerializer): "default_name_id_policy", "url_download_metadata", "url_issuer", + "url_unified", + "url_unified_init", "url_sso_post", "url_sso_redirect", "url_sso_init", diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py index f73534356b..7be7d8852e 100644 --- a/authentik/providers/saml/models.py +++ b/authentik/providers/saml/models.py @@ -241,7 +241,7 @@ class SAMLProvider(Provider): """Use IDP-Initiated SAML flow as launch URL""" try: return reverse( - "authentik_providers_saml:sso-init", + "authentik_providers_saml:init", kwargs={"application_slug": self.application.slug}, ) except Provider.application.RelatedObjectDoesNotExist: diff --git a/authentik/providers/saml/processors/metadata.py b/authentik/providers/saml/processors/metadata.py index 6a180d350b..bda36cf930 100644 --- a/authentik/providers/saml/processors/metadata.py +++ b/authentik/providers/saml/processors/metadata.py @@ -81,54 +81,35 @@ class MetadataProcessor: element.text = name_id_format yield element + def _get_unified_url(self) -> str: + """Get the unified SAML endpoint URL""" + return self.http_request.build_absolute_uri( + reverse( + "authentik_providers_saml:base", + kwargs={"application_slug": self.provider.application.slug}, + ) + ) + def get_sso_bindings(self) -> Iterator[Element]: - """Get all Bindings supported""" - binding_url_map = { - (SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri( - reverse( - "authentik_providers_saml:sso-redirect", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - (SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri( - reverse( - "authentik_providers_saml:sso-post", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - } - for binding_svc, url in binding_url_map.items(): - binding, svc = binding_svc + """Get all SSO Bindings - both point to unified endpoint""" + unified_url = self._get_unified_url() + for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]: if self.force_binding and self.force_binding != binding: continue - element = Element(f"{{{NS_SAML_METADATA}}}{svc}") + element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService") element.attrib["Binding"] = binding - element.attrib["Location"] = url + element.attrib["Location"] = unified_url yield element def get_slo_bindings(self) -> Iterator[Element]: - """Get all Bindings supported""" - binding_url_map = { - (SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri( - reverse( - "authentik_providers_saml:slo-redirect", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - (SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri( - reverse( - "authentik_providers_saml:slo-post", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - } - for binding_svc, url in binding_url_map.items(): - binding, svc = binding_svc + """Get all SLO Bindings - both point to unified endpoint""" + unified_url = self._get_unified_url() + for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]: if self.force_binding and self.force_binding != binding: continue - element = Element(f"{{{NS_SAML_METADATA}}}{svc}") + element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService") element.attrib["Binding"] = binding - element.attrib["Location"] = url + element.attrib["Location"] = unified_url yield element def _prepare_signature(self, entity_descriptor: _Element): diff --git a/authentik/providers/saml/urls.py b/authentik/providers/saml/urls.py index c56f1e22c2..e92c89d87e 100644 --- a/authentik/providers/saml/urls.py +++ b/authentik/providers/saml/urls.py @@ -4,19 +4,26 @@ from django.urls import path from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet from authentik.providers.saml.api.providers import SAMLProviderViewSet -from authentik.providers.saml.views import metadata, sso +from authentik.providers.saml.views import metadata, sso, unified from authentik.providers.saml.views.sp_slo import ( SPInitiatedSLOBindingPOSTView, SPInitiatedSLOBindingRedirectView, ) urlpatterns = [ - # Base path for Issuer/Entity ID + # Unified Endpoint - handles SSO and SLO based on message type path( "/", - sso.SAMLSSOBindingRedirectView.as_view(), + unified.SAMLUnifiedView.as_view(), name="base", ), + # IdP-initiated + path( + "/init/", + sso.SAMLSSOBindingInitView.as_view(), + name="init", + ), + # LEGACY Endpoints (backward compatibility) # SSO Bindings path( "/sso/binding/redirect/", diff --git a/authentik/providers/saml/views/unified.py b/authentik/providers/saml/views/unified.py new file mode 100644 index 0000000000..056df08ae3 --- /dev/null +++ b/authentik/providers/saml/views/unified.py @@ -0,0 +1,118 @@ +"""Unified SAML endpoint - handles SSO and SLO based on message type""" + +from base64 import b64decode + +from defusedxml.lxml import fromstring +from django.http import HttpRequest, HttpResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.clickjacking import xframe_options_sameorigin +from django.views.decorators.csrf import csrf_exempt +from structlog.stdlib import get_logger + +from authentik.common.saml.constants import NS_MAP +from authentik.flows.views.executor import SESSION_KEY_POST +from authentik.lib.views import bad_request_message +from authentik.providers.saml.utils.encoding import decode_base64_and_inflate +from authentik.providers.saml.views.flows import ( + REQUEST_KEY_SAML_REQUEST, + REQUEST_KEY_SAML_RESPONSE, +) +from authentik.providers.saml.views.sp_slo import ( + SPInitiatedSLOBindingPOSTView, + SPInitiatedSLOBindingRedirectView, +) +from authentik.providers.saml.views.sso import ( + SAMLSSOBindingPOSTView, + SAMLSSOBindingRedirectView, +) + +LOGGER = get_logger() + +# SAML message type constants +SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest" +SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest" + + +def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None: + """Parse SAML request to determine if AuthnRequest or LogoutRequest.""" + try: + if is_post_binding: + decoded_xml = b64decode(saml_request.encode()) + else: + decoded_xml = decode_base64_and_inflate(saml_request) + + root = fromstring(decoded_xml) + if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)): + return SAML_MESSAGE_TYPE_AUTHN_REQUEST + if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)): + return SAML_MESSAGE_TYPE_LOGOUT_REQUEST + return None + except Exception: # noqa: BLE001 + return None + + +@method_decorator(xframe_options_sameorigin, name="dispatch") +@method_decorator(csrf_exempt, name="dispatch") +class SAMLUnifiedView(View): + """Unified SAML endpoint - handles SSO and SLO based on message type. + + The operation type is determined by parsing + the incoming SAML message: + - AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView) + - LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView) + - LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView) + """ + + def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """Route the request based on SAML message type.""" + # ak user was not logged in, redirected to login, and is back w POST payload in session + if SESSION_KEY_POST in request.session: + return self._delegate_to_sso(request, application_slug, is_post_binding=True) + + # Determine binding from HTTP method + is_post_binding = request.method == "POST" + data = request.POST if is_post_binding else request.GET + + # LogoutResponse - delegate to SLO view (handles it in dispatch) + if REQUEST_KEY_SAML_RESPONSE in data: + return self._delegate_to_slo(request, application_slug, is_post_binding) + + # Check for SAML request + if REQUEST_KEY_SAML_REQUEST not in data: + LOGGER.info("SAML payload missing") + return bad_request_message(request, "The SAML request payload is missing.") + + # Detect message type and delegate + saml_request = data[REQUEST_KEY_SAML_REQUEST] + message_type = detect_saml_message_type(saml_request, is_post_binding) + + if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST: + return self._delegate_to_sso(request, application_slug, is_post_binding) + elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST: + return self._delegate_to_slo(request, application_slug, is_post_binding) + else: + LOGGER.warning("Unknown SAML message type", message_type=message_type) + return bad_request_message( + request, f"Unsupported SAML message type: {message_type or 'unknown'}" + ) + + def _delegate_to_sso( + self, request: HttpRequest, application_slug: str, is_post_binding: bool + ) -> HttpResponse: + """Delegate to the appropriate SSO view.""" + if is_post_binding: + view = SAMLSSOBindingPOSTView.as_view() + else: + view = SAMLSSOBindingRedirectView.as_view() + return view(request, application_slug=application_slug) + + def _delegate_to_slo( + self, request: HttpRequest, application_slug: str, is_post_binding: bool + ) -> HttpResponse: + """Delegate to the appropriate SLO view.""" + if is_post_binding: + view = SPInitiatedSLOBindingPOSTView.as_view() + else: + view = SPInitiatedSLOBindingRedirectView.as_view() + return view(request, application_slug=application_slug) diff --git a/packages/client-ts/src/models/SAMLProvider.ts b/packages/client-ts/src/models/SAMLProvider.ts index 7ee4d2af33..ff6506a90c 100644 --- a/packages/client-ts/src/models/SAMLProvider.ts +++ b/packages/client-ts/src/models/SAMLProvider.ts @@ -266,6 +266,18 @@ export interface SAMLProvider { * @memberof SAMLProvider */ readonly urlIssuer: string; + /** + * Get unified SAML endpoint URL (handles SSO and SLO) + * @type {string} + * @memberof SAMLProvider + */ + readonly urlUnified: string; + /** + * Get IdP-initiated SAML URL + * @type {string} + * @memberof SAMLProvider + */ + readonly urlUnifiedInit: string; /** * Get SSO Post URL * @type {string} @@ -328,6 +340,8 @@ export function instanceOfSAMLProvider(value: object): value is SAMLProvider { if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined) return false; if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false; + if (!("urlUnified" in value) || value["urlUnified"] === undefined) return false; + if (!("urlUnifiedInit" in value) || value["urlUnifiedInit"] === undefined) return false; if (!("urlSsoPost" in value) || value["urlSsoPost"] === undefined) return false; if (!("urlSsoRedirect" in value) || value["urlSsoRedirect"] === undefined) return false; if (!("urlSsoInit" in value) || value["urlSsoInit"] === undefined) return false; @@ -414,6 +428,8 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea : SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]), urlDownloadMetadata: json["url_download_metadata"], urlIssuer: json["url_issuer"], + urlUnified: json["url_unified"], + urlUnifiedInit: json["url_unified_init"], urlSsoPost: json["url_sso_post"], urlSsoRedirect: json["url_sso_redirect"], urlSsoInit: json["url_sso_init"], @@ -440,6 +456,8 @@ export function SAMLProviderToJSONTyped( | "meta_model_name" | "url_download_metadata" | "url_issuer" + | "url_unified" + | "url_unified_init" | "url_sso_post" | "url_sso_redirect" | "url_sso_init" diff --git a/schema.yml b/schema.yml index a6792331fc..8b5f6d5c4d 100644 --- a/schema.yml +++ b/schema.yml @@ -54328,6 +54328,14 @@ components: type: string description: Get Issuer/EntityID URL readOnly: true + url_unified: + type: string + description: Get unified SAML endpoint URL (handles SSO and SLO) + readOnly: true + url_unified_init: + type: string + description: Get IdP-initiated SAML URL + readOnly: true url_sso_post: type: string description: Get SSO Post URL @@ -54367,6 +54375,8 @@ components: - url_sso_init - url_sso_post - url_sso_redirect + - url_unified + - url_unified_init - verbose_name - verbose_name_plural SAMLProviderImportRequest: diff --git a/web/src/admin/providers/saml/SAMLProviderViewPage.ts b/web/src/admin/providers/saml/SAMLProviderViewPage.ts index a52f656d5f..70b6fff119 100644 --- a/web/src/admin/providers/saml/SAMLProviderViewPage.ts +++ b/web/src/admin/providers/saml/SAMLProviderViewPage.ts @@ -391,28 +391,20 @@ export class SAMLProviderViewPage extends AKElement {
-
-
- - +

+ ${msg( + "SAML provider endpoint. Use this URL for SP configuration.", + )} +

-
- - -
-
- -