mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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 <connor@connorpeshek.me> Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -213,6 +213,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"sign_assertion",
|
||||
"sign_response",
|
||||
"sign_logout_request",
|
||||
"sign_logout_response",
|
||||
"sp_binding",
|
||||
"sls_binding",
|
||||
"logout_method",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10759,6 +10759,10 @@
|
||||
"type": "boolean",
|
||||
"title": "Sign logout request"
|
||||
},
|
||||
"sign_logout_response": {
|
||||
"type": "boolean",
|
||||
"title": "Sign logout response"
|
||||
},
|
||||
"sp_binding": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
+50
-12
@@ -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'
|
||||
|
||||
@@ -72,6 +72,13 @@ function renderHasSigningKp(provider: Partial<SAMLProvider>) {
|
||||
?checked=${provider?.signLogoutRequest ?? false}
|
||||
help=${msg("When enabled, SAML logout requests will be signed.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="signLogoutResponse"
|
||||
label=${msg("Sign logout response")}
|
||||
?checked=${provider?.signLogoutResponse ?? false}
|
||||
help=${msg("When enabled, SAML logout responses will be signed.")}
|
||||
>
|
||||
</ak-switch-input>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`<i class="fas fa-spinner pf-c-spinner status-icon status-pending"></i>`;
|
||||
case "success":
|
||||
case LogoutStatusStatus.Success:
|
||||
return html`<i class="fas fa-check-circle status-icon status-success"></i>`;
|
||||
case "error":
|
||||
case LogoutStatusStatus.Error:
|
||||
return html`<i class="fas fa-times-circle status-icon status-error"></i>`;
|
||||
}
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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`<ak-flow-card .challenge=${this.challenge} loading>
|
||||
<span slot="title">${msg(str`Redirecting to SAML provider: ${providerName}`)}</span>
|
||||
</ak-flow-card>`;
|
||||
}
|
||||
|
||||
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`<ak-flow-card .challenge=${this.challenge} loading>
|
||||
<span slot="title"
|
||||
>${msg(str`Posting logout request to SAML provider: ${providerName}`)}</span
|
||||
>
|
||||
<span slot="title">${title}</span>
|
||||
<form
|
||||
class="pf-c-form"
|
||||
action="${ifDefined(this.challenge.postUrl)}"
|
||||
method="post"
|
||||
${ref(this.#formRef)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="SAMLRequest"
|
||||
value="${ifDefined(this.challenge.samlRequest)}"
|
||||
/>
|
||||
${this.challenge.relayState
|
||||
${this.challenge.samlRequest
|
||||
? html`<input
|
||||
type="hidden"
|
||||
name="SAMLRequest"
|
||||
value="${this.challenge.samlRequest}"
|
||||
/>`
|
||||
: nothing}
|
||||
${this.challenge.samlResponse
|
||||
? html`<input
|
||||
type="hidden"
|
||||
name="SAMLResponse"
|
||||
value="${this.challenge.samlResponse}"
|
||||
/>`
|
||||
: nothing}
|
||||
${this.challenge.samlRelayState
|
||||
? html`<input
|
||||
type="hidden"
|
||||
name="RelayState"
|
||||
value="${this.challenge.relayState}"
|
||||
value="${this.challenge.samlRelayState}"
|
||||
/>`
|
||||
: nothing}
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user