mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
9 Commits
sdko/stage
...
saml-endpo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3d795cc2 | ||
|
|
2cd89b0ab0 | ||
|
|
1e68fc887a | ||
|
|
f60a441435 | ||
|
|
f207fdfed0 | ||
|
|
1137924e49 | ||
|
|
4cb40fee4b | ||
|
|
649a4e57c2 | ||
|
|
ca03d81bd9 |
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/",
|
||||
|
||||
118
authentik/providers/saml/views/unified.py
Normal file
118
authentik/providers/saml/views/unified.py
Normal 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)
|
||||
18
packages/client-ts/src/models/SAMLProvider.ts
generated
18
packages/client-ts/src/models/SAMLProvider.ts
generated
@@ -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"
|
||||
|
||||
10
schema.yml
10
schema.yml
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user