Compare commits

...

1 Commits

Author SHA1 Message Date
connor peshek
c75bce9a97 Automatically send logoutResponse via iframe 2025-10-24 02:29:57 -05:00
7 changed files with 248 additions and 2 deletions

View File

@@ -126,6 +126,10 @@ class SessionEndChallenge(WithUserInfoChallenge):
invalidation_flow_url = CharField(required=False)
brand_name = CharField(required=True)
sls_url = CharField(required=False)
sls_binding = CharField(required=False)
logout_response = CharField(required=False)
class PermissionDict(TypedDict):
"""Consent Permission"""

View File

@@ -33,6 +33,7 @@ from authentik.lib.utils.reflection import class_to_path
if TYPE_CHECKING:
from authentik.flows.views.executor import FlowExecutorView
from authentik.providers.saml.models import SAMLProvider
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
HIST_FLOWS_STAGE_TIME = Histogram(
@@ -292,7 +293,11 @@ class SessionEndStage(ChallengeStageView):
"to": reverse("authentik_core:root-redirect"),
},
)
application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
provider: SAMLProvider | None = self.executor.plan.context.get("provider")
logout_response: str | None = self.executor.plan.context.get("logout_response")
data = {
"component": "ak-stage-session-end",
"brand_name": self.request.brand.branding_title,
@@ -300,6 +305,10 @@ class SessionEndStage(ChallengeStageView):
if application:
data["application_name"] = application.name
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
if logout_response:
data["logout_response"] = logout_response
data["sls_url"] = provider.sls_url
data["sls_binding"] = provider.sls_binding
if self.request.brand.flow_invalidation:
data["invalidation_flow_url"] = reverse(
"authentik_core:if-flow",

View File

@@ -0,0 +1,115 @@
"""LogoutResponse processor"""
import xmlsec
from lxml import etree
from lxml.etree import Element, SubElement
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.time import get_time_string
from authentik.sources.saml.processors.constants import (
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
class LogoutResponseProcessor:
"""Generate a SAML LogoutResponse"""
provider: SAMLProvider
logout_request: LogoutRequest
_issue_instant: str
_response_id: str
def __init__(self, provider: SAMLProvider, logout_request: LogoutRequest):
self.provider = provider
self.logout_request = logout_request
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 get_response(self, status: str = "Success", destination: str | None = None) -> Element:
"""Generate LogoutResponse XML"""
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 destination:
response.attrib["Destination"] = destination
if 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}"
# Add signature if configured
if self.provider.signing_kp:
self._add_signature(response)
return response
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 build_response(self, status: str = "Success", destination: str | None = None) -> str:
"""Build and sign the response, return as string"""
response = self.get_response(status, destination)
if self.provider.signing_kp:
self._sign_response(response)
return etree.tostring(response, encoding="unicode", pretty_print=False)
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)

View File

@@ -0,0 +1,54 @@
"""logout response tests"""
from defusedxml import ElementTree
from django.test import TestCase
from authentik.blueprints.tests import apply_blueprint
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
from authentik.sources.saml.processors.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
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)
response_xml = processor.build_response(status="Success", destination=self.provider.sls_url)
# 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")

View File

@@ -16,8 +16,10 @@ 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.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider, SAMLSession
from authentik.providers.saml.models import SAMLBindings, SAMLProvider, SAMLSession
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
from authentik.providers.saml.views.flows import (
PLAN_CONTEXT_SAML_LOGOUT_REQUEST,
PLAN_CONTEXT_SAML_RELAY_STATE,
@@ -68,6 +70,21 @@ class SPInitiatedSLOView(PolicyAccessView):
**self.plan_context,
},
)
processor = LogoutResponseProcessor(
self.provider, self.plan_context.get(PLAN_CONTEXT_SAML_LOGOUT_REQUEST)
)
response_xml = processor.build_response(status="Success", destination=self.provider.sls_url)
# Encode the logout response based on binding type
if self.provider.sls_binding == SAMLBindings.REDIRECT:
# For redirect binding, deflate and base64 encode
encoded_response = deflate_and_base64_encode(response_xml)
else:
# For POST binding, just base64 encode
encoded_response = nice64(response_xml)
plan.context["provider"] = self.provider
plan.context["logout_response"] = encoded_response
plan.append_stage(in_memory_stage(SessionEndStage))
# Remove samlsession from database

View File

@@ -50272,6 +50272,12 @@ components:
type: string
brand_name:
type: string
sls_url:
type: string
sls_binding:
type: string
logout_response:
type: string
required:
- brand_name
- pending_user

View File

@@ -8,7 +8,7 @@ import { BaseStage } from "#flow/stages/base";
import { SessionEndChallenge } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -23,6 +23,47 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
static styles: CSSResult[] = [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this.sendLogoutResponse();
}
protected sendLogoutResponse(): void {
if (!this.challenge.slsUrl || !this.challenge.logoutResponse) {
return;
}
const slsUrl = this.challenge.slsUrl;
const logoutResponse = this.challenge.logoutResponse;
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.name = "saml-logout-response";
document.body.appendChild(iframe);
if (this.challenge.slsBinding === "redirect") {
const params = new URLSearchParams();
params.set("SAMLResponse", logoutResponse);
iframe.src = `${slsUrl}?${params.toString()}`;
} else {
// For POST binding, create form that targets the iframe
const form = document.createElement("form");
form.method = "POST";
form.action = slsUrl;
form.target = iframe.name;
const samlInput = document.createElement("input");
samlInput.type = "hidden";
samlInput.name = "SAMLResponse";
samlInput.value = logoutResponse;
form.appendChild(samlInput);
document.body.appendChild(form);
form.submit();
form.remove();
}
}
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">