Compare commits

...

16 Commits

Author SHA1 Message Date
Connor Peshek
cf1392a89e alter how relaystate is handled 2026-04-29 04:51:09 -05:00
Connor Peshek
69a86cf258 update to main 2026-04-29 04:30:25 -05:00
Connor Peshek
f79b1ba41e fix to set sign logout request to true by default 2026-03-12 23:52:29 -05:00
Connor Peshek
772db03b4b fix merge and lint 2026-03-11 14:03:55 -05:00
Connor Peshek
724f3cc59c merge main fix conflicts 2026-03-11 13:54:32 -05:00
Connor Peshek
99bf2ac131 make sp init saml native logout work with this flow 2026-02-11 17:57:37 -06:00
Connor Peshek
dc9b302628 merge main and clean up imports 2026-02-11 14:49:32 -06:00
Connor Peshek
764e7a520c clean up shared exceptions 2026-02-11 05:23:21 -06:00
Connor Peshek
02e3baa84d fix order so full single logout works when sp init happens when authentik is idp and sp 2026-02-11 05:18:26 -06:00
Connor Peshek
46f17d23e9 fix imports 2026-02-11 04:53:30 -06:00
Connor Peshek
3f832913dc make work 2026-02-11 04:36:37 -06:00
Connor Peshek
f449335ad1 move parsers to common 2026-02-11 03:49:18 -06:00
Connor Peshek
4215e76b74 clean up logout firing and order 2026-02-10 23:53:01 -06:00
Connor Peshek
ca63ee0142 move logoutrequest parser to its own file 2026-02-10 23:30:37 -06:00
Connor Peshek
63326b22bd broadcast post in metadata and clean up 2026-02-09 19:27:49 -06:00
Connor Peshek
8e3cff2769 sources/saml: add sp init frontchannel logout 2026-02-09 17:35:55 -06:00
34 changed files with 1282 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
"""authentik SAML IDP Exceptions"""
"""Common SAML Exceptions"""
from authentik.lib.sentry import SentryIgnoredException

View File

