diff --git a/authentik/enterprise/providers/ws_federation/api/providers.py b/authentik/enterprise/providers/ws_federation/api/providers.py
index 5aa5d00717..24a34d4f52 100644
--- a/authentik/enterprise/providers/ws_federation/api/providers.py
+++ b/authentik/enterprise/providers/ws_federation/api/providers.py
@@ -4,6 +4,7 @@ from django.http import HttpRequest
from django.urls import reverse
from rest_framework.fields import CharField, SerializerMethodField, URLField
+from authentik.common.saml.constants import DEFAULT_ISSUER
from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Provider
from authentik.enterprise.api import EnterpriseRequiredMixin
@@ -18,6 +19,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
reply_url = URLField(source="acs_url")
wtrealm = CharField(source="audience")
url_wsfed = SerializerMethodField()
+ url_issuer = SerializerMethodField()
def get_url_download_metadata(self, instance: WSFederationProvider) -> str:
"""Get metadata download URL"""
@@ -47,7 +49,32 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
- return request.build_absolute_uri(reverse("authentik_providers_ws_federation:wsfed"))
+ try:
+ return request.build_absolute_uri(
+ reverse(
+ "authentik_providers_ws_federation:wsfed-app-specific",
+ kwargs={"application_slug": instance.application.slug},
+ )
+ )
+ except Provider.application.RelatedObjectDoesNotExist:
+ return ""
+
+ def get_url_issuer(self, instance: WSFederationProvider) -> str:
+ """Get Issuer/EntityID URL"""
+ if instance.issuer_override:
+ return instance.issuer_override
+ if "request" not in self._context:
+ return DEFAULT_ISSUER
+ request: HttpRequest = self._context["request"]._request
+ try:
+ return request.build_absolute_uri(
+ reverse(
+ "authentik_providers_ws_federation:metadata-download",
+ kwargs={"application_slug": instance.application.slug},
+ )
+ )
+ except Provider.application.RelatedObjectDoesNotExist:
+ return DEFAULT_ISSUER
class Meta(SAMLProviderSerializer.Meta):
model = WSFederationProvider
@@ -60,6 +87,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
"property_mappings",
"name_id_mapping",
"authn_context_class_ref_mapping",
+ "saml_version",
"digest_algorithm",
"signature_algorithm",
"signing_kp",
@@ -69,6 +97,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
"default_name_id_policy",
"url_download_metadata",
"url_wsfed",
+ "url_issuer",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
diff --git a/authentik/enterprise/providers/ws_federation/migrations/0002_wsfederationprovider_saml_version.py b/authentik/enterprise/providers/ws_federation/migrations/0002_wsfederationprovider_saml_version.py
new file mode 100644
index 0000000000..f62809e4e9
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/migrations/0002_wsfederationprovider_saml_version.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.15 on 2026-07-08 12:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_providers_ws_federation", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="wsfederationprovider",
+ name="saml_version",
+ field=models.TextField(
+ choices=[("1.1", "SAML 1.1"), ("2.0", "SAML 2.0")],
+ default="2.0",
+ help_text="SAML assertion version to issue in the security token. Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.",
+ ),
+ ),
+ ]
diff --git a/authentik/enterprise/providers/ws_federation/models.py b/authentik/enterprise/providers/ws_federation/models.py
index d59e52ac5f..f303de6df5 100644
--- a/authentik/enterprise/providers/ws_federation/models.py
+++ b/authentik/enterprise/providers/ws_federation/models.py
@@ -1,3 +1,4 @@
+from django.db import models
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
@@ -5,6 +6,13 @@ from rest_framework.serializers import Serializer
from authentik.providers.saml.models import SAMLProvider
+class WSFederationSAMLVersion(models.TextChoices):
+ """SAML Assertion version issued by a WS-Federation provider"""
+
+ SAML_1_1 = "1.1", _("SAML 1.1")
+ SAML_2_0 = "2.0", _("SAML 2.0")
+
+
class WSFederationProvider(SAMLProvider):
"""WS-Federation for applications which support WS-Fed."""
@@ -12,6 +20,15 @@ class WSFederationProvider(SAMLProvider):
# - acs_url -> reply_url
# - audience -> realm / wtrealm
+ saml_version = models.TextField(
+ choices=WSFederationSAMLVersion.choices,
+ default=WSFederationSAMLVersion.SAML_2_0,
+ help_text=_(
+ "SAML assertion version to issue in the security token. Microsoft Entra ID and "
+ "classic ADFS-style relying parties typically require SAML 1.1."
+ ),
+ )
+
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.ws_federation.api.providers import (
diff --git a/authentik/enterprise/providers/ws_federation/processors/assertion_saml11.py b/authentik/enterprise/providers/ws_federation/processors/assertion_saml11.py
new file mode 100644
index 0000000000..e67f92b008
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/processors/assertion_saml11.py
@@ -0,0 +1,218 @@
+"""SAML 1.1 Assertion generator for WS-Federation"""
+
+from hashlib import sha256
+from types import GeneratorType
+
+import xmlsec
+from lxml.etree import Element, SubElement, _Element # nosec
+from structlog.stdlib import get_logger
+
+from authentik.common.saml.constants import (
+ DIGEST_ALGORITHM_TRANSLATION_MAP,
+ NS_SIGNATURE,
+ SIGN_ALGORITHM_TRANSFORM_MAP,
+)
+from authentik.core.expression.exceptions import PropertyMappingExpressionException
+from authentik.events.models import Event, EventAction
+from authentik.events.signals import get_login_event
+from authentik.lib.xml import remove_xml_newlines
+from authentik.providers.saml.models import SAMLPropertyMapping
+from authentik.providers.saml.processors.assertion import AssertionProcessor
+from authentik.sources.saml.exceptions import InvalidSignature
+from authentik.stages.password.stage import PLAN_CONTEXT_METHOD
+
+LOGGER = get_logger()
+
+# SAML 1.0 and 1.1 share this namespace; version is set via MajorVersion/MinorVersion attributes
+NS_SAML11_ASSERTION = "urn:oasis:names:tc:SAML:1.0:assertion"
+NS_MAP_SAML11 = {"saml": NS_SAML11_ASSERTION, "ds": NS_SIGNATURE}
+
+SAML11_CM_BEARER = "urn:oasis:names:tc:SAML:1.0:cm:bearer"
+SAML11_AM_PASSWORD = "urn:oasis:names:tc:SAML:1.0:am:password"
+SAML11_AM_UNSPECIFIED = "urn:oasis:names:tc:SAML:1.0:am:unspecified"
+WSFED_ATTRIBUTE_NAMESPACE = "http://schemas.xmlsoap.org/claims"
+
+
+class SAML11AssertionProcessor(AssertionProcessor):
+ """SAML 1.1 assertion builder, overriding the SAML 2.0 methods that differ."""
+
+ def get_name_id(self) -> _Element:
+ # same value/format resolution as SAML 2.0, wrapped as NameIdentifier instead of NameID
+ name_id = super().get_name_id()
+ name_identifier = Element(f"{{{NS_SAML11_ASSERTION}}}NameIdentifier")
+ name_identifier.attrib["Format"] = name_id.attrib["Format"]
+ name_identifier.text = name_id.text
+ return name_identifier
+
+ def get_assertion_subject(self) -> _Element:
+ # SAML 1.1 has no assertion-level Subject; each statement embeds its own
+ subject = Element(f"{{{NS_SAML11_ASSERTION}}}Subject")
+ subject.append(self.get_name_id())
+
+ subject_confirmation = SubElement(subject, f"{{{NS_SAML11_ASSERTION}}}SubjectConfirmation")
+ confirmation_method = SubElement(
+ subject_confirmation, f"{{{NS_SAML11_ASSERTION}}}ConfirmationMethod"
+ )
+ confirmation_method.text = SAML11_CM_BEARER
+ return subject
+
+ def get_assertion_conditions(self) -> _Element:
+ conditions = Element(f"{{{NS_SAML11_ASSERTION}}}Conditions")
+ conditions.attrib["NotBefore"] = self._valid_not_before
+ conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
+ if self.provider.audience != "":
+ audience_restriction_condition = SubElement(
+ conditions, f"{{{NS_SAML11_ASSERTION}}}AudienceRestrictionCondition"
+ )
+ audience = SubElement(
+ audience_restriction_condition, f"{{{NS_SAML11_ASSERTION}}}Audience"
+ )
+ audience.text = self.provider.audience
+ return conditions
+
+ def get_assertion_auth_n_statement(self) -> _Element:
+ auth_statement = Element(f"{{{NS_SAML11_ASSERTION}}}AuthenticationStatement")
+ auth_statement.attrib["AuthenticationInstant"] = self._auth_instant
+ auth_statement.attrib["AuthenticationMethod"] = SAML11_AM_UNSPECIFIED
+ self.session_index = sha256(
+ self.http_request.session.session_key.encode("ascii")
+ ).hexdigest()
+ event = get_login_event(self.http_request)
+ if event and event.context.get(PLAN_CONTEXT_METHOD, "") == "password":
+ auth_statement.attrib["AuthenticationMethod"] = SAML11_AM_PASSWORD
+
+ if self.provider.authn_context_class_ref_mapping:
+ try:
+ value = self.provider.authn_context_class_ref_mapping.evaluate(
+ user=self.http_request.user,
+ request=self.http_request,
+ provider=self.provider,
+ )
+ if value is not None:
+ auth_statement.attrib["AuthenticationMethod"] = str(value)
+ except PropertyMappingExpressionException as exc:
+ Event.new(
+ EventAction.CONFIGURATION_ERROR,
+ message=(
+ "Failed to evaluate property-mapping: "
+ f"'{self.provider.authn_context_class_ref_mapping.name}'"
+ ),
+ provider=self.provider,
+ mapping=self.provider.authn_context_class_ref_mapping,
+ ).from_http(self.http_request)
+ LOGGER.warning("Failed to evaluate property mapping", exc=exc)
+
+ auth_statement.append(self.get_assertion_subject())
+ return auth_statement
+
+ def get_attributes(self) -> _Element | None:
+ # None if empty: SAML 1.1's schema requires at least one Attribute per AttributeStatement
+ attribute_statement = Element(f"{{{NS_SAML11_ASSERTION}}}AttributeStatement")
+ attribute_statement.append(self.get_assertion_subject())
+ user = self.http_request.user
+ has_attribute = False
+ for mapping in SAMLPropertyMapping.objects.filter(provider=self.provider).order_by(
+ "saml_name"
+ ):
+ try:
+ mapping: SAMLPropertyMapping
+ value = mapping.evaluate(
+ user=user,
+ request=self.http_request,
+ provider=self.provider,
+ )
+ if value is None:
+ continue
+
+ attribute = Element(f"{{{NS_SAML11_ASSERTION}}}Attribute")
+ attribute.attrib["AttributeName"] = mapping.saml_name
+ attribute.attrib["AttributeNamespace"] = WSFED_ATTRIBUTE_NAMESPACE
+
+ if not isinstance(value, list | GeneratorType):
+ value = [value]
+
+ for value_item in value:
+ attribute_value = SubElement(
+ attribute, f"{{{NS_SAML11_ASSERTION}}}AttributeValue"
+ )
+ str_value = str(value_item) if not isinstance(value_item, str) else value_item
+ attribute_value.text = str_value
+
+ attribute_statement.append(attribute)
+ has_attribute = True
+ except (PropertyMappingExpressionException, ValueError) as exc:
+ Event.new(
+ EventAction.CONFIGURATION_ERROR,
+ message=f"Failed to evaluate property-mapping: '{mapping.name}'",
+ provider=self.provider,
+ mapping=mapping,
+ ).from_http(self.http_request)
+ LOGGER.warning("Failed to evaluate property mapping", exc=exc)
+ continue
+ if not has_attribute:
+ return None
+ return attribute_statement
+
+ def get_assertion(self) -> _Element:
+ assertion = Element(f"{{{NS_SAML11_ASSERTION}}}Assertion", nsmap=NS_MAP_SAML11)
+ assertion.attrib["MajorVersion"] = "1"
+ assertion.attrib["MinorVersion"] = "1"
+ assertion.attrib["AssertionID"] = self._assertion_id
+ assertion.attrib["IssueInstant"] = self._issue_instant
+ self.issuer = self._get_issuer_value()
+ assertion.attrib["Issuer"] = self.issuer
+
+ assertion.append(self.get_assertion_conditions())
+ assertion.append(self.get_assertion_auth_n_statement())
+ attribute_statement = self.get_attributes()
+ if attribute_statement is not None:
+ assertion.append(attribute_statement)
+
+ # unlike SAML 2.0, ds:Signature must be the last child of Assertion
+ if self.provider.signing_kp and self.provider.sign_assertion:
+ sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
+ self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
+ )
+ signature = xmlsec.template.create(
+ assertion,
+ xmlsec.constants.TransformExclC14N,
+ sign_algorithm_transform,
+ ns=xmlsec.constants.DSigNs,
+ )
+ assertion.append(signature)
+
+ return assertion
+
+ def _sign(self, element: _Element):
+ # same as AssertionProcessor._sign, but referencing AssertionID instead of ID
+ digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
+ self.provider.digest_algorithm, xmlsec.constants.TransformSha1
+ )
+ xmlsec.tree.add_ids(element, ["AssertionID"])
+ signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
+ ref = xmlsec.template.add_reference(
+ signature_node,
+ digest_algorithm_transform,
+ uri="#" + element.attrib["AssertionID"],
+ )
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
+ xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
+ key_info = xmlsec.template.ensure_key_info(signature_node)
+ xmlsec.template.add_x509_data(key_info)
+
+ ctx = xmlsec.SignatureContext()
+
+ key = xmlsec.Key.from_memory(
+ self.provider.signing_kp.key_data,
+ xmlsec.constants.KeyDataFormatPem,
+ None,
+ )
+ key.load_cert_from_memory(
+ self.provider.signing_kp.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ )
+ ctx.key = key
+ try:
+ ctx.sign(remove_xml_newlines(element, signature_node))
+ except xmlsec.Error as exc:
+ raise InvalidSignature() from exc
diff --git a/authentik/enterprise/providers/ws_federation/processors/constants.py b/authentik/enterprise/providers/ws_federation/processors/constants.py
index 4bcdaa48af..5e1b1702bd 100644
--- a/authentik/enterprise/providers/ws_federation/processors/constants.py
+++ b/authentik/enterprise/providers/ws_federation/processors/constants.py
@@ -1,9 +1,13 @@
from authentik.common.saml.constants import NS_MAP as _map
+from authentik.enterprise.providers.ws_federation.models import WSFederationSAMLVersion
WS_FED_ACTION_SIGN_IN = "wsignin1.0"
WS_FED_ACTION_SIGN_OUT = "wsignout1.0"
WS_FED_ACTION_SIGN_OUT_CLEANUP = "wsignoutcleanup1.0"
+WS_FED_QS_ACTION = "wa"
+WS_FED_QS_REALM = "wtrealm"
+WS_FED_QS_REPLY = "wreply"
WS_FED_POST_KEY_ACTION = "wa"
WS_FED_POST_KEY_RESULT = "wresult"
WS_FED_POST_KEY_CONTEXT = "wctx"
@@ -11,10 +15,18 @@ WS_FED_POST_KEY_CONTEXT = "wctx"
WSS_TOKEN_TYPE_SAML2 = (
"http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0" # nosec
)
+WSS_TOKEN_TYPE_SAML11 = (
+ "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" # nosec
+)
WSS_KEY_IDENTIFIER_SAML_ID = (
"http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLID"
)
+WSS_TOKEN_TYPE_BY_VERSION = {
+ WSFederationSAMLVersion.SAML_1_1: WSS_TOKEN_TYPE_SAML11,
+ WSFederationSAMLVersion.SAML_2_0: WSS_TOKEN_TYPE_SAML2,
+}
+
NS_WS_FED_PROTOCOL = "http://docs.oasis-open.org/wsfed/federation/200706"
NS_WS_FED_TRUST = "http://schemas.xmlsoap.org/ws/2005/02/trust"
NS_WSI = "http://www.w3.org/2001/XMLSchema-instance"
diff --git a/authentik/enterprise/providers/ws_federation/processors/metadata.py b/authentik/enterprise/providers/ws_federation/processors/metadata.py
index 2acaf27f27..fe17eadf95 100644
--- a/authentik/enterprise/providers/ws_federation/processors/metadata.py
+++ b/authentik/enterprise/providers/ws_federation/processors/metadata.py
@@ -22,7 +22,12 @@ class MetadataProcessor(BaseMetadataProcessor):
address = SubElement(endpoint_ref, f"{{{NS_ADDRESSING}}}Address", nsmap=NS_MAP)
address.text = self.http_request.build_absolute_uri(
- reverse("authentik_providers_ws_federation:wsfed")
+ reverse(
+ "authentik_providers_ws_federation:wsfed-app-specific",
+ kwargs={
+ "application_slug": self.provider.application.slug,
+ },
+ )
)
def add_role_descriptor_sts(self, entity_descriptor: _Element):
diff --git a/authentik/enterprise/providers/ws_federation/processors/sign_in.py b/authentik/enterprise/providers/ws_federation/processors/sign_in.py
index 7c4d310c02..a08d6a7657 100644
--- a/authentik/enterprise/providers/ws_federation/processors/sign_in.py
+++ b/authentik/enterprise/providers/ws_federation/processors/sign_in.py
@@ -7,7 +7,13 @@ from lxml import etree # nosec
from lxml.etree import Element, SubElement, _Element # nosec
from authentik.core.models import Application
-from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.models import (
+ WSFederationProvider,
+ WSFederationSAMLVersion,
+)
+from authentik.enterprise.providers.ws_federation.processors.assertion_saml11 import (
+ SAML11AssertionProcessor,
+)
from authentik.enterprise.providers.ws_federation.processors.constants import (
NS_ADDRESSING,
NS_MAP,
@@ -20,8 +26,11 @@ from authentik.enterprise.providers.ws_federation.processors.constants import (
WS_FED_POST_KEY_ACTION,
WS_FED_POST_KEY_CONTEXT,
WS_FED_POST_KEY_RESULT,
+ WS_FED_QS_ACTION,
+ WS_FED_QS_REALM,
+ WS_FED_QS_REPLY,
WSS_KEY_IDENTIFIER_SAML_ID,
- WSS_TOKEN_TYPE_SAML2,
+ WSS_TOKEN_TYPE_BY_VERSION,
)
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.utils import delete_none_values
@@ -39,28 +48,26 @@ class SignInRequest:
@staticmethod
def parse(request: HttpRequest) -> SignInRequest:
- action = request.GET.get("wa")
- if action != WS_FED_ACTION_SIGN_IN:
- raise ValueError("Invalid action")
- realm = request.GET.get("wtrealm")
- if not realm:
- raise ValueError("Missing Realm")
-
req = SignInRequest(
- wa=action,
- wtrealm=realm,
- wreply=request.GET.get("wreply"),
- wctx=request.GET.get("wctx", ""),
+ wa=request.GET.get(WS_FED_QS_ACTION),
+ wtrealm=request.GET.get(WS_FED_QS_REALM),
+ wreply=request.GET.get(WS_FED_QS_REPLY),
+ wctx=request.GET.get(WS_FED_POST_KEY_CONTEXT, ""),
)
+ return req
- _, provider = req.get_app_provider()
- if not req.wreply:
- req.wreply = provider.acs_url
- reply = urlparse(req.wreply)
+ def __post_init__(self):
+ if self.wa != WS_FED_ACTION_SIGN_IN:
+ raise ValueError("Invalid action")
+ if not self.wtrealm:
+ raise ValueError("Missing Realm")
+ _, provider = self.get_app_provider()
+ if not self.wreply:
+ self.wreply = provider.acs_url
+ reply = urlparse(self.wreply)
configured = urlparse(provider.acs_url)
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
raise ValueError("Invalid wreply")
- return req
def get_app_provider(self):
provider: WSFederationProvider = get_object_or_404(
@@ -82,7 +89,12 @@ class SignInProcessor:
self.provider = provider
self.request = request
self.sign_in_request = sign_in_request
- self.saml_processor = AssertionProcessor(self.provider, self.request, AuthNRequest())
+ processor_cls = (
+ SAML11AssertionProcessor
+ if self.provider.saml_version == WSFederationSAMLVersion.SAML_1_1
+ else AssertionProcessor
+ )
+ self.saml_processor = processor_cls(self.provider, self.request, AuthNRequest())
self.saml_processor.provider.audience = self.sign_in_request.wtrealm
if self.provider.signing_kp:
self.saml_processor.provider.sign_assertion = True
@@ -105,7 +117,7 @@ class SignInProcessor:
)
token_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}TokenType")
- token_type.text = WSS_TOKEN_TYPE_SAML2
+ token_type.text = WSS_TOKEN_TYPE_BY_VERSION[self.provider.saml_version]
request_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}RequestType")
request_type.text = "http://schemas.xmlsoap.org/ws/2005/02/trust/Issue"
@@ -143,7 +155,9 @@ class SignInProcessor:
def response_add_attached_reference(self, tag: str, value: str) -> _Element:
ref = Element(f"{{{NS_WS_FED_TRUST}}}{tag}")
sec_token_ref = SubElement(ref, f"{{{NS_WSS_SEC}}}SecurityTokenReference")
- sec_token_ref.attrib[f"{{{NS_WSS_D3P1}}}TokenType"] = WSS_TOKEN_TYPE_SAML2
+ sec_token_ref.attrib[f"{{{NS_WSS_D3P1}}}TokenType"] = WSS_TOKEN_TYPE_BY_VERSION[
+ self.provider.saml_version
+ ]
key_identifier = SubElement(sec_token_ref, f"{{{NS_WSS_SEC}}}KeyIdentifier")
key_identifier.attrib["ValueType"] = WSS_KEY_IDENTIFIER_SAML_ID
@@ -152,7 +166,8 @@ class SignInProcessor:
def response(self) -> dict[str, str]:
root = self.create_response_token()
- assertion = root.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
+ # match by local name, since "saml" may be bound to the 1.1 or 2.0 namespace here
+ assertion = root.xpath("//*[local-name()='Assertion']")[0]
if self.provider.signing_kp:
self.saml_processor._sign(assertion)
str_token = etree.tostring(root).decode("utf-8") # nosec
diff --git a/authentik/enterprise/providers/ws_federation/processors/sign_out.py b/authentik/enterprise/providers/ws_federation/processors/sign_out.py
index 1446627f69..2eac298522 100644
--- a/authentik/enterprise/providers/ws_federation/processors/sign_out.py
+++ b/authentik/enterprise/providers/ws_federation/processors/sign_out.py
@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import InitVar, dataclass
from urllib.parse import urlparse
from django.http import HttpRequest
@@ -6,7 +6,12 @@ from django.shortcuts import get_object_or_404
from authentik.core.models import Application
from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
-from authentik.enterprise.providers.ws_federation.processors.constants import WS_FED_ACTION_SIGN_OUT
+from authentik.enterprise.providers.ws_federation.processors.constants import (
+ WS_FED_ACTION_SIGN_OUT,
+ WS_FED_QS_ACTION,
+ WS_FED_QS_REALM,
+ WS_FED_QS_REPLY,
+)
@dataclass()
@@ -14,30 +19,38 @@ class SignOutRequest:
wa: str
wtrealm: str
wreply: str
+ request: InitVar[HttpRequest]
@staticmethod
def parse(request: HttpRequest) -> SignOutRequest:
- action = request.GET.get("wa")
- if action != WS_FED_ACTION_SIGN_OUT:
- raise ValueError("Invalid action")
- realm = request.GET.get("wtrealm")
- if not realm:
- raise ValueError("Missing Realm")
-
- req = SignOutRequest(
- wa=action,
- wtrealm=realm,
- wreply=request.GET.get("wreply"),
+ return SignOutRequest(
+ wa=request.GET.get(WS_FED_QS_ACTION),
+ wtrealm=request.GET.get(WS_FED_QS_REALM),
+ wreply=request.GET.get(WS_FED_QS_REPLY),
+ request=request,
)
- _, provider = req.get_app_provider()
- if not req.wreply:
- req.wreply = provider.acs_url
- reply = urlparse(req.wreply)
+ def __post_init__(self, request: HttpRequest):
+ if self.wa != WS_FED_ACTION_SIGN_OUT:
+ raise ValueError("Invalid action")
+ self.__post_init_resolve_realm(request)
+ _, provider = self.get_app_provider()
+ if not self.wreply:
+ self.wreply = provider.acs_url
+ if not self.wtrealm:
+ raise ValueError("Missing Realm")
+ reply = urlparse(self.wreply)
configured = urlparse(provider.acs_url)
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
raise ValueError("Invalid wreply")
- return req
+
+ def __post_init_resolve_realm(self, request: HttpRequest):
+ slug = request.resolver_match.kwargs.get("application_slug")
+ if not slug:
+ return
+ app = get_object_or_404(Application, slug=slug)
+ provider = get_object_or_404(WSFederationProvider, pk=app.provider_id)
+ self.wtrealm = provider.audience
def get_app_provider(self):
provider: WSFederationProvider = get_object_or_404(
diff --git a/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py b/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py
index 066e3135f7..790d83ecc3 100644
--- a/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py
+++ b/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py
@@ -5,11 +5,18 @@ from lxml import etree # nosec
from authentik.core.models import Application
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
-from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.models import (
+ WSFederationProvider,
+ WSFederationSAMLVersion,
+)
+from authentik.enterprise.providers.ws_federation.processors.assertion_saml11 import (
+ NS_SAML11_ASSERTION,
+)
from authentik.enterprise.providers.ws_federation.processors.constants import (
NS_MAP,
WS_FED_ACTION_SIGN_IN,
WS_FED_POST_KEY_RESULT,
+ WSS_TOKEN_TYPE_SAML11,
)
from authentik.enterprise.providers.ws_federation.processors.sign_in import (
SignInProcessor,
@@ -17,6 +24,7 @@ from authentik.enterprise.providers.ws_federation.processors.sign_in import (
)
from authentik.lib.generators import generate_id
from authentik.lib.xml import lxml_from_string
+from authentik.providers.saml.models import SAMLPropertyMapping
class TestWSFedSignIn(TestCase):
@@ -55,7 +63,7 @@ class TestWSFedSignIn(TestCase):
request,
SignInRequest(
wa=WS_FED_ACTION_SIGN_IN,
- wtrealm="",
+ wtrealm=self.provider.audience,
wreply="",
wctx=None,
),
@@ -76,7 +84,7 @@ class TestWSFedSignIn(TestCase):
request,
SignInRequest(
wa=WS_FED_ACTION_SIGN_IN,
- wtrealm="",
+ wtrealm=self.provider.audience,
wreply="",
wctx=None,
),
@@ -96,3 +104,110 @@ class TestWSFedSignIn(TestCase):
None,
)
ctx.verify(signature_node)
+
+
+class TestWSFedSignInSAML11(TestCase):
+ # NS_MAP binds the "saml" prefix to the SAML 2.0 namespace; override it for SAML 1.1 lookups
+ ns_map = {**NS_MAP, "saml": NS_SAML11_ASSERTION}
+
+ def setUp(self):
+ self.flow = create_test_flow()
+ self.cert = create_test_cert()
+ self.provider = WSFederationProvider.objects.create(
+ name=generate_id(),
+ authorization_flow=self.flow,
+ signing_kp=self.cert,
+ acs_url="https://t.goauthentik.io",
+ audience="foo",
+ saml_version=WSFederationSAMLVersion.SAML_1_1,
+ )
+ self.app = Application.objects.create(
+ name=generate_id(), slug=generate_id(), provider=self.provider
+ )
+ self.factory = RequestFactory()
+
+ def _get_token(self) -> str:
+ request = self.factory.get("/", user=get_anonymous_user())
+ proc = SignInProcessor(
+ self.provider,
+ request,
+ SignInRequest(
+ wa=WS_FED_ACTION_SIGN_IN,
+ wtrealm=self.provider.audience,
+ wreply="",
+ wctx=None,
+ ),
+ )
+ return proc.response()[WS_FED_POST_KEY_RESULT]
+
+ def test_token_gen(self):
+ token = self._get_token()
+ root = lxml_from_string(token)
+
+ schema = etree.XMLSchema(
+ etree.parse(source="schemas/ws-trust.xsd", parser=etree.XMLParser()) # nosec
+ )
+ self.assertTrue(schema.validate(etree=root), schema.error_log)
+
+ assertion = root.xpath("//*[local-name()='Assertion']")[0]
+ self.assertEqual(assertion.tag, f"{{{NS_SAML11_ASSERTION}}}Assertion")
+ self.assertEqual(assertion.attrib["MajorVersion"], "1")
+ self.assertEqual(assertion.attrib["MinorVersion"], "1")
+ self.assertIn("AssertionID", assertion.attrib)
+ self.assertNotIn("ID", assertion.attrib)
+
+ assertion_schema = etree.XMLSchema(
+ etree.parse( # nosec
+ source="schemas/oasis-sstc-saml-schema-assertion-1.1.xsd",
+ parser=etree.XMLParser(),
+ )
+ )
+ self.assertTrue(assertion_schema.validate(etree=assertion), assertion_schema.error_log)
+
+ token_type = root.xpath("//t:TokenType", namespaces=self.ns_map)[0]
+ self.assertEqual(token_type.text, WSS_TOKEN_TYPE_SAML11)
+
+ # SAML 1.1 uses NameIdentifier/AuthenticationStatement, not NameID/AuthnStatement
+ self.assertEqual(len(assertion.xpath("//saml:NameIdentifier", namespaces=self.ns_map)), 1)
+ self.assertEqual(
+ len(assertion.xpath("//saml:AuthenticationStatement", namespaces=self.ns_map)), 1
+ )
+
+ def test_signature(self):
+ token = self._get_token()
+
+ root = lxml_from_string(token)
+ xmlsec.tree.add_ids(root, ["AssertionID"])
+ signature_nodes = root.xpath(
+ "//*[local-name()='Assertion']/ds:Signature", namespaces=self.ns_map
+ )
+ self.assertEqual(len(signature_nodes), 1)
+
+ signature_node = signature_nodes[0]
+ ctx = xmlsec.SignatureContext()
+ ctx.key = xmlsec.Key.from_memory(
+ self.cert.certificate_data,
+ xmlsec.constants.KeyDataFormatCertPem,
+ None,
+ )
+ ctx.verify(signature_node)
+
+ def test_attribute_statement(self):
+ mapping = SAMLPropertyMapping.objects.create(
+ name=generate_id(), saml_name="test-claim", expression="return 'test-value'"
+ )
+ self.provider.property_mappings.add(mapping)
+
+ token = self._get_token()
+ root = lxml_from_string(token)
+
+ attributes = root.xpath("//saml:Attribute", namespaces=self.ns_map)
+ self.assertEqual(len(attributes), 1)
+ attribute = attributes[0]
+ self.assertEqual(attribute.attrib["AttributeName"], "test-claim")
+ self.assertIn("AttributeNamespace", attribute.attrib)
+ self.assertNotIn("Name", attribute.attrib)
+ self.assertNotIn("FriendlyName", attribute.attrib)
+ values = attribute.xpath("saml:AttributeValue", namespaces=self.ns_map)
+ self.assertEqual(len(values), 1)
+ self.assertEqual(values[0].text, "test-value")
diff --git a/authentik/enterprise/providers/ws_federation/urls.py b/authentik/enterprise/providers/ws_federation/urls.py
index 39452ec395..78c9a2f060 100644
--- a/authentik/enterprise/providers/ws_federation/urls.py
+++ b/authentik/enterprise/providers/ws_federation/urls.py
@@ -11,6 +11,11 @@ urlpatterns = [
WSFedEntryView.as_view(),
name="wsfed",
),
+ path(
+ "
+ ${msg( + "Microsoft Entra ID and classic ADFS-style relying parties typically require SAML 1.1.", + { id: "wsfed.saml-version.description" }, + )} +
+