Compare commits

...

9 Commits

Author SHA1 Message Date
Connor Peshek
ef3d795cc2 clean up 2026-04-29 04:09:54 -05:00
Connor Peshek
2cd89b0ab0 update to main 2026-04-28 20:52:24 -05:00
Connor Peshek
1e68fc887a Merge branch 'main' into saml-endpoints 2026-03-12 21:44:21 -05:00
Connor Peshek
f60a441435 fix for tests when sp init login 2026-02-25 17:53:38 -06:00
Connor Peshek
f207fdfed0 Merge branch 'main' into saml-endpoints 2026-02-25 17:23:28 -06:00
Connor Peshek
1137924e49 Merge branch 'main' into saml-endpoints 2026-02-06 23:06:49 -06:00
Connor Peshek
4cb40fee4b fix saml parsing 2026-02-05 17:42:12 -06:00
Connor Peshek
649a4e57c2 Merge branch 'main' into saml-endpoints 2026-02-04 16:27:45 -06:00
Connor Peshek
ca03d81bd9 providers/saml: make unified saml endpoint 2026-02-04 15:57:12 -06:00
8 changed files with 221 additions and 84 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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):

View File

@@ -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(
"<slug:application_slug>/",
sso.SAMLSSOBindingRedirectView.as_view(),
unified.SAMLUnifiedView.as_view(),
name="base",
),
# IdP-initiated
path(
"<slug:application_slug>/init/",
sso.SAMLSSOBindingInitView.as_view(),
name="init",
),
# LEGACY Endpoints (backward compatibility)
# SSO Bindings
path(
"<slug:application_slug>/sso/binding/redirect/",

View File

@@ -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)

View File

@@ -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"

View File

@@ -53821,6 +53821,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
@@ -53860,6 +53868,8 @@ components:
- url_sso_init
- url_sso_post
- url_sso_redirect
- url_unified
- url_unified_init
- verbose_name
- verbose_name_plural
SAMLProviderImportRequest:

View File

@@ -391,28 +391,20 @@ export class SAMLProviderViewPage extends AKElement {
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SSO URL (Post)")}</span
>${msg("SAML Endpoint")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SSO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoRedirect)}"
value="${ifDefined(this.provider.urlUnified)}"
/>
<p class="pf-c-form__helper-text">
${msg(
"SAML provider endpoint. Use this URL for SP configuration.",
)}
</p>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
@@ -424,33 +416,7 @@ export class SAMLProviderViewPage extends AKElement {
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoInit)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Post)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloRedirect)}"
value="${ifDefined(this.provider.urlUnifiedInit)}"
/>
</div>
</form>