From 858a040dfb209c1ee04bb6fb490bbd9712830015 Mon Sep 17 00:00:00 2001 From: Connor Peshek Date: Wed, 11 Feb 2026 14:18:39 -0600 Subject: [PATCH] providers/saml: send logoutResponse on sp-init logout (#17691) * providers/saml: send logoutResponse on sp-init logout * Use first updated to fix multiple submits * add backchannel logoutResponse * tests --------- Signed-off-by: Connor Peshek Co-authored-by: connor peshek --- authentik/providers/iframe_logout.py | 16 +- authentik/providers/saml/api/providers.py | 1 + .../0021_samlprovider_sign_logout_response.py | 18 ++ authentik/providers/saml/models.py | 1 + authentik/providers/saml/native_logout.py | 16 +- .../processors/logout_response_processor.py | 196 ++++++++++++ authentik/providers/saml/signals.py | 6 +- authentik/providers/saml/tasks.py | 86 +++++ .../providers/saml/tests/test_idp_logout.py | 12 +- .../tests/test_logout_response_processor.py | 139 +++++++++ authentik/providers/saml/tests/test_tasks.py | 291 +++++++++++++++++ .../providers/saml/tests/test_views_sp_slo.py | 295 +++++++++++++++++- authentik/providers/saml/views/sp_slo.py | 111 ++++++- blueprints/schema.json | 4 + schema.yml | 62 +++- .../providers/saml/SAMLProviderFormForm.ts | 7 + web/src/flow/providers/IFrameLogoutStage.ts | 71 +++-- .../flow/providers/saml/NativeLogoutStage.ts | 42 ++- 18 files changed, 1298 insertions(+), 76 deletions(-) create mode 100644 authentik/providers/saml/migrations/0021_samlprovider_sign_logout_response.py create mode 100644 authentik/providers/saml/processors/logout_response_processor.py create mode 100644 authentik/providers/saml/tests/test_logout_response_processor.py create mode 100644 authentik/providers/saml/tests/test_tasks.py diff --git a/authentik/providers/iframe_logout.py b/authentik/providers/iframe_logout.py index 617e038310..3eaeb95a4a 100644 --- a/authentik/providers/iframe_logout.py +++ b/authentik/providers/iframe_logout.py @@ -1,19 +1,31 @@ """Shared logout stages for SAML and OIDC providers""" from django.http import HttpResponse -from rest_framework.fields import CharField, DictField, ListField +from rest_framework.fields import CharField, ListField from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS +from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import Challenge, ChallengeResponse from authentik.flows.stage import ChallengeStageView from authentik.providers.saml.views.flows import PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS +class LogoutURL(PassiveSerializer): + """Data for a single logout URL""" + + url = CharField() + provider_name = CharField(required=False, allow_null=True) + binding = CharField(required=False, allow_null=True) + saml_request = CharField(required=False, allow_null=True) + saml_response = CharField(required=False, allow_null=True) + saml_relay_state = CharField(required=False, allow_null=True) + + class IframeLogoutChallenge(Challenge): """Challenge for iframe logout""" component = CharField(default="ak-provider-iframe-logout") - logout_urls = ListField(child=DictField(), default=list) + logout_urls = ListField(child=LogoutURL(), default=list) class IframeLogoutChallengeResponse(ChallengeResponse): diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py index c3e20ba054..0fbc987570 100644 --- a/authentik/providers/saml/api/providers.py +++ b/authentik/providers/saml/api/providers.py @@ -213,6 +213,7 @@ class SAMLProviderSerializer(ProviderSerializer): "sign_assertion", "sign_response", "sign_logout_request", + "sign_logout_response", "sp_binding", "sls_binding", "logout_method", diff --git a/authentik/providers/saml/migrations/0021_samlprovider_sign_logout_response.py b/authentik/providers/saml/migrations/0021_samlprovider_sign_logout_response.py new file mode 100644 index 0000000000..fdb8e09a6c --- /dev/null +++ b/authentik/providers/saml/migrations/0021_samlprovider_sign_logout_response.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-10-24 18:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0020_samlprovider_logout_method_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="samlprovider", + name="sign_logout_response", + field=models.BooleanField(default=False), + ), + ] diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py index 20f9532bd5..eeb91fc5b9 100644 --- a/authentik/providers/saml/models.py +++ b/authentik/providers/saml/models.py @@ -227,6 +227,7 @@ class SAMLProvider(Provider): sign_assertion = models.BooleanField(default=True) sign_response = models.BooleanField(default=False) sign_logout_request = models.BooleanField(default=False) + sign_logout_response = models.BooleanField(default=False) @property def launch_url(self) -> str | None: diff --git a/authentik/providers/saml/native_logout.py b/authentik/providers/saml/native_logout.py index 9d9ffdf92c..ae968941b4 100644 --- a/authentik/providers/saml/native_logout.py +++ b/authentik/providers/saml/native_logout.py @@ -1,11 +1,12 @@ """SAML Logout stages for automatic injection""" from django.http import HttpResponse -from rest_framework.fields import BooleanField, CharField +from rest_framework.fields import BooleanField, CharField, ChoiceField from structlog.stdlib import get_logger from authentik.flows.challenge import Challenge, ChallengeResponse, HttpChallengeResponse from authentik.flows.stage import ChallengeStageView +from authentik.providers.saml.models import SAMLBindings from authentik.providers.saml.views.flows import PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS LOGGER = get_logger() @@ -19,14 +20,17 @@ class NativeLogoutChallenge(Challenge): """Challenge for native browser logout""" component = CharField(default="ak-provider-saml-native-logout") - post_url = CharField(required=False) - saml_request = CharField(required=False) - relay_state = CharField(required=False) provider_name = CharField(required=False) - binding = CharField(required=False) - redirect_url = CharField(required=False) is_complete = BooleanField(required=False, default=False) + post_url = CharField(required=False) + redirect_url = CharField(required=False) + + saml_binding = ChoiceField(choices=SAMLBindings.choices, required=False) + saml_request = CharField(required=False) + saml_response = CharField(required=False) + saml_relay_state = CharField(required=False) + class NativeLogoutChallengeResponse(ChallengeResponse): """Response for native browser logout""" diff --git a/authentik/providers/saml/processors/logout_response_processor.py b/authentik/providers/saml/processors/logout_response_processor.py new file mode 100644 index 0000000000..d97ad54eb9 --- /dev/null +++ b/authentik/providers/saml/processors/logout_response_processor.py @@ -0,0 +1,196 @@ +"""LogoutResponse processor""" + +import base64 +from urllib.parse import quote, urlencode + +import xmlsec +from lxml import etree +from lxml.etree import Element, SubElement + +from authentik.common.saml.constants import ( + DIGEST_ALGORITHM_TRANSLATION_MAP, + NS_MAP, + NS_SAML_ASSERTION, + NS_SAML_PROTOCOL, + SIGN_ALGORITHM_TRANSFORM_MAP, +) +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 + + +class LogoutResponseProcessor: + """Generate a SAML LogoutResponse""" + + provider: SAMLProvider + logout_request: LogoutRequest + destination: str | None + relay_state: str | None + _issue_instant: str + _response_id: str + + def __init__( + self, + provider: SAMLProvider, + logout_request: LogoutRequest, + destination: str | None = None, + relay_state: str | None = None, + ): + self.provider = provider + self.logout_request = logout_request + self.destination = destination + self.relay_state = relay_state or (logout_request.relay_state if logout_request else None) + self._issue_instant = get_time_string() + self._response_id = get_random_id() + + def get_issuer(self) -> Element: + """Get Issuer element""" + issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer") + issuer.text = self.provider.issuer + return issuer + + def build(self, status: str = "Success") -> Element: + """Build a SAML LogoutResponse as etree Element""" + response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP) + response.attrib["Version"] = "2.0" + response.attrib["IssueInstant"] = self._issue_instant + response.attrib["ID"] = self._response_id + + if self.destination: + response.attrib["Destination"] = self.destination + + if self.logout_request and self.logout_request.id: + response.attrib["InResponseTo"] = self.logout_request.id + + response.append(self.get_issuer()) + + # Add Status element + status_element = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status") + status_code = SubElement(status_element, f"{{{NS_SAML_PROTOCOL}}}StatusCode") + status_code.attrib["Value"] = f"urn:oasis:names:tc:SAML:2.0:status:{status}" + + return response + + def build_response(self, status: str = "Success") -> str: + """Build and sign LogoutResponse, return as XML string (not encoded)""" + response = self.build(status) + if self.provider.signing_kp and self.provider.sign_logout_response: + self._add_signature(response) + self._sign_response(response) + return etree.tostring(response).decode() + + def encode_post(self, status: str = "Success") -> str: + """Encode LogoutResponse for POST binding""" + response = self.build(status) + if self.provider.signing_kp and self.provider.sign_logout_response: + self._add_signature(response) + self._sign_response(response) + return base64.b64encode(etree.tostring(response)).decode() + + def encode_redirect(self, status: str = "Success") -> str: + """Encode LogoutResponse for Redirect binding""" + response = self.build(status) + # Note: For redirect binding, signatures are added as query parameters, not in XML + 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, status: str = "Success") -> str: + """Build complete logout response URL for redirect binding with signature if needed""" + encoded_response = self.encode_redirect(status) + params = { + "SAMLResponse": encoded_response, + } + + if self.relay_state: + params["RelayState"] = self.relay_state + + if self.provider.signing_kp and self.provider.sign_logout_response: + sig_alg = self.provider.signature_algorithm + params["SigAlg"] = sig_alg + + # Build the string to sign + query_string = self._build_signable_query_string(params) + + signature = self._sign_query_string(query_string) + params["Signature"] = base64.b64encode(signature).decode() + + # Some SP's use query params on their sls endpoint + if not self.destination: + raise ValueError("destination is required for redirect URL") + + separator = "&" if "?" in self.destination else "?" + return f"{self.destination}{separator}{urlencode(params)}" + + def _add_signature(self, element: Element): + """Add signature placeholder to element""" + sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( + self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1 + ) + signature = xmlsec.template.create( + element, + xmlsec.constants.TransformExclC14N, + sign_algorithm_transform, + ns=xmlsec.constants.DSigNs, + ) + element.insert(1, signature) # Insert after Issuer + + def _sign_response(self, response: Element): + """Sign the response element""" + digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get( + self.provider.digest_algorithm, xmlsec.constants.TransformSha1 + ) + + xmlsec.tree.add_ids(response, ["ID"]) + signature_node = xmlsec.tree.find_node(response, xmlsec.constants.NodeSignature) + + ref = xmlsec.template.add_reference( + signature_node, + digest_algorithm_transform, + uri="#" + response.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() + ctx.key = xmlsec.Key.from_memory( + self.provider.signing_kp.key_data, # Use key_data for the private key + xmlsec.constants.KeyDataFormatPem, + ) + ctx.key.load_cert_from_memory( + self.provider.signing_kp.certificate_data, xmlsec.constants.KeyDataFormatPem + ) + ctx.sign(signature_node) + + def _build_signable_query_string(self, params: dict) -> str: + """Build query string for signing (order matters per SAML spec)""" + # SAML spec requires specific order: SAMLResponse, RelayState, SigAlg + # Values must be URL-encoded individually before concatenation + ordered = [] + if "SAMLResponse" in params: + ordered.append(f"SAMLResponse={quote(params['SAMLResponse'], 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.provider.signature_algorithm, xmlsec.constants.TransformRsaSha256 + ) + + key = xmlsec.Key.from_memory( + self.provider.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) diff --git a/authentik/providers/saml/signals.py b/authentik/providers/saml/signals.py index 9dce6c4c68..5a52ebe60d 100644 --- a/authentik/providers/saml/signals.py +++ b/authentik/providers/saml/signals.py @@ -175,16 +175,16 @@ def handle_flow_pre_user_logout( logout_data = { "post_url": session.provider.sls_url, "saml_request": form_data["SAMLRequest"], - "relay_state": form_data["RelayState"], + "saml_relay_state": form_data["RelayState"], "provider_name": session.provider.name, - "binding": SAMLBindings.POST, + "saml_binding": SAMLBindings.POST, } else: logout_url = processor.get_redirect_url() logout_data = { "redirect_url": logout_url, "provider_name": session.provider.name, - "binding": SAMLBindings.REDIRECT, + "saml_binding": SAMLBindings.REDIRECT, } native_sessions.append(logout_data) diff --git a/authentik/providers/saml/tasks.py b/authentik/providers/saml/tasks.py index ae602ac4df..84b87d20b0 100644 --- a/authentik/providers/saml/tasks.py +++ b/authentik/providers/saml/tasks.py @@ -5,8 +5,11 @@ from django.contrib.auth import get_user_model from dramatiq.actor import actor from structlog.stdlib import get_logger +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() User = get_user_model() @@ -78,3 +81,86 @@ def send_post_logout_request(provider: SAMLProvider, processor: LogoutRequestPro ) return True + + +@actor(description="Send SAML LogoutResponse to a Service Provider (backchannel)") +def send_saml_logout_response( + provider_pk: int, + sls_url: str, + logout_request_id: str | None = None, + relay_state: str | None = None, +): + """Send SAML LogoutResponse to a Service Provider using backchannel (server-to-server)""" + provider = SAMLProvider.objects.filter(pk=provider_pk).first() + if not provider: + LOGGER.error( + "Provider not found for SAML logout response", + provider_pk=provider_pk, + ) + return False + + LOGGER.debug( + "Sending backchannel SAML logout response", + provider=provider.name, + sls_url=sls_url, + ) + + # Create a minimal LogoutRequest object for the response processor + # We only need the ID and relay_state for building the response + logout_request = None + if logout_request_id: + logout_request = LogoutRequest() + logout_request.id = logout_request_id + logout_request.relay_state = relay_state + + # Build the logout response + processor = LogoutResponseProcessor( + provider=provider, + logout_request=logout_request, + destination=sls_url, + relay_state=relay_state, + ) + + encoded_response = processor.encode_post() + + form_data = { + "SAMLResponse": encoded_response, + } + + if relay_state: + form_data["RelayState"] = relay_state + + # Send the logout response to the SP + try: + response = requests.post( + sls_url, + data=form_data, + timeout=10, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + allow_redirects=True, + ) + response.raise_for_status() + + LOGGER.info( + "Successfully sent backchannel logout response to SP", + provider=provider.name, + sls_url=sls_url, + status_code=response.status_code, + ) + return True + + except requests.exceptions.RequestException as exc: + LOGGER.warning( + "Failed to send backchannel logout response to SP", + provider=provider.name, + sls_url=sls_url, + error=str(exc), + ) + Event.new( + EventAction.CONFIGURATION_ERROR, + provider=provider, + message=f"Backchannel logout response failed: {str(exc)}", + ).save() + return False diff --git a/authentik/providers/saml/tests/test_idp_logout.py b/authentik/providers/saml/tests/test_idp_logout.py index 3ccb6d2761..1f49843305 100644 --- a/authentik/providers/saml/tests/test_idp_logout.py +++ b/authentik/providers/saml/tests/test_idp_logout.py @@ -69,7 +69,7 @@ class TestNativeLogoutStageView(TestCase): { "redirect_url": "https://sp1.example.com/sls?SAMLRequest=encoded", "provider_name": "test-provider-1", - "binding": "redirect", + "saml_binding": "redirect", } ] stage_view = NativeLogoutStageView( @@ -85,7 +85,7 @@ class TestNativeLogoutStageView(TestCase): # Should return a NativeLogoutChallenge self.assertIsInstance(challenge, NativeLogoutChallenge) - self.assertEqual(challenge.initial_data["binding"], "redirect") + self.assertEqual(challenge.initial_data["saml_binding"], "redirect") self.assertEqual(challenge.initial_data["provider_name"], "test-provider-1") self.assertIn("redirect_url", challenge.initial_data) @@ -102,9 +102,9 @@ class TestNativeLogoutStageView(TestCase): { "post_url": "https://sp2.example.com/sls", "saml_request": "encoded_saml_request", - "relay_state": "https://idp.example.com/flow/test-flow", + "saml_relay_state": "https://idp.example.com/flow/test-flow", "provider_name": "test-provider-2", - "binding": "post", + "saml_binding": "post", } ] stage_view = NativeLogoutStageView( @@ -120,11 +120,11 @@ class TestNativeLogoutStageView(TestCase): # Should return a NativeLogoutChallenge self.assertIsInstance(challenge, NativeLogoutChallenge) - self.assertEqual(challenge.initial_data["binding"], "post") + self.assertEqual(challenge.initial_data["saml_binding"], "post") self.assertEqual(challenge.initial_data["provider_name"], "test-provider-2") self.assertEqual(challenge.initial_data["post_url"], "https://sp2.example.com/sls") self.assertIn("saml_request", challenge.initial_data) - self.assertIn("relay_state", challenge.initial_data) + self.assertIn("saml_relay_state", challenge.initial_data) def test_get_challenge_all_complete(self): """Test get_challenge when all providers are done""" diff --git a/authentik/providers/saml/tests/test_logout_response_processor.py b/authentik/providers/saml/tests/test_logout_response_processor.py new file mode 100644 index 0000000000..0503020423 --- /dev/null +++ b/authentik/providers/saml/tests/test_logout_response_processor.py @@ -0,0 +1,139 @@ +"""logout response tests""" + +from defusedxml import ElementTree +from django.test import TestCase + +from authentik.blueprints.tests import apply_blueprint +from authentik.common.saml.constants import ( + NS_SAML_ASSERTION, + NS_SAML_PROTOCOL, + NS_SIGNATURE, +) +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 LogoutRequest +from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor + + +class TestLogoutResponse(TestCase): + """Test LogoutResponse processor""" + + @apply_blueprint("system/providers-saml.yaml") + def setUp(self): + cert = create_test_cert() + self.provider: SAMLProvider = SAMLProvider.objects.create( + authorization_flow=create_test_flow(), + acs_url="http://testserver/source/saml/provider/acs/", + sls_url="http://testserver/source/saml/provider/sls/", + signing_kp=cert, + verification_kp=cert, + ) + self.provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + self.provider.save() + + def test_build_response(self): + """Test building a LogoutResponse""" + logout_request = LogoutRequest( + id="test-request-id", + issuer="test-sp", + relay_state="test-relay-state", + ) + + processor = LogoutResponseProcessor( + self.provider, logout_request, destination=self.provider.sls_url + ) + response_xml = processor.build_response(status="Success") + + # Parse and verify + root = ElementTree.fromstring(response_xml) + self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutResponse") + self.assertEqual(root.attrib["Version"], "2.0") + self.assertEqual(root.attrib["Destination"], self.provider.sls_url) + self.assertEqual(root.attrib["InResponseTo"], "test-request-id") + + # Check Issuer + issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer") + self.assertEqual(issuer.text, self.provider.issuer) + + # Check Status + status = root.find(f".//{{{NS_SAML_PROTOCOL}}}StatusCode") + self.assertEqual(status.attrib["Value"], "urn:oasis:names:tc:SAML:2.0:status:Success") + + def test_build_response_signed(self): + """Test building a signed LogoutResponse""" + self.provider.sign_logout_response = True + self.provider.save() + + logout_request = LogoutRequest( + id="test-request-id", + issuer="test-sp", + relay_state="test-relay-state", + ) + + processor = LogoutResponseProcessor( + self.provider, logout_request, destination=self.provider.sls_url + ) + response_xml = processor.build_response(status="Success") + + # Parse and verify signature is present + root = ElementTree.fromstring(response_xml) + signature = root.find(f".//{{{NS_SIGNATURE}}}Signature") + self.assertIsNotNone(signature) + + # Verify signature structure + signed_info = signature.find(f"{{{NS_SIGNATURE}}}SignedInfo") + self.assertIsNotNone(signed_info) + signature_value = signature.find(f"{{{NS_SIGNATURE}}}SignatureValue") + self.assertIsNotNone(signature_value) + self.assertIsNotNone(signature_value.text) + + def test_no_inresponseto(self): + """Test building response without a logout request omits InResponseTo attribute""" + processor = LogoutResponseProcessor(self.provider, None, destination=self.provider.sls_url) + response_xml = processor.build_response(status="Success") + + root = ElementTree.fromstring(response_xml) + self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutResponse") + self.assertNotIn("InResponseTo", root.attrib) + + def test_no_destination(self): + """Test building response without destination""" + logout_request = LogoutRequest( + id="test-request-id", + issuer="test-sp", + ) + + processor = LogoutResponseProcessor(self.provider, logout_request, destination=None) + response_xml = processor.build_response(status="Success") + + root = ElementTree.fromstring(response_xml) + self.assertNotIn("Destination", root.attrib) + + def test_relay_state_from_logout_request(self): + """Test that relay_state is taken from logout_request if not provided""" + logout_request = LogoutRequest( + id="test-request-id", + issuer="test-sp", + relay_state="request-relay-state", + ) + + processor = LogoutResponseProcessor( + self.provider, logout_request, destination=self.provider.sls_url + ) + self.assertEqual(processor.relay_state, "request-relay-state") + + def test_relay_state_override(self): + """Test that explicit relay_state overrides logout_request relay_state""" + logout_request = LogoutRequest( + id="test-request-id", + issuer="test-sp", + relay_state="request-relay-state", + ) + + processor = LogoutResponseProcessor( + self.provider, + logout_request, + destination=self.provider.sls_url, + relay_state="explicit-relay-state", + ) + self.assertEqual(processor.relay_state, "explicit-relay-state") diff --git a/authentik/providers/saml/tests/test_tasks.py b/authentik/providers/saml/tests/test_tasks.py new file mode 100644 index 0000000000..0201cec56d --- /dev/null +++ b/authentik/providers/saml/tests/test_tasks.py @@ -0,0 +1,291 @@ +"""Tests for SAML provider tasks""" + +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from requests.exceptions import ConnectionError, HTTPError + +from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL +from authentik.core.tests.utils import create_test_cert, create_test_flow +from authentik.providers.saml.models import SAMLProvider +from authentik.providers.saml.tasks import ( + send_post_logout_request, + send_saml_logout_request, + send_saml_logout_response, +) + + +class TestSendSamlLogoutResponse(TestCase): + """Tests for send_saml_logout_response task""" + + def setUp(self): + """Set up test fixtures""" + self.cert = create_test_cert() + self.flow = create_test_flow() + + self.provider = SAMLProvider.objects.create( + name="test-provider", + authorization_flow=self.flow, + acs_url="https://sp.example.com/acs", + sls_url="https://sp.example.com/sls", + issuer="https://idp.example.com", + signing_kp=self.cert, + ) + + @patch("authentik.providers.saml.tasks.requests.post") + def test_successful_logout_response(self, mock_post): + """Test successful POST to SP returns True""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + result = send_saml_logout_response( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + logout_request_id="test-request-id", + relay_state="https://sp.example.com/return", + ) + + self.assertTrue(result) + mock_post.assert_called_once() + + # Verify the POST was made with correct data + call_kwargs = mock_post.call_args[1] + self.assertEqual(call_kwargs["timeout"], 10) + self.assertEqual( + call_kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded" + ) + + # Verify form data contains SAMLResponse and RelayState + form_data = call_kwargs["data"] + self.assertIn("SAMLResponse", form_data) + self.assertIn("RelayState", form_data) + self.assertEqual(form_data["RelayState"], "https://sp.example.com/return") + + @patch("authentik.providers.saml.tasks.requests.post") + def test_successful_logout_response_no_relay_state(self, mock_post): + """Test successful POST without relay_state""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + result = send_saml_logout_response( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + logout_request_id="test-request-id", + relay_state=None, + ) + + self.assertTrue(result) + + # Verify form data does not contain RelayState + form_data = mock_post.call_args[1]["data"] + self.assertIn("SAMLResponse", form_data) + self.assertNotIn("RelayState", form_data) + + def test_provider_not_found(self): + """Test returns False when provider doesn't exist""" + result = send_saml_logout_response( + provider_pk=99999, # Non-existent provider + sls_url="https://sp.example.com/sls", + logout_request_id="test-request-id", + relay_state=None, + ) + + self.assertFalse(result) + + @patch("authentik.providers.saml.tasks.Event") + @patch("authentik.providers.saml.tasks.requests.post") + def test_http_error_creates_event(self, mock_post, mock_event_class): + """Test HTTP error creates an error event""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = HTTPError("500 Server Error") + mock_post.return_value = mock_response + + mock_event = MagicMock() + mock_event_class.new.return_value = mock_event + + result = send_saml_logout_response( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + logout_request_id="test-request-id", + relay_state=None, + ) + + self.assertFalse(result) + + # Verify error event was created + mock_event_class.new.assert_called_once() + call_kwargs = mock_event_class.new.call_args[1] + self.assertIn("Backchannel logout response failed", call_kwargs["message"]) + mock_event.save.assert_called_once() + + +class TestSendSamlLogoutRequest(TestCase): + """Tests for send_saml_logout_request task""" + + def setUp(self): + """Set up test fixtures""" + self.cert = create_test_cert() + self.flow = create_test_flow() + + self.provider = SAMLProvider.objects.create( + name="test-provider", + authorization_flow=self.flow, + acs_url="https://sp.example.com/acs", + sls_url="https://sp.example.com/sls", + issuer="https://idp.example.com", + signing_kp=self.cert, + ) + + @patch("authentik.providers.saml.tasks.requests.post") + def test_successful_logout_request(self, mock_post): + """Test successful POST logout request returns True""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + result = send_saml_logout_request( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + + self.assertTrue(result) + mock_post.assert_called_once() + + # Verify the POST was made with correct data + call_kwargs = mock_post.call_args[1] + self.assertEqual(call_kwargs["timeout"], 10) + self.assertEqual( + call_kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded" + ) + + # Verify form data contains SAMLRequest + form_data = call_kwargs["data"] + self.assertIn("SAMLRequest", form_data) + + def test_provider_not_found(self): + """Test returns False when provider doesn't exist""" + result = send_saml_logout_request( + provider_pk=99999, # Non-existent provider + sls_url="https://sp.example.com/sls", + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + + self.assertFalse(result) + + @patch("authentik.providers.saml.tasks.requests.post") + def test_http_error_raises(self, mock_post): + """Test HTTP error raises exception (no try/catch in send_post_logout_request)""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = HTTPError("500 Server Error") + mock_post.return_value = mock_response + + with self.assertRaises(HTTPError): + send_saml_logout_request( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + + +class TestSendPostLogoutRequest(TestCase): + """Tests for send_post_logout_request function""" + + def setUp(self): + """Set up test fixtures""" + self.cert = create_test_cert() + self.flow = create_test_flow() + + self.provider = SAMLProvider.objects.create( + name="test-provider", + authorization_flow=self.flow, + acs_url="https://sp.example.com/acs", + sls_url="https://sp.example.com/sls", + issuer="https://idp.example.com", + signing_kp=self.cert, + ) + + @patch("authentik.providers.saml.tasks.requests.post") + def test_successful_post(self, mock_post): + """Test successful POST returns True""" + from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + processor = LogoutRequestProcessor( + provider=self.provider, + user=None, + destination=self.provider.sls_url, + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + + result = send_post_logout_request(self.provider, processor) + + self.assertTrue(result) + mock_post.assert_called_once() + + @patch("authentik.providers.saml.tasks.requests.post") + def test_with_relay_state(self, mock_post): + """Test POST includes RelayState when present""" + from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + processor = LogoutRequestProcessor( + provider=self.provider, + user=None, + destination=self.provider.sls_url, + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + relay_state="https://sp.example.com/return", + ) + + result = send_post_logout_request(self.provider, processor) + + self.assertTrue(result) + + # Verify RelayState is included + form_data = mock_post.call_args[1]["data"] + self.assertIn("RelayState", form_data) + self.assertEqual(form_data["RelayState"], "https://sp.example.com/return") + + @patch("authentik.providers.saml.tasks.requests.post") + def test_connection_error_raises(self, mock_post): + """Test connection error raises exception""" + from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor + + mock_post.side_effect = ConnectionError("Connection refused") + + processor = LogoutRequestProcessor( + provider=self.provider, + user=None, + destination=self.provider.sls_url, + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + + with self.assertRaises(ConnectionError): + send_post_logout_request(self.provider, processor) diff --git a/authentik/providers/saml/tests/test_views_sp_slo.py b/authentik/providers/saml/tests/test_views_sp_slo.py index 59f450de1c..c24e61f9f0 100644 --- a/authentik/providers/saml/tests/test_views_sp_slo.py +++ b/authentik/providers/saml/tests/test_views_sp_slo.py @@ -8,13 +8,15 @@ from django.urls import reverse from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL from authentik.core.models import Application -from authentik.core.tests.utils import create_test_brand, create_test_flow +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 SAMLProvider +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 PLAN_CONTEXT_SAML_RELAY_STATE +from authentik.providers.saml.views.flows import ( + PLAN_CONTEXT_SAML_RELAY_STATE, +) from authentik.providers.saml.views.sp_slo import ( SPInitiatedSLOBindingPOSTView, SPInitiatedSLOBindingRedirectView, @@ -436,3 +438,290 @@ class TestSPInitiatedSLOViews(TestCase): # Should treat it as plain URL and redirect to it self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/some/invalid/path") + + +class TestSPInitiatedSLOLogoutMethods(TestCase): + """Test SP-initiated SAML SLO logout method branching""" + + def setUp(self): + """Set up test fixtures""" + self.factory = RequestFactory() + self.brand = create_test_brand() + self.flow = create_test_flow() + self.invalidation_flow = create_test_flow() + self.cert = create_test_cert() + + # Create provider with sls_url + self.provider = SAMLProvider.objects.create( + name="test-provider", + authorization_flow=self.flow, + invalidation_flow=self.invalidation_flow, + acs_url="https://sp.example.com/acs", + sls_url="https://sp.example.com/sls", + issuer="https://idp.example.com", + sp_binding="redirect", + sls_binding="redirect", + signing_kp=self.cert, + ) + + # Create application + self.application = Application.objects.create( + name="test-app", + slug="test-app-logout-methods", + provider=self.provider, + ) + + # Create logout request processor for generating test requests + self.processor = LogoutRequestProcessor( + provider=self.provider, + user=None, + destination="https://idp.example.com/sls", + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + relay_state="https://sp.example.com/return", + ) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_frontchannel_native_post_binding(self, mock_auth_session): + """Test FRONTCHANNEL_NATIVE with POST binding parses request correctly""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE + self.provider.sls_binding = SAMLBindings.POST + self.provider.save() + + encoded_request = self.processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": "https://sp.example.com/return", + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the logout request was parsed and provider is configured correctly + self.assertIn("authentik/providers/saml/logout_request", view.plan_context) + self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.FRONTCHANNEL_NATIVE) + self.assertEqual(view.provider.sls_binding, SAMLBindings.POST) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_frontchannel_native_redirect_binding(self, mock_auth_session): + """Test FRONTCHANNEL_NATIVE with REDIRECT binding creates redirect URL""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_NATIVE + self.provider.sls_binding = SAMLBindings.REDIRECT + self.provider.save() + + encoded_request = self.processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": "https://sp.example.com/return", + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the logout request was parsed + self.assertIn("authentik/providers/saml/logout_request", view.plan_context) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_frontchannel_iframe_post_binding(self, mock_auth_session): + """Test FRONTCHANNEL_IFRAME with POST binding creates IframeLogoutStageView""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME + self.provider.sls_binding = SAMLBindings.POST + self.provider.save() + + encoded_request = self.processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": "https://sp.example.com/return", + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the logout request was parsed + self.assertIn("authentik/providers/saml/logout_request", view.plan_context) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_frontchannel_iframe_redirect_binding(self, mock_auth_session): + """Test FRONTCHANNEL_IFRAME with REDIRECT binding""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME + self.provider.sls_binding = SAMLBindings.REDIRECT + self.provider.save() + + encoded_request = self.processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": "https://sp.example.com/return", + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the logout request was parsed + self.assertIn("authentik/providers/saml/logout_request", view.plan_context) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_backchannel_parses_request(self, mock_auth_session): + """Test BACKCHANNEL mode parses request correctly""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.BACKCHANNEL + self.provider.sls_binding = SAMLBindings.POST + self.provider.save() + + encoded_request = self.processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": "https://sp.example.com/return", + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the logout request was parsed and provider is configured correctly + self.assertIn("authentik/providers/saml/logout_request", view.plan_context) + self.assertEqual(view.provider.logout_method, SAMLLogoutMethods.BACKCHANNEL) + self.assertEqual(view.provider.sls_binding, SAMLBindings.POST) + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_no_sls_url_only_session_end(self, mock_auth_session): + """Test that only SessionEndStage is appended when sls_url is empty""" + mock_auth_session.from_request.return_value = None + + # Create provider without sls_url + provider_no_sls = SAMLProvider.objects.create( + name="no-sls-provider", + authorization_flow=self.flow, + invalidation_flow=self.invalidation_flow, + acs_url="https://sp.example.com/acs", + sls_url="", # No SLS URL + issuer="https://idp.example.com", + ) + + app_no_sls = Application.objects.create( + name="no-sls-app", + slug="no-sls-app", + provider=provider_no_sls, + ) + + processor = LogoutRequestProcessor( + provider=provider_no_sls, + user=None, + destination="https://idp.example.com/sls", + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + ) + encoded_request = processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{app_no_sls.slug}/", + { + "SAMLRequest": encoded_request, + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=app_no_sls.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify the provider has no sls_url + self.assertEqual(view.provider.sls_url, "") + + @patch("authentik.providers.saml.views.sp_slo.AuthenticatedSession") + def test_relay_state_propagation(self, mock_auth_session): + """Test that relay state from logout request is passed through to response""" + mock_auth_session.from_request.return_value = None + + self.provider.logout_method = SAMLLogoutMethods.FRONTCHANNEL_IFRAME + self.provider.save() + + expected_relay_state = "https://sp.example.com/custom-return" + + processor = LogoutRequestProcessor( + provider=self.provider, + user=None, + destination="https://idp.example.com/sls", + name_id="test@example.com", + name_id_format=SAML_NAME_ID_FORMAT_EMAIL, + session_index="test-session-123", + relay_state=expected_relay_state, + ) + encoded_request = processor.encode_redirect() + + request = self.factory.get( + f"/slo/redirect/{self.application.slug}/", + { + "SAMLRequest": encoded_request, + "RelayState": expected_relay_state, + }, + ) + request.session = {} + request.brand = self.brand + request.user = MagicMock() + + view = SPInitiatedSLOBindingRedirectView() + view.setup(request, application_slug=self.application.slug) + view.resolve_provider_application() + view.check_saml_request() + + # Verify relay state was captured + logout_request = view.plan_context.get("authentik/providers/saml/logout_request") + self.assertEqual(logout_request.relay_state, expected_relay_state) diff --git a/authentik/providers/saml/views/sp_slo.py b/authentik/providers/saml/views/sp_slo.py index b7f3015e5a..9c232c4100 100644 --- a/authentik/providers/saml/views/sp_slo.py +++ b/authentik/providers/saml/views/sp_slo.py @@ -15,10 +15,22 @@ from authentik.flows.stage import SessionEndStage 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 SAMLProvider, SAMLSession +from authentik.providers.saml.models import ( + SAMLBindings, + SAMLLogoutMethods, + SAMLProvider, + 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 from authentik.providers.saml.views.flows import ( + PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS, + PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS, PLAN_CONTEXT_SAML_LOGOUT_REQUEST, PLAN_CONTEXT_SAML_RELAY_STATE, REQUEST_KEY_RELAY_STATE, @@ -68,7 +80,102 @@ class SPInitiatedSLOView(PolicyAccessView): **self.plan_context, }, ) - plan.append_stage(in_memory_stage(SessionEndStage)) + + if self.provider.sls_url: + # Get logout request and extract relay state + logout_request = self.plan_context.get(PLAN_CONTEXT_SAML_LOGOUT_REQUEST) + relay_state = logout_request.relay_state if logout_request else None + + # Store relay state for the logout response + plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state + + if self.provider.logout_method == SAMLLogoutMethods.FRONTCHANNEL_NATIVE: + # Native mode - user will be redirected/posted away from authentik + processor = LogoutResponseProcessor( + self.provider, + logout_request, + destination=self.provider.sls_url, + ) + + if self.provider.sls_binding == SAMLBindings.POST: + logout_response = processor.encode_post() + logout_data = { + "post_url": self.provider.sls_url, + "saml_response": logout_response, + "saml_relay_state": relay_state, + "provider_name": self.provider.name, + "saml_binding": SAMLBindings.POST, + } + else: + logout_url = processor.get_redirect_url() + logout_data = { + "redirect_url": logout_url, + "provider_name": self.provider.name, + "saml_binding": SAMLBindings.REDIRECT, + } + + plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [logout_data] + plan.append_stage(in_memory_stage(NativeLogoutStageView)) + elif self.provider.logout_method == SAMLLogoutMethods.BACKCHANNEL: + # Backchannel mode - server sends logout response directly to SP in background + # No user interaction needed + if self.provider.sls_binding != SAMLBindings.POST: + LOGGER.warning( + "Backchannel logout requires POST binding, but provider is configured " + "with %s binding", + self.provider.sls_binding, + provider=self.provider, + ) + + # Queue the logout response to be sent in the background + # This doesn't block the user's logout from completing + send_saml_logout_response.send( + provider_pk=self.provider.pk, + sls_url=self.provider.sls_url, + logout_request_id=logout_request.id if logout_request else None, + relay_state=relay_state, + ) + + LOGGER.debug( + "Queued backchannel logout response", + provider=self.provider, + sls_url=self.provider.sls_url, + ) + + # Just end the session - no user interaction needed + plan.append_stage(in_memory_stage(SessionEndStage)) + else: + # Iframe mode (default for FRONTCHANNEL_IFRAME) - user stays on authentik + processor = LogoutResponseProcessor( + self.provider, + logout_request, + destination=self.provider.sls_url, + ) + + logout_response = processor.build_response() + + if self.provider.sls_binding == SAMLBindings.POST: + logout_data = { + "url": self.provider.sls_url, + "saml_response": nice64(logout_response), + "saml_relay_state": relay_state, + "provider_name": self.provider.name, + "binding": SAMLBindings.POST, + } + else: + logout_url = processor.get_redirect_url() + logout_data = { + "url": logout_url, + "provider_name": self.provider.name, + "binding": SAMLBindings.REDIRECT, + } + + plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [logout_data] + plan.append_stage(in_memory_stage(IframeLogoutStageView)) + plan.append_stage(in_memory_stage(SessionEndStage)) + else: + # No SLS URL configured, just end session + plan.append_stage(in_memory_stage(SessionEndStage)) # Remove samlsession from database auth_session = AuthenticatedSession.from_request(self.request, self.request.user) diff --git a/blueprints/schema.json b/blueprints/schema.json index 67ee06c4e0..91ade14742 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -10759,6 +10759,10 @@ "type": "boolean", "title": "Sign logout request" }, + "sign_logout_response": { + "type": "boolean", + "title": "Sign logout response" + }, "sp_binding": { "type": "string", "enum": [ diff --git a/schema.yml b/schema.yml index 7da23370bf..8d04623ab7 100644 --- a/schema.yml +++ b/schema.yml @@ -18677,6 +18677,10 @@ paths: name: sign_logout_request schema: type: boolean + - in: query + name: sign_logout_response + schema: + type: boolean - in: query name: sign_response schema: @@ -19863,6 +19867,10 @@ paths: name: sign_logout_request schema: type: boolean + - in: query + name: sign_logout_response + schema: + type: boolean - in: query name: sign_response schema: @@ -40696,8 +40704,7 @@ components: logout_urls: type: array items: - type: object - additionalProperties: {} + $ref: '#/components/schemas/LogoutURL' IframeLogoutChallengeResponseRequest: type: object description: Response for iframe logout @@ -42393,6 +42400,29 @@ components: required: - challenge - name + LogoutURL: + type: object + description: Data for a single logout URL + properties: + url: + type: string + provider_name: + type: string + nullable: true + binding: + type: string + nullable: true + saml_request: + type: string + nullable: true + saml_response: + type: string + nullable: true + saml_relay_state: + type: string + nullable: true + required: + - url MDMConfigRequest: type: object description: Base serializer class which doesn't implement create/update methods @@ -42956,21 +42986,23 @@ components: type: array items: $ref: '#/components/schemas/ErrorDetail' - post_url: - type: string - saml_request: - type: string - relay_state: - type: string provider_name: type: string - binding: - type: string - redirect_url: - type: string is_complete: type: boolean default: false + post_url: + type: string + redirect_url: + type: string + saml_binding: + $ref: '#/components/schemas/SAMLBindingsEnum' + saml_request: + type: string + saml_response: + type: string + saml_relay_state: + type: string NativeLogoutChallengeResponseRequest: type: object description: Response for native browser logout @@ -49905,6 +49937,8 @@ components: type: boolean sign_logout_request: type: boolean + sign_logout_response: + type: boolean sp_binding: allOf: - $ref: '#/components/schemas/SAMLBindingsEnum' @@ -53397,6 +53431,8 @@ components: type: boolean sign_logout_request: type: boolean + sign_logout_response: + type: boolean sp_binding: allOf: - $ref: '#/components/schemas/SAMLBindingsEnum' @@ -53591,6 +53627,8 @@ components: type: boolean sign_logout_request: type: boolean + sign_logout_response: + type: boolean sp_binding: allOf: - $ref: '#/components/schemas/SAMLBindingsEnum' diff --git a/web/src/admin/providers/saml/SAMLProviderFormForm.ts b/web/src/admin/providers/saml/SAMLProviderFormForm.ts index 42cf75c71c..16c6e2ded9 100644 --- a/web/src/admin/providers/saml/SAMLProviderFormForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts @@ -72,6 +72,13 @@ function renderHasSigningKp(provider: Partial) { ?checked=${provider?.signLogoutRequest ?? false} help=${msg("When enabled, SAML logout requests will be signed.")} > + + `; } diff --git a/web/src/flow/providers/IFrameLogoutStage.ts b/web/src/flow/providers/IFrameLogoutStage.ts index a26a8cdf7f..2447b318d0 100644 --- a/web/src/flow/providers/IFrameLogoutStage.ts +++ b/web/src/flow/providers/IFrameLogoutStage.ts @@ -5,6 +5,7 @@ import { BaseStage } from "#flow/stages/base"; import { FlowChallengeResponseRequest, IframeLogoutChallenge, + LogoutURL, SAMLBindingsEnum, } from "@goauthentik/api"; @@ -19,28 +20,26 @@ import PFLogin from "@patternfly/patternfly/components/Login/login.css"; import PFProgress from "@patternfly/patternfly/components/Progress/progress.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +enum LogoutStatusStatus { + Pending = "pending", + Success = "success", + Error = "error", +} + interface LogoutStatus { providerName: string; - status: "pending" | "success" | "error"; + status: LogoutStatusStatus; } -interface LogoutURLData { - url: string; - saml_request?: string; - provider_name?: string; - binding?: string; -} - -function renderStatusIcon(status: string): TemplateResult | typeof nothing { +function renderStatusIcon(status: LogoutStatusStatus): TemplateResult | typeof nothing { switch (status) { - case "pending": + case LogoutStatusStatus.Pending: return html``; - case "success": + case LogoutStatusStatus.Success: return html``; - case "error": + case LogoutStatusStatus.Error: return html``; } - return nothing; } @customElement("ak-provider-iframe-logout") @@ -110,12 +109,12 @@ export class IFrameLogoutStage extends BaseStage< super.firstUpdated(changedProperties); // Initialize status tracking - const logoutUrls = (this.challenge?.logoutUrls as LogoutURLData[]) || []; + const logoutUrls = (this.challenge?.logoutUrls as LogoutURL[]) || []; this.logoutStatuses = logoutUrls.map( (url): LogoutStatus => ({ - providerName: url.provider_name || msg("Unknown Provider"), - status: "pending", + providerName: url.providerName || msg("Unknown Provider"), + status: LogoutStatusStatus.Pending, }), ); @@ -124,7 +123,7 @@ export class IFrameLogoutStage extends BaseStage< } protected async performLogouts(): Promise { - const logoutUrls = (this.challenge?.logoutUrls as LogoutURLData[]) || []; + const logoutUrls = (this.challenge?.logoutUrls as LogoutURL[]) || []; // Create iframes for each logout URL logoutUrls.forEach((logoutData, index) => { @@ -140,7 +139,7 @@ export class IFrameLogoutStage extends BaseStage< }, 6000); // 6 seconds (5 second timeout + 1 second buffer) } - protected createLogoutIframe(logoutData: LogoutURLData, index: number): void { + protected createLogoutIframe(logoutData: LogoutURL, index: number): void { const iframe = document.createElement("iframe"); iframe.style.display = "none"; iframe.name = `saml-logout-${index}`; @@ -167,7 +166,10 @@ export class IFrameLogoutStage extends BaseStage< }); // Handle based on binding type - if (logoutData.binding === SAMLBindingsEnum.Redirect || !logoutData.saml_request) { + if ( + logoutData.binding === SAMLBindingsEnum.Redirect || + (!logoutData.samlRequest && !logoutData.samlResponse) + ) { // For REDIRECT binding, just navigate the iframe to the URL iframe.src = logoutData.url; } else { @@ -177,12 +179,29 @@ export class IFrameLogoutStage extends BaseStage< form.action = logoutData.url; form.target = iframe.name; - // Add SAML request - const samlInput = document.createElement("input"); - samlInput.type = "hidden"; - samlInput.name = "SAMLRequest"; - samlInput.value = logoutData.saml_request; - form.appendChild(samlInput); + // Add SAML request OR response (depending on which is present) + if (logoutData.samlRequest) { + const samlInput = document.createElement("input"); + samlInput.type = "hidden"; + samlInput.name = "SAMLRequest"; + samlInput.value = logoutData.samlRequest; + form.appendChild(samlInput); + } else if (logoutData.samlResponse) { + const samlInput = document.createElement("input"); + samlInput.type = "hidden"; + samlInput.name = "SAMLResponse"; + samlInput.value = logoutData.samlResponse; + form.appendChild(samlInput); + } + + // Add RelayState if present + if (logoutData.samlRelayState) { + const relayInput = document.createElement("input"); + relayInput.type = "hidden"; + relayInput.name = "RelayState"; + relayInput.value = logoutData.samlRelayState; + form.appendChild(relayInput); + } // Add to document and submit document.body.appendChild(form); @@ -198,7 +217,7 @@ export class IFrameLogoutStage extends BaseStage< const statuses = [...this.logoutStatuses]; statuses[index] = { ...statuses[index], - status: success ? "success" : "error", + status: success ? LogoutStatusStatus.Success : LogoutStatusStatus.Error, }; this.logoutStatuses = statuses; diff --git a/web/src/flow/providers/saml/NativeLogoutStage.ts b/web/src/flow/providers/saml/NativeLogoutStage.ts index 87f7765eb6..f26dc42c3a 100644 --- a/web/src/flow/providers/saml/NativeLogoutStage.ts +++ b/web/src/flow/providers/saml/NativeLogoutStage.ts @@ -46,12 +46,12 @@ export class NativeLogoutStage extends BaseStage< } // If POST binding, auto-submit the form - if (this.challenge.binding === SAMLBindingsEnum.Post && this.#formRef.value) { + if (this.challenge.samlBinding === SAMLBindingsEnum.Post && this.#formRef.value) { this.#formRef.value.submit(); } // If redirect binding, perform the redirect - if (this.challenge.binding === SAMLBindingsEnum.Redirect) { + if (this.challenge.samlBinding === SAMLBindingsEnum.Redirect) { if (!this.challenge.redirectUrl) { throw new TypeError(`Binding challenge does not a have a redirect URL`); } @@ -78,38 +78,48 @@ export class NativeLogoutStage extends BaseStage< } // For redirect binding, just show loading and firstUpdated will redirect for us - if (this.challenge.binding === SAMLBindingsEnum.Redirect) { + if (this.challenge.samlBinding === SAMLBindingsEnum.Redirect) { return html` ${msg(str`Redirecting to SAML provider: ${providerName}`)} `; } - if (this.challenge.binding !== SAMLBindingsEnum.Post) { - throw new TypeError(`Unknown challenge binding type ${this.challenge.binding}`); + if (this.challenge.samlBinding !== SAMLBindingsEnum.Post) { + throw new TypeError(`Unknown challenge binding type ${this.challenge.samlBinding}`); } // For POST binding, render auto-submit form - if (this.challenge.binding === SAMLBindingsEnum.Post) { + if (this.challenge.samlBinding === SAMLBindingsEnum.Post) { + const title = this.challenge.samlResponse + ? msg(str`Posting logout response to SAML provider: ${providerName}`) + : msg(str`Posting logout request to SAML provider: ${providerName}`); return html` - ${msg(str`Posting logout request to SAML provider: ${providerName}`)} + ${title}
- - ${this.challenge.relayState + ${this.challenge.samlRequest + ? html`` + : nothing} + ${this.challenge.samlResponse + ? html`` + : nothing} + ${this.challenge.samlRelayState ? html`` : nothing}