@@ -1,4 +1,4 @@
"""LogoutRequest parser"""
"""Shared SAML LogoutRequest parser"""
from base64 import b64decode
from dataclasses import dataclass
@@ -6,41 +6,29 @@ from dataclasses import dataclass
from defusedxml import ElementTree
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
@dataclass(slots=True)
class LogoutRequest:
"""Logout Request"""
"""Parsed SAML LogoutRequest"""
id: str | None = None
issuer: str | None = None
name_id: str | None = None
name_id_format: str | None = None
session_index: str | None = None
relay_state: str | None = None
class LogoutRequestParser:
"""LogoutRequest Parser"""
provider: SAMLProvider
def __init__(self, provider: SAMLProvider):
self.provider = provider
"""Parse incoming SAML LogoutRequest messages"""
def _parse_xml(self, decoded_xml: str | bytes, relay_state: str | None = None) -> LogoutRequest:
root = ElementTree.fromstring(decoded_xml)
request = LogoutRequest(
id=root.attrib["ID"],
id=root.attrib.get("ID"),
)
# Try both namespaces for Issuer
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
@@ -55,7 +43,6 @@ class LogoutRequestParser:
name_ids = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameID")
if len(name_ids) > 0:
request.name_id = name_ids[0].text
# Extract NameID Format if present
if "Format" in name_ids[0].attrib:
request.name_id_format = name_ids[0].attrib["Format"]
@@ -70,22 +57,17 @@ class LogoutRequestParser:
return request
def parse(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
"""Validate and parse raw request with enveloped signautre."""
"""Parse a POST-binding LogoutRequest (base64 encoded)."""
try:
decoded_xml = b64decode(saml_request.encode())
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request") from None
return self._parse_xml(decoded_xml, relay_state)
def parse_detached(
self,
saml_request: str,
relay_state: str | None = None,
) -> LogoutRequest:
"""Validate and parse raw request with detached signature"""
def parse_detached(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
"""Parse a Redirect-binding LogoutRequest (deflate + base64 encoded)."""
try:
decoded_xml = decode_base64_and_inflate(saml_request)
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request") from None
return self._parse_xml(decoded_xml, relay_state)

View File

@@ -0,0 +1,43 @@
"""Shared SAML LogoutResponse parser"""
from defusedxml.lxml import fromstring
from lxml.etree import _Element # nosec
from structlog.stdlib import get_logger
from authentik.common.saml.constants import NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
LOGGER = get_logger()
class LogoutResponseParser:
"""Parse and validate SAML LogoutResponse messages"""
_root: _Element
def __init__(self, raw_response: str):
self._raw_response = raw_response
def parse(self):
"""Decode and parse the LogoutResponse XML."""
# decode_base64_and_inflate handles both deflate-compressed (Redirect binding)
# and plain base64 (POST binding) responses
response_xml = decode_base64_and_inflate(self._raw_response)
self._root = fromstring(response_xml.encode())
def verify_status(self) -> bool:
"""Check LogoutResponse status. Returns True if status is Success."""
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
if status is None:
return True
status_code = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
if status_code is None:
return True
status_value = status_code.attrib.get("Value", "")
if status_value != SAML_STATUS_SUCCESS:
LOGGER.warning(
"LogoutResponse status is not Success",
status=status_value,
)
return False
return True

View File

@@ -19,8 +19,8 @@ from authentik.common.saml.constants import (
RSA_SHA512,
SAML_NAME_ID_FORMAT_UNSPECIFIED,
)
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.lib.xml import lxml_from_string
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.sources.saml.models import SAMLNameIDPolicy

View File

@@ -15,8 +15,8 @@ from authentik.common.saml.constants import (
NS_SAML_PROTOCOL,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
from authentik.common.saml.parsers.logout_request import LogoutRequest
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
from authentik.providers.saml.utils.time import get_time_string

View File

@@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model
from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.common.saml.parsers.logout_request import LogoutRequest
from authentik.events.models import Event, EventAction
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
LOGGER = get_logger()

View File

@@ -8,10 +8,10 @@ from authentik.common.saml.constants import (
RSA_SHA256,
SAML_NAME_ID_FORMAT_EMAIL,
)
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
class TestLogoutIntegration(TestCase):
@@ -46,7 +46,7 @@ class TestLogoutIntegration(TestCase):
)
# Create parser for validation
self.parser = LogoutRequestParser(self.provider)
self.parser = LogoutRequestParser()
def test_post_binding_roundtrip(self):
"""Test that a POST-encoded request can be parsed correctly"""
@@ -100,7 +100,7 @@ class TestLogoutIntegration(TestCase):
encoded = processor.encode_post()
# Create parser with verification enabled
parser = LogoutRequestParser(self.provider)
parser = LogoutRequestParser()
# Parse it - this would validate signature if verification is enabled
parsed = parser.parse(encoded)

View File

@@ -4,9 +4,9 @@ from django.test import TestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_TRANSIENT
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
from authentik.sources.saml.models import SAMLSource
GET_LOGOUT_REQUEST = (
@@ -51,7 +51,7 @@ class TestLogoutRequest(TestCase):
def test_static_get(self):
"""Test static LogoutRequest"""
request = LogoutRequestParser(self.provider).parse_detached(GET_LOGOUT_REQUEST)
request = LogoutRequestParser().parse_detached(GET_LOGOUT_REQUEST)
self.assertEqual(request.id, "id-2ea1b01f69363ac95e3da4a15409b9d8ec525944")
self.assertEqual(request.issuer, "saml-test-sp")
# The GET request has an empty NameID element with transient format
@@ -60,7 +60,7 @@ class TestLogoutRequest(TestCase):
def test_static_post(self):
"""Test static LogoutRequest"""
request = LogoutRequestParser(self.provider).parse(POST_LOGOUT_REQUEST)
request = LogoutRequestParser().parse(POST_LOGOUT_REQUEST)
self.assertEqual(request.id, "id-b8f4fd51ed4106f1e782b95d51d9ad3f385e5816")
self.assertEqual(request.issuer, "saml-test-sp")
# The POST request has an empty NameID element with transient format

View File

@@ -9,11 +9,11 @@ from authentik.common.saml.constants import (
NS_SAML_PROTOCOL,
NS_SIGNATURE,
)
from authentik.common.saml.parsers.logout_request import LogoutRequest
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
from authentik.providers.saml.processors.metadata import MetadataProcessor

View File

@@ -7,11 +7,11 @@ from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
from authentik.providers.saml.views.flows import (

View File

@@ -7,6 +7,8 @@ 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.exceptions import CannotHandleAssertion
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.core.models import Application, AuthenticatedSession
from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage
@@ -16,7 +18,6 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import (
SAMLBindings,
SAMLLogoutMethods,
@@ -24,7 +25,6 @@ from authentik.providers.saml.models import (
SAMLSession,
)
from authentik.providers.saml.native_logout import NativeLogoutStageView
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
from authentik.providers.saml.tasks import send_saml_logout_response
from authentik.providers.saml.utils.encoding import nice64
@@ -251,7 +251,7 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
return bad_request_message(self.request, "The SAML request payload is missing.")
try:
logout_request = LogoutRequestParser(self.provider).parse_detached(
logout_request = LogoutRequestParser().parse_detached(
self.request.GET[REQUEST_KEY_SAML_REQUEST],
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
)
@@ -295,7 +295,7 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
return bad_request_message(self.request, "The SAML request payload is missing.")
try:
logout_request = LogoutRequestParser(self.provider).parse(
logout_request = LogoutRequestParser().parse(
payload[REQUEST_KEY_SAML_REQUEST],
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
)

View File

@@ -8,6 +8,7 @@ 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.exceptions import CannotHandleAssertion
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
@@ -16,7 +17,6 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
from authentik.providers.saml.views.flows import (

View File

@@ -43,6 +43,7 @@ class SAMLSourceSerializer(SourceSerializer):
"force_authn",
"name_id_policy",
"binding_type",
"slo_binding",
"verification_kp",
"signing_kp",
"digest_algorithm",
@@ -51,6 +52,8 @@ class SAMLSourceSerializer(SourceSerializer):
"encryption_kp",
"signed_assertion",
"signed_response",
"sign_authn_request",
"sign_logout_request",
]
@@ -78,6 +81,7 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
"force_authn",
"name_id_policy",
"binding_type",
"slo_binding",
"verification_kp",
"signing_kp",
"digest_algorithm",
@@ -85,6 +89,8 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
"temporary_user_delete_after",
"signed_assertion",
"signed_response",
"sign_authn_request",
"sign_logout_request",
]
search_fields = ["name", "slug"]
ordering = ["name"]

View File

@@ -0,0 +1,101 @@
# Generated by Django 5.2.11 on 2026-02-09 22:34
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
("authentik_sources_saml", "0021_samlsource_signed_assertion_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="samlsource",
name="sign_authn_request",
field=models.BooleanField(
default=True,
help_text="Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
verbose_name="Sign AuthnRequest",
),
),
migrations.AddField(
model_name="samlsource",
name="sign_logout_request",
field=models.BooleanField(
default=True,
help_text="Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
verbose_name="Sign LogoutRequest",
),
),
migrations.AddField(
model_name="samlsource",
name="slo_binding",
field=models.CharField(
choices=[("REDIRECT", "Redirect Binding"), ("POST", "POST Binding")],
default="REDIRECT",
help_text="Binding type for Single Logout requests to the IdP.",
max_length=100,
verbose_name="SLO Binding",
),
),
migrations.CreateModel(
name="SAMLSourceSession",
fields=[
(
"saml_session_id",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
(
"session_index",
models.TextField(
blank=True,
default="",
help_text="SAML SessionIndex from the IdP's AuthnStatement",
),
),
("name_id", models.TextField(help_text="SAML NameID value for this session")),
(
"name_id_format",
models.TextField(blank=True, default="", help_text="SAML NameID format"),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"session",
models.ForeignKey(
help_text="Link to the user's authenticated session",
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_sources_saml.samlsource",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "SAML Source Session",
"verbose_name_plural": "SAML Source Sessions",
"indexes": [
models.Index(fields=["source", "user"], name="authentik_s_source__abd088_idx"),
models.Index(fields=["session"], name="authentik_s_session_054d2d_idx"),
],
},
),
]

View File

@@ -1,6 +1,7 @@
"""saml sp models"""
from typing import Any
from uuid import uuid4
from django.db import models
from django.http import HttpRequest
@@ -36,9 +37,11 @@ from authentik.common.saml.constants import (
SHA512,
)
from authentik.core.models import (
AuthenticatedSession,
GroupSourceConnection,
PropertyMapping,
Source,
User,
UserSourceConnection,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
@@ -78,6 +81,13 @@ class SAMLNameIDPolicy(models.TextChoices):
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
class SAMLSLOBindingTypes(models.TextChoices):
"""SAML SLO Binding types"""
REDIRECT = "REDIRECT", _("Redirect Binding")
POST = "POST", _("POST Binding")
class SAMLSource(Source):
"""Authenticate using an external SAML Identity Provider."""
@@ -134,6 +144,28 @@ class SAMLSource(Source):
choices=SAMLBindingTypes.choices,
default=SAMLBindingTypes.REDIRECT,
)
slo_binding = models.CharField(
max_length=100,
choices=SAMLSLOBindingTypes.choices,
default=SAMLSLOBindingTypes.REDIRECT,
verbose_name=_("SLO Binding"),
help_text=_("Binding type for Single Logout requests to the IdP."),
)
sign_authn_request = models.BooleanField(
default=True,
verbose_name=_("Sign AuthnRequest"),
help_text=_(
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
),
)
sign_logout_request = models.BooleanField(
default=True,
verbose_name=_("Sign LogoutRequest"),
help_text=_(
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
),
)
temporary_user_delete_after = models.TextField(
default="days=1",
@@ -355,3 +387,39 @@ class GroupSAMLSourceConnection(GroupSourceConnection):
class Meta:
verbose_name = _("Group SAML Source Connection")
verbose_name_plural = _("Group SAML Source Connections")
class SAMLSourceSession(models.Model):
"""Track active SAML source sessions for Single Logout support"""
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
source = models.ForeignKey(SAMLSource, on_delete=models.CASCADE)
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
session = models.ForeignKey(
AuthenticatedSession,
on_delete=models.CASCADE,
help_text=_("Link to the user's authenticated session"),
)
session_index = models.TextField(
default="",
blank=True,
help_text=_("SAML SessionIndex from the IdP's AuthnStatement"),
)
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
name_id_format = models.TextField(
default="",
blank=True,
help_text=_("SAML NameID format"),
)
created = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("SAML Source Session")
verbose_name_plural = _("SAML Source Sessions")
indexes = [
models.Index(fields=["source", "user"]),
models.Index(fields=["session"]),
]
def __str__(self):
return f"SAML Source Session for source {self.source_id} and user {self.user_id}"

View File

@@ -0,0 +1,210 @@
"""SAML Source LogoutRequest Processor"""
import base64
from urllib.parse import quote, urlencode
import xmlsec
from django.http import HttpRequest
from lxml import etree # nosec
from lxml.etree import Element, _Element
from authentik.common.saml.constants import (
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_NAME_ID_FORMAT_EMAIL,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
from authentik.lib.xml import remove_xml_newlines
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
from authentik.providers.saml.utils.time import get_time_string
from authentik.sources.saml.models import SAMLSource
class LogoutRequestProcessor:
"""Generate SAML LogoutRequest messages for SP-initiated logout"""
source: SAMLSource
http_request: HttpRequest
destination: str
name_id: str
name_id_format: str
session_index: str
relay_state: str | None
_issue_instant: str
_request_id: str
def __init__(
self,
source: SAMLSource,
http_request: HttpRequest,
destination: str,
name_id: str,
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
session_index: str = "",
relay_state: str | None = None,
):
self.source = source
self.http_request = http_request
self.destination = destination
self.name_id = name_id
self.name_id_format = name_id_format
self.session_index = session_index
self.relay_state = relay_state
self._issue_instant = get_time_string()
self._request_id = get_random_id()
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.source.get_issuer(self.http_request)
return issuer
def get_name_id(self) -> Element:
"""Get NameID element"""
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
name_id.attrib["Format"] = self.name_id_format
name_id.text = self.name_id
return name_id
def build(self) -> Element:
"""Build a SAML LogoutRequest as etree Element"""
logout_request = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutRequest", nsmap=NS_MAP)
logout_request.attrib["ID"] = self._request_id
logout_request.attrib["Version"] = "2.0"
logout_request.attrib["IssueInstant"] = self._issue_instant
logout_request.attrib["Destination"] = self.destination
logout_request.append(self.get_issuer())
logout_request.append(self.get_name_id())
if self.session_index:
session_index_element = Element(f"{{{NS_SAML_PROTOCOL}}}SessionIndex")
session_index_element.text = self.session_index
logout_request.append(session_index_element)
return logout_request
def encode_post(self) -> str:
"""Encode LogoutRequest for POST binding"""
logout_request = self.build()
if self.source.signing_kp and self.source.sign_logout_request:
self._sign_logout_request(logout_request)
return base64.b64encode(etree.tostring(logout_request)).decode()
def encode_redirect(self) -> str:
"""Encode LogoutRequest for Redirect binding"""
logout_request = self.build()
xml_str = etree.tostring(logout_request, encoding="UTF-8", xml_declaration=True)
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
def get_redirect_url(self) -> str:
"""Build complete logout URL for redirect binding with signature if needed"""
encoded_request = self.encode_redirect()
params = {
"SAMLRequest": encoded_request,
}
if self.relay_state:
params["RelayState"] = self.relay_state
if self.source.signing_kp and self.source.sign_logout_request:
sig_alg = self.source.signature_algorithm
params["SigAlg"] = sig_alg
query_string = self._build_signable_query_string(params)
signature = self._sign_query_string(query_string)
params["Signature"] = base64.b64encode(signature).decode()
separator = "&" if "?" in self.destination else "?"
return f"{self.destination}{separator}{urlencode(params)}"
def get_post_form_data(self) -> dict:
"""Get form data for POST binding"""
return {
"SAMLRequest": self.encode_post(),
"RelayState": self.relay_state or "",
}
def _sign_logout_request(self, logout_request: _Element):
"""Sign the LogoutRequest element"""
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)
signature = xmlsec.template.create(
logout_request,
xmlsec.constants.TransformExclC14N,
signature_algorithm_transform,
ns=xmlsec.constants.DSigNs,
)
issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
if issuer is not None:
issuer.addnext(signature)
else:
logout_request.insert(0, signature)
self._sign(logout_request)
def _sign(self, element: _Element):
"""Sign an XML element based on the source's configured signing settings"""
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
self.source.digest_algorithm, xmlsec.constants.TransformSha1
)
xmlsec.tree.add_ids(element, ["ID"])
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
ref = xmlsec.template.add_reference(
signature_node,
digest_algorithm_transform,
uri="#" + element.attrib["ID"],
)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
key_info = xmlsec.template.ensure_key_info(signature_node)
xmlsec.template.add_x509_data(key_info)
ctx = xmlsec.SignatureContext()
key = xmlsec.Key.from_memory(
self.source.signing_kp.key_data,
xmlsec.constants.KeyDataFormatPem,
None,
)
key.load_cert_from_memory(
self.source.signing_kp.certificate_data,
xmlsec.constants.KeyDataFormatCertPem,
)
ctx.key = key
ctx.sign(remove_xml_newlines(element, signature_node))
def _build_signable_query_string(self, params: dict) -> str:
"""Build query string for signing (order matters per SAML spec)"""
ordered = []
if "SAMLRequest" in params:
ordered.append(f"SAMLRequest={quote(params['SAMLRequest'], safe='')}")
if "RelayState" in params:
ordered.append(f"RelayState={quote(params['RelayState'], safe='')}")
if "SigAlg" in params:
ordered.append(f"SigAlg={quote(params['SigAlg'], safe='')}")
return "&".join(ordered)
def _sign_query_string(self, query_string: str) -> bytes:
"""Sign the query string for redirect binding"""
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha256
)
key = xmlsec.Key.from_memory(
self.source.signing_kp.key_data,
xmlsec.constants.KeyDataFormatPem,
None,
)
ctx = xmlsec.SignatureContext()
ctx.key = key
return ctx.sign_binary(query_string.encode("utf-8"), signature_algorithm_transform)

View File

@@ -0,0 +1,96 @@
"""SAML Source LogoutResponse Builder"""
import base64
from urllib.parse import urlencode
from django.http import HttpRequest
from lxml import etree # nosec
from lxml.etree import Element
from authentik.common.saml.constants import (
NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_STATUS_SUCCESS,
)
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
from authentik.providers.saml.utils.time import get_time_string
from authentik.sources.saml.models import SAMLSource
class LogoutResponseBuilder:
"""Build SAML LogoutResponse messages for IdP-initiated logout"""
source: SAMLSource
http_request: HttpRequest
destination: str
in_response_to: str
_issue_instant: str
_response_id: str
def __init__(
self,
source: SAMLSource,
http_request: HttpRequest,
destination: str,
in_response_to: str,
):
self.source = source
self.http_request = http_request
self.destination = destination
self.in_response_to = in_response_to
self._issue_instant = get_time_string()
self._response_id = get_random_id()
def build(self) -> Element:
"""Build a SAML LogoutResponse as etree Element"""
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
response.attrib["ID"] = self._response_id
response.attrib["Version"] = "2.0"
response.attrib["IssueInstant"] = self._issue_instant
response.attrib["Destination"] = self.destination
response.attrib["InResponseTo"] = self.in_response_to
# Issuer
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.source.get_issuer(self.http_request)
response.append(issuer)
# Status
status = Element(f"{{{NS_SAML_PROTOCOL}}}Status")
status_code = Element(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
status_code.attrib["Value"] = SAML_STATUS_SUCCESS
status.append(status_code)
response.append(status)
return response
def encode_post(self) -> str:
"""Encode LogoutResponse for POST binding"""
response = self.build()
return base64.b64encode(etree.tostring(response)).decode()
def encode_redirect(self) -> str:
"""Encode LogoutResponse for Redirect binding"""
response = self.build()
xml_str = etree.tostring(response, encoding="UTF-8", xml_declaration=True)
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
def get_redirect_url(self, relay_state: str | None = None) -> str:
"""Build complete URL for redirect binding"""
encoded = self.encode_redirect()
params = {"SAMLResponse": encoded}
if relay_state:
params["RelayState"] = relay_state
separator = "&" if "?" in self.destination else "?"
return f"{self.destination}{separator}{urlencode(params)}"
def get_post_form_data(self, relay_state: str | None = None) -> dict:
"""Get form data for POST binding"""
data = {"SAMLResponse": self.encode_post()}
if relay_state:
data["RelayState"] = relay_state
return data

View File

@@ -8,6 +8,7 @@ from authentik.common.saml.constants import (
NS_SAML_METADATA,
NS_SIGNATURE,
SAML_BINDING_POST,
SAML_BINDING_REDIRECT,
)
from authentik.providers.saml.utils.encoding import strip_pem_header
from authentik.sources.saml.models import SAMLSource
@@ -75,6 +76,19 @@ class MetadataProcessor:
if encryption_descriptor is not None:
sp_sso_descriptor.append(encryption_descriptor)
if self.source.slo_url:
slo_location = self.source.build_full_url(self.http_request, view="slo")
slo_redirect = SubElement(
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService"
)
slo_redirect.attrib["Binding"] = SAML_BINDING_REDIRECT
slo_redirect.attrib["Location"] = slo_location
slo_post = SubElement(sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService")
slo_post.attrib["Binding"] = SAML_BINDING_POST
slo_post.attrib["Location"] = slo_location
sp_sso_descriptor.append(self.get_name_id_format())
assertion_consumer_service = SubElement(

View File

@@ -72,7 +72,11 @@ class RequestProcessor:
# Create issuer object
auth_n_request.append(self.get_issuer())
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
if (
self.source.signing_kp
and self.source.sign_authn_request
and self.source.binding_type != SAMLBindingTypes.REDIRECT
):
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)
@@ -93,7 +97,11 @@ class RequestProcessor:
(used for POST Bindings)"""
auth_n_request = self.get_auth_n()
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
if (
self.source.signing_kp
and self.source.sign_authn_request
and self.source.binding_type != SAMLBindingTypes.REDIRECT
):
xmlsec.tree.add_ids(auth_n_request, ["ID"])
ctx = xmlsec.SignatureContext()
@@ -141,7 +149,7 @@ class RequestProcessor:
if self.relay_state != "":
response_dict["RelayState"] = self.relay_state
if self.source.signing_kp:
if self.source.signing_kp and self.source.sign_authn_request:
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)

View File

@@ -42,12 +42,9 @@ from authentik.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from authentik.sources.saml.models import (
GroupSAMLSourceConnection,
SAMLSource,
UserSAMLSourceConnection,
)
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
from authentik.sources.saml.stages import PLAN_CONTEXT_SAML_SESSION_DATA, SAMLSourceFlowManager
LOGGER = get_logger()
if TYPE_CHECKING:
@@ -240,6 +237,7 @@ class ResponseProcessor:
UserSAMLSourceConnection.objects.create(
source=self._source, user=user, identifier=name_id.text
)
session_index = self._get_session_index()
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
@@ -249,9 +247,25 @@ class ResponseProcessor:
"assertion": self.get_assertion(),
"name_id": name_id,
},
policy_context={},
policy_context={
PLAN_CONTEXT_SAML_SESSION_DATA: {
"session_index": session_index or "",
"name_id": name_id.text,
"name_id_format": name_id.attrib.get("Format", ""),
},
},
)
def _get_session_index(self) -> str | None:
"""Get SessionIndex from AuthnStatement element"""
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
if assertion is None:
return None
authn_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
if authn_statement is None:
return None
return authn_statement.attrib.get("SessionIndex")
def get_assertion(self) -> Element | None:
"""Get assertion element, if we have a signed assertion"""
if self._assertion is not None:
@@ -307,6 +321,7 @@ class ResponseProcessor:
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
return self._handle_name_id_transient()
session_index = self._get_session_index()
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
@@ -318,12 +333,10 @@ class ResponseProcessor:
},
policy_context={
"saml_response": etree.tostring(self._root),
PLAN_CONTEXT_SAML_SESSION_DATA: {
"session_index": session_index or "",
"name_id": name_id.text,
"name_id_format": name_id.attrib.get("Format", ""),
},
},
)
class SAMLSourceFlowManager(SourceFlowManager):
"""Source flow manager for SAML Sources"""
user_connection_type = UserSAMLSourceConnection
group_connection_type = GroupSAMLSourceConnection

View File

@@ -3,12 +3,40 @@
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.http import HttpRequest
from django.urls import reverse
from structlog.stdlib import get_logger
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, AuthenticatedSession, User
from authentik.flows.challenge import PLAN_CONTEXT_ATTRS, PLAN_CONTEXT_TITLE, PLAN_CONTEXT_URL
from authentik.flows.models import in_memory_stage
from authentik.flows.stage import RedirectStage, SessionEndStage
from authentik.flows.views.executor import FlowExecutorView
from authentik.providers.saml.native_logout import NativeLogoutStageView
from authentik.sources.saml.models import SAMLSLOBindingTypes, SAMLSourceSession
from authentik.sources.saml.processors.logout_request import LogoutRequestProcessor
from authentik.sources.saml.views import PLAN_CONTEXT_SAML_RELAY_STATE, AutosubmitStageView
from authentik.stages.user_logout.models import UserLogoutStage
from authentik.stages.user_logout.stage import flow_pre_user_logout
LOGGER = get_logger()
# Stages that redirect the user away from authentik. Source SLO stages must be
# inserted before these so they have a chance to execute.
TERMINAL_STAGE_VIEWS = {SessionEndStage, NativeLogoutStageView}
def _insert_before_terminal_stage(plan, stage):
"""Insert a stage before any terminal stage (SessionEndStage, NativeLogoutStageView)
in the plan. Falls back to append if no terminal stage is found."""
for i, binding in enumerate(plan.bindings):
try:
if binding.stage.view in TERMINAL_STAGE_VIEWS:
plan.insert_stage(stage, index=i)
return
except NotImplementedError:
continue
plan.append_stage(stage)
@receiver(user_logged_out)
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
@@ -18,3 +46,89 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
LOGGER.debug("Deleted temporary user", user=user)
user.delete()
@receiver(flow_pre_user_logout)
def handle_saml_source_pre_user_logout(
sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
):
"""Handle SAML source SP-initiated SLO when user logs out via flow.
Injects a stage into the logout flow to redirect the user to the IdP's SLO URL."""
if not isinstance(executor.current_stage, UserLogoutStage):
return
if not user.is_authenticated:
return
auth_session = AuthenticatedSession.from_request(request, user)
if not auth_session:
return
# Find SAMLSourceSessions for this user's current session
saml_source_sessions = SAMLSourceSession.objects.filter(
session=auth_session,
user=user,
).select_related("source")
for saml_session in saml_source_sessions:
source = saml_session.source
if not source.slo_url or not source.enabled:
continue
try:
# Use the flow executor URL as relay_state so that after the IdP
# processes the LogoutRequest and sends a LogoutResponse, the user
# is redirected back to the flow to continue remaining stages.
relay_state = request.build_absolute_uri(
reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": executor.flow.slug},
)
)
# Stash the outbound relay_state so the SLOView can redirect to a
# server-known value rather than trusting the echoed request param.
executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
processor = LogoutRequestProcessor(
source=source,
http_request=request,
destination=source.slo_url,
name_id=saml_session.name_id,
name_id_format=saml_session.name_id_format,
session_index=saml_session.session_index,
relay_state=relay_state,
)
# Insert before terminal stages (SessionEndStage, NativeLogoutStageView)
# so the SLO redirect runs before the flow ends or the user is
# redirected away. Provider logout stages (at index 1/2) still run
# first since they're inserted earlier.
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
redirect_url = processor.get_redirect_url()
stage = in_memory_stage(RedirectStage, destination=redirect_url)
else:
# POST binding
form_data = processor.get_post_form_data()
executor.plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
executor.plan.context[PLAN_CONTEXT_URL] = source.slo_url
executor.plan.context[PLAN_CONTEXT_ATTRS] = form_data
stage = in_memory_stage(AutosubmitStageView)
_insert_before_terminal_stage(executor.plan, stage)
LOGGER.debug(
"Injected SAML source SLO into logout flow",
source=source.name,
binding=source.slo_binding,
)
except (KeyError, AttributeError) as exc:
LOGGER.warning(
"Failed to generate SAML source logout request",
source=source.name,
exc=exc,
)
# Clean up SAMLSourceSessions for this auth session
saml_source_sessions.delete()

View File

@@ -0,0 +1,71 @@
"""SAML Source stages and flow manager"""
from django.http import HttpRequest, HttpResponse
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User
from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.core.sources.stage import PostSourceStage
from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
from authentik.sources.saml.models import (
GroupSAMLSourceConnection,
SAMLSource,
SAMLSourceSession,
UserSAMLSourceConnection,
)
LOGGER = get_logger()
PLAN_CONTEXT_SAML_SESSION_DATA = "saml_session_data"
class SAMLPostSourceStage(PostSourceStage):
"""Extends PostSourceStage to also create SAMLSourceSession for SLO support."""
def dispatch(self, request: HttpRequest) -> HttpResponse:
response = super().dispatch(request)
session_data = self.executor.plan.context.get(PLAN_CONTEXT_SAML_SESSION_DATA)
if not session_data:
return response
source = self.executor.plan.context.get(PLAN_CONTEXT_SOURCE)
if not isinstance(source, SAMLSource):
return response
user: User = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user or not user.pk:
return response
auth_session = AuthenticatedSession.from_request(request, user)
if not auth_session:
return response
SAMLSourceSession.objects.create(
source=source,
user=user,
session=auth_session,
session_index=session_data.get("session_index", ""),
name_id=session_data.get("name_id", ""),
name_id_format=session_data.get("name_id_format", ""),
)
LOGGER.debug(
"Created SAMLSourceSession",
source=source.name,
user=user,
session_index=session_data.get("session_index", ""),
)
return response
class SAMLSourceFlowManager(SourceFlowManager):
"""Source flow manager for SAML Sources"""
user_connection_type = UserSAMLSourceConnection
group_connection_type = GroupSAMLSourceConnection
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
return [
in_memory_stage(SAMLPostSourceStage),
]

View File

@@ -3,7 +3,6 @@
from urllib.parse import parse_qsl, urlparse, urlunparse
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpRequest, HttpResponse
from django.http.response import HttpResponseBadRequest
@@ -13,9 +12,13 @@ from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from lxml import etree # nosec
from structlog.stdlib import get_logger
from xmlsec import InternalError, VerificationError
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
from authentik.flows.challenge import (
PLAN_CONTEXT_ATTRS,
PLAN_CONTEXT_TITLE,
@@ -33,7 +36,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
from authentik.flows.stage import ChallengeStageView
from authentik.flows.stage import ChallengeStageView, RedirectStage, SessionEndStage
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import nice64
@@ -44,7 +47,13 @@ from authentik.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
from authentik.sources.saml.models import (
SAMLBindingTypes,
SAMLSLOBindingTypes,
SAMLSource,
SAMLSourceSession,
)
from authentik.sources.saml.processors.logout_response import LogoutResponseBuilder
from authentik.sources.saml.processors.metadata import MetadataProcessor
from authentik.sources.saml.processors.request import RequestProcessor
from authentik.sources.saml.processors.response import ResponseProcessor
@@ -52,6 +61,8 @@ from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentS
LOGGER = get_logger()
PLAN_CONTEXT_SAML_RELAY_STATE = "goauthentik.io/sources/saml/relay_state"
class AutosubmitStageView(ChallengeStageView):
"""Wrapper stage to create an autosubmit challenge from plan context variables"""
@@ -181,16 +192,195 @@ class ACSView(View):
return bad_request_message(request, str(exc))
class SLOView(LoginRequiredMixin, View):
"""Single-Logout-View"""
@method_decorator(csrf_exempt, name="dispatch")
class SLOView(View):
"""Single-Logout-View: handles SP-initiated SLO, IdP-initiated LogoutRequest,
and LogoutResponse from IdP"""
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Log user out and redirect them to the IdP's SLO URL."""
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Handle GET requests: LogoutResponse, LogoutRequest, or initiate SLO."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
logout(request)
return redirect(source.slo_url)
if "SAMLResponse" in request.GET:
return self._handle_logout_response(
request,
request.GET["SAMLResponse"],
relay_state=request.GET.get("RelayState"),
)
if "SAMLRequest" in request.GET:
return self._handle_logout_request(
request, source, request.GET["SAMLRequest"], is_post=False
)
# No SAML message, initiate SP-initiated SLO
return self._initiate_logout(request)
def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Handle POST requests: LogoutResponse or LogoutRequest from the IdP."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
if "SAMLResponse" in request.POST:
return self._handle_logout_response(
request,
request.POST["SAMLResponse"],
relay_state=request.POST.get("RelayState"),
)
if "SAMLRequest" in request.POST:
return self._handle_logout_request(
request, source, request.POST["SAMLRequest"], is_post=True
)
return bad_request_message(request, "Missing SAMLRequest or SAMLResponse")
def _initiate_logout(self, request: HttpRequest) -> HttpResponse:
"""Initiate logout using the brand's invalidation flow.
The invalidation flow contains a UserLogoutStage which fires the
flow_pre_user_logout signal. Our signal handler in signals.py picks that up,
finds the SAMLSourceSession, and injects the SLO redirect/POST stage."""
# Sources do not have an invalidation flow, use the brand's
flow = request.brand.flow_invalidation
if not flow:
logout(request)
return redirect("authentik_core:root-redirect")
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
plan = planner.plan(request)
except FlowNonApplicableException:
logout(request)
return redirect("authentik_core:root-redirect")
plan.append_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(request, flow)
def _handle_logout_request(
self,
request: HttpRequest,
source: SAMLSource,
raw_request: str,
is_post: bool = False,
) -> HttpResponse:
"""Handle an incoming LogoutRequest from the IdP (IdP-initiated SLO).
Parses the request, deletes the SAMLSourceSession (to prevent circular
redirect back to the IdP), runs the invalidation flow, and appends a
final stage to send the LogoutResponse back to the IdP."""
parser = LogoutRequestParser()
try:
if is_post:
logout_request = parser.parse(raw_request)
else:
logout_request = parser.parse_detached(raw_request)
except (CannotHandleAssertion, ValueError) as exc:
LOGGER.warning("Failed to parse LogoutRequest from IdP", exc=exc)
return bad_request_message(request, str(exc))
relay_state = (
request.GET.get("RelayState") if not is_post else request.POST.get("RelayState")
)
# Delete SAMLSourceSession so the source signal handler doesn't try to
# redirect back to the IdP (which would be circular)
SAMLSourceSession.objects.filter(
source=source,
user=request.user,
).delete()
# Build the LogoutResponse to send back to the IdP after logout
response_builder = LogoutResponseBuilder(
source=source,
http_request=request,
destination=source.slo_url,
in_response_to=logout_request.id,
)
# Sources do not have an invalidation flow, use the brand's
flow = request.brand.flow_invalidation
if not flow:
logout(request)
return self._send_logout_response(response_builder, relay_state)
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
plan = planner.plan(request)
except FlowNonApplicableException:
logout(request)
return self._send_logout_response(response_builder, relay_state)
# Append logout response stage, then session end
self._append_response_stage(plan, source, response_builder, relay_state)
plan.append_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(request, flow)
def _send_logout_response(
self,
response_builder: LogoutResponseBuilder,
relay_state: str | None = None,
) -> HttpResponse:
"""Send LogoutResponse back to the IdP directly (no flow).
Without a flow we can't render an autosubmit form, so always redirect."""
return redirect(response_builder.get_redirect_url(relay_state))
def _append_response_stage(
self,
plan: FlowPlan,
source: SAMLSource,
response_builder: LogoutResponseBuilder,
relay_state: str | None = None,
):
"""Append a stage to send the LogoutResponse back to the IdP."""
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
redirect_url = response_builder.get_redirect_url(relay_state)
plan.append_stage(in_memory_stage(RedirectStage, destination=redirect_url))
else:
# POST binding — use autosubmit form
form_data = response_builder.get_post_form_data(relay_state)
plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
plan.context[PLAN_CONTEXT_URL] = source.slo_url
plan.context[PLAN_CONTEXT_ATTRS] = form_data
plan.append_stage(in_memory_stage(AutosubmitStageView))
def _handle_logout_response(
self, request: HttpRequest, raw_response: str, relay_state: str | None = None
) -> HttpResponse:
"""Parse and handle a LogoutResponse from the IdP."""
processor = LogoutResponseParser(raw_response)
try:
processor.parse()
except (ValueError, etree.XMLSyntaxError) as exc:
LOGGER.warning("Failed to parse LogoutResponse", exc=exc)
return redirect("authentik_core:root-redirect")
processor.verify_status()
# If a RelayState was provided (e.g. the flow executor URL), advance
# past the current stage (RedirectStage) in the plan so the flow
# continues to the next stage instead of looping. Only redirect to the
# value stashed in the plan context on outbound — never to the value
# echoed back in the request, which is attacker-controllable.
if relay_state and SESSION_KEY_PLAN in request.session:
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
stored_relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE, "")
if relay_state != stored_relay_state:
LOGGER.warning(
"SAML logout relay_state mismatch, possible open redirect attempt",
received_relay_state=relay_state,
stored_relay_state=stored_relay_state,
)
if plan.bindings:
plan.pop()
request.session[SESSION_KEY_PLAN] = plan
if stored_relay_state:
return redirect(stored_relay_state)
return redirect("authentik_core:root-redirect")
class MetadataView(View):

View File

@@ -6049,18 +6049,22 @@
"authentik_sources_saml.add_groupsamlsourceconnection",
"authentik_sources_saml.add_samlsource",
"authentik_sources_saml.add_samlsourcepropertymapping",
"authentik_sources_saml.add_samlsourcesession",
"authentik_sources_saml.add_usersamlsourceconnection",
"authentik_sources_saml.change_groupsamlsourceconnection",
"authentik_sources_saml.change_samlsource",
"authentik_sources_saml.change_samlsourcepropertymapping",
"authentik_sources_saml.change_samlsourcesession",
"authentik_sources_saml.change_usersamlsourceconnection",
"authentik_sources_saml.delete_groupsamlsourceconnection",
"authentik_sources_saml.delete_samlsource",
"authentik_sources_saml.delete_samlsourcepropertymapping",
"authentik_sources_saml.delete_samlsourcesession",
"authentik_sources_saml.delete_usersamlsourceconnection",
"authentik_sources_saml.view_groupsamlsourceconnection",
"authentik_sources_saml.view_samlsource",
"authentik_sources_saml.view_samlsourcepropertymapping",
"authentik_sources_saml.view_samlsourcesession",
"authentik_sources_saml.view_usersamlsourceconnection",
"authentik_sources_scim.add_scimsource",
"authentik_sources_scim.add_scimsourcegroup",
@@ -11746,18 +11750,22 @@
"authentik_sources_saml.add_groupsamlsourceconnection",
"authentik_sources_saml.add_samlsource",
"authentik_sources_saml.add_samlsourcepropertymapping",
"authentik_sources_saml.add_samlsourcesession",
"authentik_sources_saml.add_usersamlsourceconnection",
"authentik_sources_saml.change_groupsamlsourceconnection",
"authentik_sources_saml.change_samlsource",
"authentik_sources_saml.change_samlsourcepropertymapping",
"authentik_sources_saml.change_samlsourcesession",
"authentik_sources_saml.change_usersamlsourceconnection",
"authentik_sources_saml.delete_groupsamlsourceconnection",
"authentik_sources_saml.delete_samlsource",
"authentik_sources_saml.delete_samlsourcepropertymapping",
"authentik_sources_saml.delete_samlsourcesession",
"authentik_sources_saml.delete_usersamlsourceconnection",
"authentik_sources_saml.view_groupsamlsourceconnection",
"authentik_sources_saml.view_samlsource",
"authentik_sources_saml.view_samlsourcepropertymapping",
"authentik_sources_saml.view_samlsourcesession",
"authentik_sources_saml.view_usersamlsourceconnection",
"authentik_sources_scim.add_scimsource",
"authentik_sources_scim.add_scimsourcegroup",
@@ -13578,6 +13586,15 @@
],
"title": "Binding type"
},
"slo_binding": {
"type": "string",
"enum": [
"REDIRECT",
"POST"
],
"title": "SLO Binding",
"description": "Binding type for Single Logout requests to the IdP."
},
"verification_kp": {
"type": "string",
"format": "uuid",
@@ -13634,6 +13651,16 @@
"signed_response": {
"type": "boolean",
"title": "Signed response"
},
"sign_authn_request": {
"type": "boolean",
"title": "Sign AuthnRequest",
"description": "Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
},
"sign_logout_request": {
"type": "boolean",
"title": "Sign LogoutRequest",
"description": "Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
}
},
"required": []

View File

@@ -101,6 +101,7 @@ import type {
SCIMSourceUser,
SCIMSourceUserRequest,
SignatureAlgorithmEnum,
SloBindingEnum,
Source,
SourceType,
SyncStatus,
@@ -749,10 +750,13 @@ export interface SourcesSamlListRequest {
policyEngineMode?: PolicyEngineMode;
preAuthenticationFlow?: string;
search?: string;
signAuthnRequest?: boolean;
signLogoutRequest?: boolean;
signatureAlgorithm?: SignatureAlgorithmEnum;
signedAssertion?: boolean;
signedResponse?: boolean;
signingKp?: string;
sloBinding?: SloBindingEnum;
sloUrl?: string;
slug?: string;
ssoUrl?: string;
@@ -7734,6 +7738,14 @@ export class SourcesApi extends runtime.BaseAPI {
queryParameters["search"] = requestParameters["search"];
}
if (requestParameters["signAuthnRequest"] != null) {
queryParameters["sign_authn_request"] = requestParameters["signAuthnRequest"];
}
if (requestParameters["signLogoutRequest"] != null) {
queryParameters["sign_logout_request"] = requestParameters["signLogoutRequest"];
}
if (requestParameters["signatureAlgorithm"] != null) {
queryParameters["signature_algorithm"] = requestParameters["signatureAlgorithm"];
}
@@ -7750,6 +7762,10 @@ export class SourcesApi extends runtime.BaseAPI {
queryParameters["signing_kp"] = requestParameters["signingKp"];
}
if (requestParameters["sloBinding"] != null) {
queryParameters["slo_binding"] = requestParameters["sloBinding"];
}
if (requestParameters["sloUrl"] != null) {
queryParameters["slo_url"] = requestParameters["sloUrl"];
}

View File

@@ -30,6 +30,8 @@ import {
SignatureAlgorithmEnumFromJSON,
SignatureAlgorithmEnumToJSON,
} from "./SignatureAlgorithmEnum";
import type { SloBindingEnum } from "./SloBindingEnum";
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
@@ -165,6 +167,12 @@ export interface PatchedSAMLSourceRequest {
* @memberof PatchedSAMLSourceRequest
*/
bindingType?: BindingTypeEnum;
/**
* Binding type for Single Logout requests to the IdP.
* @type {SloBindingEnum}
* @memberof PatchedSAMLSourceRequest
*/
sloBinding?: SloBindingEnum;
/**
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
* @type {string}
@@ -213,6 +221,18 @@ export interface PatchedSAMLSourceRequest {
* @memberof PatchedSAMLSourceRequest
*/
signedResponse?: boolean;
/**
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof PatchedSAMLSourceRequest
*/
signAuthnRequest?: boolean;
/**
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof PatchedSAMLSourceRequest
*/
signLogoutRequest?: boolean;
}
/**
@@ -278,6 +298,8 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
json["binding_type"] == null
? undefined
: BindingTypeEnumFromJSON(json["binding_type"]),
sloBinding:
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
digestAlgorithm:
@@ -295,6 +317,10 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
signAuthnRequest:
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
signLogoutRequest:
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
};
}
@@ -332,6 +358,7 @@ export function PatchedSAMLSourceRequestToJSONTyped(
force_authn: value["forceAuthn"],
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
verification_kp: value["verificationKp"],
signing_kp: value["signingKp"],
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
@@ -340,5 +367,7 @@ export function PatchedSAMLSourceRequestToJSONTyped(
encryption_kp: value["encryptionKp"],
signed_assertion: value["signedAssertion"],
signed_response: value["signedResponse"],
sign_authn_request: value["signAuthnRequest"],
sign_logout_request: value["signLogoutRequest"],
};
}

View File

@@ -30,6 +30,8 @@ import {
SignatureAlgorithmEnumFromJSON,
SignatureAlgorithmEnumToJSON,
} from "./SignatureAlgorithmEnum";
import type { SloBindingEnum } from "./SloBindingEnum";
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
import type { ThemedUrls } from "./ThemedUrls";
import { ThemedUrlsFromJSON } from "./ThemedUrls";
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
@@ -215,6 +217,12 @@ export interface SAMLSource {
* @memberof SAMLSource
*/
bindingType?: BindingTypeEnum;
/**
* Binding type for Single Logout requests to the IdP.
* @type {SloBindingEnum}
* @memberof SAMLSource
*/
sloBinding?: SloBindingEnum;
/**
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
* @type {string}
@@ -263,6 +271,18 @@ export interface SAMLSource {
* @memberof SAMLSource
*/
signedResponse?: boolean;
/**
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof SAMLSource
*/
signAuthnRequest?: boolean;
/**
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof SAMLSource
*/
signLogoutRequest?: boolean;
}
/**
@@ -343,6 +363,8 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
json["binding_type"] == null
? undefined
: BindingTypeEnumFromJSON(json["binding_type"]),
sloBinding:
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
digestAlgorithm:
@@ -360,6 +382,10 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
signAuthnRequest:
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
signLogoutRequest:
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
};
}
@@ -407,6 +433,7 @@ export function SAMLSourceToJSONTyped(
force_authn: value["forceAuthn"],
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
verification_kp: value["verificationKp"],
signing_kp: value["signingKp"],
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
@@ -415,5 +442,7 @@ export function SAMLSourceToJSONTyped(
encryption_kp: value["encryptionKp"],
signed_assertion: value["signedAssertion"],
signed_response: value["signedResponse"],
sign_authn_request: value["signAuthnRequest"],
sign_logout_request: value["signLogoutRequest"],
};
}

View File

@@ -30,6 +30,8 @@ import {
SignatureAlgorithmEnumFromJSON,
SignatureAlgorithmEnumToJSON,
} from "./SignatureAlgorithmEnum";
import type { SloBindingEnum } from "./SloBindingEnum";
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
@@ -165,6 +167,12 @@ export interface SAMLSourceRequest {
* @memberof SAMLSourceRequest
*/
bindingType?: BindingTypeEnum;
/**
* Binding type for Single Logout requests to the IdP.
* @type {SloBindingEnum}
* @memberof SAMLSourceRequest
*/
sloBinding?: SloBindingEnum;
/**
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
* @type {string}
@@ -213,6 +221,18 @@ export interface SAMLSourceRequest {
* @memberof SAMLSourceRequest
*/
signedResponse?: boolean;
/**
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof SAMLSourceRequest
*/
signAuthnRequest?: boolean;
/**
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
* @type {boolean}
* @memberof SAMLSourceRequest
*/
signLogoutRequest?: boolean;
}
/**
@@ -280,6 +300,8 @@ export function SAMLSourceRequestFromJSONTyped(
json["binding_type"] == null
? undefined
: BindingTypeEnumFromJSON(json["binding_type"]),
sloBinding:
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
digestAlgorithm:
@@ -297,6 +319,10 @@ export function SAMLSourceRequestFromJSONTyped(
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
signAuthnRequest:
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
signLogoutRequest:
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
};
}
@@ -334,6 +360,7 @@ export function SAMLSourceRequestToJSONTyped(
force_authn: value["forceAuthn"],
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
verification_kp: value["verificationKp"],
signing_kp: value["signingKp"],
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
@@ -342,5 +369,7 @@ export function SAMLSourceRequestToJSONTyped(
encryption_kp: value["encryptionKp"],
signed_assertion: value["signedAssertion"],
signed_response: value["signedResponse"],
sign_authn_request: value["signAuthnRequest"],
sign_logout_request: value["signLogoutRequest"],
};
}

View File

@@ -0,0 +1,57 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
*/
export const SloBindingEnum = {
Redirect: "REDIRECT",
Post: "POST",
UnknownDefaultOpenApi: "11184809",
} as const;
export type SloBindingEnum = (typeof SloBindingEnum)[keyof typeof SloBindingEnum];
export function instanceOfSloBindingEnum(value: any): boolean {
for (const key in SloBindingEnum) {
if (Object.prototype.hasOwnProperty.call(SloBindingEnum, key)) {
if (SloBindingEnum[key as keyof typeof SloBindingEnum] === value) {
return true;
}
}
}
return false;
}
export function SloBindingEnumFromJSON(json: any): SloBindingEnum {
return SloBindingEnumFromJSONTyped(json, false);
}
export function SloBindingEnumFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): SloBindingEnum {
return json as SloBindingEnum;
}
export function SloBindingEnumToJSON(value?: SloBindingEnum | null): any {
return value as any;
}
export function SloBindingEnumToJSONTyped(
value: any,
ignoreDiscriminator: boolean,
): SloBindingEnum {
return value as SloBindingEnum;
}

View File

@@ -766,6 +766,7 @@ export * from "./SettingsRequest";
export * from "./SeverityEnum";
export * from "./ShellChallenge";
export * from "./SignatureAlgorithmEnum";
export * from "./SloBindingEnum";
export * from "./Software";
export * from "./SoftwareRequest";
export * from "./Source";

View File

@@ -192,6 +192,7 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = [
"authentik.common.*",
"authentik.admin.*",
"authentik.api.*",
"authentik.blueprints.*",

View File

@@ -24369,6 +24369,14 @@ paths:
type: string
format: uuid
- $ref: '#/components/parameters/QuerySearch'
- in: query
name: sign_authn_request
schema:
type: boolean
- in: query
name: sign_logout_request
schema:
type: boolean
- in: query
name: signature_algorithm
schema:
@@ -24386,6 +24394,14 @@ paths:
schema:
type: string
format: uuid
- in: query
name: slo_binding
schema:
allOf:
- $ref: '#/components/schemas/SloBindingEnum'
description: |+
Binding type for Single Logout requests to the IdP.
- in: query
name: slo_url
schema:
@@ -50436,6 +50452,10 @@ components:
no Policy is sent.
binding_type:
$ref: '#/components/schemas/BindingTypeEnum'
slo_binding:
allOf:
- $ref: '#/components/schemas/SloBindingEnum'
description: Binding type for Single Logout requests to the IdP.
verification_kp:
type: string
format: uuid
@@ -50473,6 +50493,16 @@ components:
type: boolean
signed_response:
type: boolean
sign_authn_request:
type: boolean
title: Sign AuthnRequest
description: Whether to sign outgoing AuthnRequests. Requires a Signing
Keypair to be set.
sign_logout_request:
type: boolean
title: Sign LogoutRequest
description: Whether to sign outgoing LogoutRequests. Requires a Signing
Keypair to be set.
PatchedSCIMMappingRequest:
type: object
description: SCIMMapping Serializer
@@ -54176,6 +54206,10 @@ components:
no Policy is sent.
binding_type:
$ref: '#/components/schemas/BindingTypeEnum'
slo_binding:
allOf:
- $ref: '#/components/schemas/SloBindingEnum'
description: Binding type for Single Logout requests to the IdP.
verification_kp:
type: string
format: uuid
@@ -54212,6 +54246,16 @@ components:
type: boolean
signed_response:
type: boolean
sign_authn_request:
type: boolean
title: Sign AuthnRequest
description: Whether to sign outgoing AuthnRequests. Requires a Signing
Keypair to be set.
sign_logout_request:
type: boolean
title: Sign LogoutRequest
description: Whether to sign outgoing LogoutRequests. Requires a Signing
Keypair to be set.
required:
- component
- icon_themed_urls
@@ -54380,6 +54424,10 @@ components:
no Policy is sent.
binding_type:
$ref: '#/components/schemas/BindingTypeEnum'
slo_binding:
allOf:
- $ref: '#/components/schemas/SloBindingEnum'
description: Binding type for Single Logout requests to the IdP.
verification_kp:
type: string
format: uuid
@@ -54417,6 +54465,16 @@ components:
type: boolean
signed_response:
type: boolean
sign_authn_request:
type: boolean
title: Sign AuthnRequest
description: Whether to sign outgoing AuthnRequests. Requires a Signing
Keypair to be set.
sign_logout_request:
type: boolean
title: Sign LogoutRequest
description: Whether to sign outgoing LogoutRequests. Requires a Signing
Keypair to be set.
required:
- name
- pre_authentication_flow
@@ -55677,6 +55735,11 @@ components:
- http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
- http://www.w3.org/2000/09/xmldsig#dsa-sha1
type: string
SloBindingEnum:
enum:
- REDIRECT
- POST
type: string
Software:
type: object
properties:

View File

@@ -28,6 +28,7 @@ import {
SAMLNameIDPolicyEnum,
SAMLSource,
SignatureAlgorithmEnum,
SloBindingEnum,
SourcesApi,
UsageEnum,
UserMatchingModeEnum,
@@ -226,6 +227,32 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
${msg("Optional URL if the IDP supports Single-Logout.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("SLO Binding")}
required
name="sloBinding"
>
<ak-radio
.options=${[
{
label: msg("Redirect binding"),
value: SloBindingEnum.Redirect,
default: true,
},
{
label: msg("Post binding"),
value: SloBindingEnum.Post,
},
]}
.value=${this.instance?.sloBinding}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${msg(
"Binding type used for sending Single Logout requests to the IdP.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Issuer")} name="issuer">
<input
type="text"
@@ -274,6 +301,22 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
)}
</p>
</ak-form-element-horizontal>
<ak-switch-input
name="signAuthnRequest"
label=${msg("Sign AuthnRequest")}
?checked=${this.instance?.signAuthnRequest ?? false}
help=${msg(
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
)}
></ak-switch-input>
<ak-switch-input
name="signLogoutRequest"
label=${msg("Sign LogoutRequest")}
?checked=${this.instance?.signLogoutRequest ?? false}
help=${msg(
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
)}
></ak-switch-input>
<ak-form-element-horizontal
label=${msg("Verification Certificate")}
name="verificationKp"