diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml
index e7bf569e8e..5d2b3a5725 100644
--- a/.github/workflows/ci-main.yml
+++ b/.github/workflows/ci-main.yml
@@ -187,6 +187,8 @@ jobs:
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
- name: ldap
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
+ - name: ws-fed
+ glob: tests/e2e/test_provider_ws_fed*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
diff --git a/authentik/enterprise/providers/ws_federation/__init__.py b/authentik/enterprise/providers/ws_federation/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/providers/ws_federation/api/__init__.py b/authentik/enterprise/providers/ws_federation/api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/providers/ws_federation/api/providers.py b/authentik/enterprise/providers/ws_federation/api/providers.py
new file mode 100644
index 0000000000..207463b748
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/api/providers.py
@@ -0,0 +1,69 @@
+"""WSFederationProvider API Views"""
+
+from django.http import HttpRequest
+from django.urls import reverse
+from rest_framework.fields import SerializerMethodField, URLField
+
+from authentik.core.api.providers import ProviderSerializer
+from authentik.core.models import Application
+from authentik.enterprise.api import EnterpriseRequiredMixin
+from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.processors.metadata import MetadataProcessor
+from authentik.providers.saml.api.providers import SAMLProviderSerializer, SAMLProviderViewSet
+
+
+class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerializer):
+ """WSFederationProvider Serializer"""
+
+ reply_url = URLField(source="acs_url")
+ url_wsfed = SerializerMethodField()
+ wtrealm = SerializerMethodField()
+
+ def get_url_wsfed(self, instance: WSFederationProvider) -> str:
+ """Get WS-Fed url"""
+ if "request" not in self._context:
+ return ""
+ request: HttpRequest = self._context["request"]._request
+ return request.build_absolute_uri(reverse("authentik_providers_ws_federation:wsfed"))
+
+ def get_wtrealm(self, instance: WSFederationProvider) -> str:
+ try:
+ return f"goauthentik.io://app/{instance.application.slug}"
+ except Application.DoesNotExist:
+ return None
+
+ class Meta(SAMLProviderSerializer.Meta):
+ model = WSFederationProvider
+ fields = ProviderSerializer.Meta.fields + [
+ "reply_url",
+ "assertion_valid_not_before",
+ "assertion_valid_not_on_or_after",
+ "session_valid_not_on_or_after",
+ "property_mappings",
+ "name_id_mapping",
+ "authn_context_class_ref_mapping",
+ "digest_algorithm",
+ "signature_algorithm",
+ "signing_kp",
+ "encryption_kp",
+ "sign_assertion",
+ "sign_logout_request",
+ "default_name_id_policy",
+ "url_download_metadata",
+ "url_wsfed",
+ "wtrealm",
+ ]
+ extra_kwargs = ProviderSerializer.Meta.extra_kwargs
+
+
+class WSFederationProviderViewSet(SAMLProviderViewSet):
+ """WSFederationProvider Viewset"""
+
+ queryset = WSFederationProvider.objects.all()
+ serializer_class = WSFederationProviderSerializer
+ filterset_fields = "__all__"
+ ordering = ["name"]
+ search_fields = ["name"]
+
+ metadata_generator_class = MetadataProcessor
+ import_metadata = None
diff --git a/authentik/enterprise/providers/ws_federation/apps.py b/authentik/enterprise/providers/ws_federation/apps.py
new file mode 100644
index 0000000000..bbc86a58c9
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/apps.py
@@ -0,0 +1,13 @@
+"""WSFed app config"""
+
+from authentik.enterprise.apps import EnterpriseConfig
+
+
+class AuthentikEnterpriseProviderWSFederatopm(EnterpriseConfig):
+ """authentik enterprise ws federation app config"""
+
+ name = "authentik.enterprise.providers.ws_federation"
+ label = "authentik_providers_ws_federation"
+ verbose_name = "authentik Enterprise.Providers.WS-Federation"
+ default = True
+ mountpoint = "application/wsfed/"
diff --git a/authentik/enterprise/providers/ws_federation/migrations/0001_initial.py b/authentik/enterprise/providers/ws_federation/migrations/0001_initial.py
new file mode 100644
index 0000000000..b875a585b9
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.2.10 on 2026-01-18 23:25
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_providers_saml", "0020_samlprovider_logout_method_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="WSFederationProvider",
+ fields=[
+ (
+ "samlprovider_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_providers_saml.samlprovider",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "WS-Federation Provider",
+ "verbose_name_plural": "WS-Federation Providers",
+ },
+ bases=("authentik_providers_saml.samlprovider",),
+ ),
+ ]
diff --git a/authentik/enterprise/providers/ws_federation/migrations/__init__.py b/authentik/enterprise/providers/ws_federation/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/providers/ws_federation/models.py b/authentik/enterprise/providers/ws_federation/models.py
new file mode 100644
index 0000000000..1194292ff7
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/models.py
@@ -0,0 +1,32 @@
+from django.templatetags.static import static
+from django.utils.translation import gettext_lazy as _
+from rest_framework.serializers import Serializer
+
+from authentik.providers.saml.models import SAMLProvider
+
+
+class WSFederationProvider(SAMLProvider):
+ """WS-Federation for applications which support WS-Fed."""
+
+ @property
+ def serializer(self) -> type[Serializer]:
+ from authentik.enterprise.providers.ws_federation.api.providers import (
+ WSFederationProviderSerializer,
+ )
+
+ return WSFederationProviderSerializer
+
+ @property
+ def icon_url(self) -> str | None:
+ return static("authentik/sources/wsfed.svg")
+
+ @property
+ def component(self) -> str:
+ return "ak-provider-wsfed-form"
+
+ def __str__(self):
+ return f"WS-Federation Provider {self.name}"
+
+ class Meta:
+ verbose_name = _("WS-Federation Provider")
+ verbose_name_plural = _("WS-Federation Providers")
diff --git a/authentik/enterprise/providers/ws_federation/processors/__init__.py b/authentik/enterprise/providers/ws_federation/processors/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/providers/ws_federation/processors/constants.py b/authentik/enterprise/providers/ws_federation/processors/constants.py
new file mode 100644
index 0000000000..4abb701023
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/processors/constants.py
@@ -0,0 +1,39 @@
+from authentik.sources.saml.processors.constants import NS_MAP as _map
+
+WS_FED_ACTION_SIGN_IN = "wsignin1.0"
+WS_FED_ACTION_SIGN_OUT = "wsignout1.0"
+WS_FED_ACTION_SIGN_OUT_CLEANUP = "wsignoutcleanup1.0"
+
+WS_FED_POST_KEY_ACTION = "wa"
+WS_FED_POST_KEY_RESULT = "wresult"
+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_KEY_IDENTIFIER_SAML_ID = (
+ "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLID"
+)
+
+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"
+NS_ADDRESSING = "http://www.w3.org/2005/08/addressing"
+NS_POLICY = "http://schemas.xmlsoap.org/ws/2004/09/policy"
+NS_WSS_SEC = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
+NS_WSS_UTILITY = (
+ "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
+)
+NS_WSS_D3P1 = "http://docs.oasis-open.org/wss/oasis-wss-wssecurity-secext-1.1.xsd"
+
+NS_MAP = {
+ **_map,
+ "fed": NS_WS_FED_PROTOCOL,
+ "xsi": NS_WSI,
+ "wsa": NS_ADDRESSING,
+ "t": NS_WS_FED_TRUST,
+ "wsu": NS_WSS_UTILITY,
+ "wsp": NS_POLICY,
+ "wssec": NS_WSS_SEC,
+ "d3p1": NS_WSS_D3P1,
+}
diff --git a/authentik/enterprise/providers/ws_federation/processors/metadata.py b/authentik/enterprise/providers/ws_federation/processors/metadata.py
new file mode 100644
index 0000000000..a33fe9ec9d
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/processors/metadata.py
@@ -0,0 +1,40 @@
+from django.urls import reverse
+from lxml.etree import SubElement, _Element # nosec
+
+from authentik.enterprise.providers.ws_federation.processors.constants import (
+ NS_ADDRESSING,
+ NS_MAP,
+ NS_WS_FED_PROTOCOL,
+ NS_WSI,
+)
+from authentik.providers.saml.processors.metadata import MetadataProcessor as BaseMetadataProcessor
+from authentik.sources.saml.processors.constants import NS_SAML_METADATA
+
+
+class MetadataProcessor(BaseMetadataProcessor):
+ def add_children(self, entity_descriptor: _Element):
+ self.add_role_descriptor_sts(entity_descriptor)
+ super().add_children(entity_descriptor)
+
+ def add_endpoint(self, parent: _Element, name: str):
+ endpoint = SubElement(parent, f"{{{NS_WS_FED_PROTOCOL}}}{name}", nsmap=NS_MAP)
+ endpoint_ref = SubElement(endpoint, f"{{{NS_ADDRESSING}}}EndpointReference", nsmap=NS_MAP)
+
+ 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")
+ )
+
+ def add_role_descriptor_sts(self, entity_descriptor: _Element):
+ role_descriptor = SubElement(
+ entity_descriptor, f"{{{NS_SAML_METADATA}}}RoleDescriptor", nsmap=NS_MAP
+ )
+ role_descriptor.attrib[f"{{{NS_WSI}}}type"] = "fed:SecurityTokenServiceType"
+ role_descriptor.attrib["protocolSupportEnumeration"] = NS_WS_FED_PROTOCOL
+
+ signing_descriptor = self.get_signing_key_descriptor()
+ if signing_descriptor is not None:
+ role_descriptor.append(signing_descriptor)
+
+ self.add_endpoint(role_descriptor, "SecurityTokenServiceEndpoint")
+ self.add_endpoint(role_descriptor, "PassiveRequestorEndpoint")
diff --git a/authentik/enterprise/providers/ws_federation/processors/sign_in.py b/authentik/enterprise/providers/ws_federation/processors/sign_in.py
new file mode 100644
index 0000000000..ede8c120ff
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/processors/sign_in.py
@@ -0,0 +1,162 @@
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+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.processors.constants import (
+ NS_ADDRESSING,
+ NS_MAP,
+ NS_POLICY,
+ NS_WS_FED_TRUST,
+ NS_WSS_D3P1,
+ NS_WSS_SEC,
+ NS_WSS_UTILITY,
+ WS_FED_ACTION_SIGN_IN,
+ WS_FED_POST_KEY_ACTION,
+ WS_FED_POST_KEY_CONTEXT,
+ WS_FED_POST_KEY_RESULT,
+ WSS_KEY_IDENTIFIER_SAML_ID,
+ WSS_TOKEN_TYPE_SAML2,
+)
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.policies.utils import delete_none_values
+from authentik.providers.saml.processors.assertion import AssertionProcessor
+from authentik.providers.saml.processors.authn_request_parser import AuthNRequest
+from authentik.providers.saml.utils.time import get_time_string
+
+
+@dataclass()
+class SignInRequest:
+ wa: str
+ wtrealm: str
+ wreply: str
+ wctx: str | None
+
+ app_slug: str
+
+ @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")
+ parsed = urlparse(realm)
+
+ req = SignInRequest(
+ wa=action,
+ wtrealm=realm,
+ wreply=request.GET.get("wreply"),
+ wctx=request.GET.get("wctx", ""),
+ app_slug=parsed.path[1:],
+ )
+
+ _, provider = req.get_app_provider()
+ if not req.wreply.startswith(provider.acs_url):
+ raise ValueError("Invalid wreply")
+ return req
+
+ def get_app_provider(self):
+ application = get_object_or_404(Application, slug=self.app_slug)
+ provider: WSFederationProvider = get_object_or_404(
+ WSFederationProvider, pk=application.provider_id
+ )
+ return application, provider
+
+
+class SignInProcessor:
+ provider: WSFederationProvider
+ request: HttpRequest
+ sign_in_request: SignInRequest
+ saml_processor: AssertionProcessor
+
+ def __init__(
+ self, provider: WSFederationProvider, request: HttpRequest, sign_in_request: SignInRequest
+ ):
+ self.provider = provider
+ self.request = request
+ self.sign_in_request = sign_in_request
+ self.saml_processor = AssertionProcessor(self.provider, self.request, AuthNRequest())
+ self.saml_processor.provider.audience = self.sign_in_request.wtrealm
+
+ def create_response_token(self):
+ root = Element(f"{{{NS_WS_FED_TRUST}}}RequestSecurityTokenResponse", nsmap=NS_MAP)
+
+ root.append(self.response_add_lifetime())
+ root.append(self.response_add_applies_to())
+ root.append(self.response_add_requested_security_token())
+ root.append(
+ self.response_add_attached_reference(
+ "RequestedAttachedReference", self.saml_processor._assertion_id
+ )
+ )
+ root.append(
+ self.response_add_attached_reference(
+ "RequestedUnattachedReference", self.saml_processor._assertion_id
+ )
+ )
+
+ token_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}TokenType")
+ token_type.text = WSS_TOKEN_TYPE_SAML2
+
+ request_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}RequestType")
+ request_type.text = "http://schemas.xmlsoap.org/ws/2005/02/trust/Issue"
+
+ key_type = SubElement(root, f"{{{NS_WS_FED_TRUST}}}KeyType")
+ key_type.text = "http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey"
+
+ return root
+
+ def response_add_lifetime(self) -> _Element:
+ """Add Lifetime element"""
+ lifetime = Element(f"{{{NS_WS_FED_TRUST}}}Lifetime", nsmap=NS_MAP)
+ created = SubElement(lifetime, f"{{{NS_WSS_UTILITY}}}Created")
+ created.text = get_time_string()
+ expires = SubElement(lifetime, f"{{{NS_WSS_UTILITY}}}Expires")
+ expires.text = get_time_string(
+ timedelta_from_string(self.provider.session_valid_not_on_or_after)
+ )
+ return lifetime
+
+ def response_add_applies_to(self) -> _Element:
+ """Add AppliesTo element"""
+ applies_to = Element(f"{{{NS_POLICY}}}AppliesTo")
+ endpoint_ref = SubElement(applies_to, f"{{{NS_ADDRESSING}}}EndpointReference")
+ address = SubElement(endpoint_ref, f"{{{NS_ADDRESSING}}}Address")
+ address.text = self.sign_in_request.wtrealm
+ return applies_to
+
+ def response_add_requested_security_token(self) -> _Element:
+ """Add RequestedSecurityToken and child assertion"""
+ token = Element(f"{{{NS_WS_FED_TRUST}}}RequestedSecurityToken")
+ token.append(self.saml_processor.get_assertion())
+ return token
+
+ 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
+
+ key_identifier = SubElement(sec_token_ref, f"{{{NS_WSS_SEC}}}KeyIdentifier")
+ key_identifier.attrib["ValueType"] = WSS_KEY_IDENTIFIER_SAML_ID
+ key_identifier.text = value
+ return ref
+
+ def response(self) -> dict[str, str]:
+ root = self.create_response_token()
+ assertion = root.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
+ self.saml_processor._sign(assertion)
+ str_token = etree.tostring(root).decode("utf-8") # nosec
+ return delete_none_values(
+ {
+ WS_FED_POST_KEY_ACTION: WS_FED_ACTION_SIGN_IN,
+ WS_FED_POST_KEY_RESULT: str_token,
+ WS_FED_POST_KEY_CONTEXT: self.sign_in_request.wctx,
+ }
+ )
diff --git a/authentik/enterprise/providers/ws_federation/processors/sign_out.py b/authentik/enterprise/providers/ws_federation/processors/sign_out.py
new file mode 100644
index 0000000000..78b5dd1552
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/processors/sign_out.py
@@ -0,0 +1,47 @@
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+from django.http import HttpRequest
+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
+
+
+@dataclass()
+class SignOutRequest:
+ wa: str
+ wtrealm: str
+ wreply: str
+
+ app_slug: str
+
+ @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")
+ parsed = urlparse(realm)
+
+ req = SignOutRequest(
+ wa=action,
+ wtrealm=realm,
+ wreply=request.GET.get("wreply"),
+ app_slug=parsed.path[1:],
+ )
+
+ _, provider = req.get_app_provider()
+ if not req.wreply.startswith(provider.acs_url):
+ raise ValueError("Invalid wreply")
+ return req
+
+ def get_app_provider(self):
+ application = get_object_or_404(Application, slug=self.app_slug)
+ provider: WSFederationProvider = get_object_or_404(
+ WSFederationProvider, pk=application.provider_id
+ )
+ return application, provider
diff --git a/authentik/enterprise/providers/ws_federation/signals.py b/authentik/enterprise/providers/ws_federation/signals.py
new file mode 100644
index 0000000000..33cfa5c46f
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/signals.py
@@ -0,0 +1,93 @@
+"""WS-Fed Provider signals"""
+
+from urllib.parse import urlencode, urlparse, urlunparse
+
+from django.dispatch import receiver
+from django.http import HttpRequest
+from django.urls import reverse
+from django.utils import timezone
+from structlog.stdlib import get_logger
+
+from authentik.core.models import AuthenticatedSession, User
+from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.processors.constants import (
+ WS_FED_ACTION_SIGN_OUT_CLEANUP,
+ WS_FED_POST_KEY_ACTION,
+)
+from authentik.flows.models import in_memory_stage
+from authentik.flows.views.executor import FlowExecutorView
+from authentik.providers.iframe_logout import IframeLogoutStageView
+from authentik.providers.saml.models import SAMLBindings, SAMLSession
+from authentik.providers.saml.views.flows import (
+ PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS,
+ PLAN_CONTEXT_SAML_RELAY_STATE,
+)
+from authentik.stages.user_logout.models import UserLogoutStage
+from authentik.stages.user_logout.stage import flow_pre_user_logout
+
+LOGGER = get_logger()
+
+
+@receiver(flow_pre_user_logout)
+def handle_ws_fed_iframe_pre_user_logout(
+ sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
+):
+ """Handle WS-Fed iframe logout when user logs out via flow"""
+
+ # Only proceed if this is actually a UserLogoutStage
+ if not isinstance(executor.current_stage, UserLogoutStage):
+ return
+
+ if not user.is_authenticated:
+ return
+
+ auth_session = AuthenticatedSession.from_request(request, user)
+ if not auth_session:
+ return
+
+ wsfed_sessions = SAMLSession.objects.filter(
+ session=auth_session,
+ user=user,
+ expires__gt=timezone.now(),
+ expiring=True,
+ # Only get WS-Federation provider sessions
+ provider__wsfederationprovider__isnull=False,
+ ).select_related("provider__wsfederationprovider")
+
+ if not wsfed_sessions.exists():
+ LOGGER.debug("No sessions requiring IFrame frontchannel logout")
+ return
+
+ saml_sessions = []
+
+ relay_state = request.build_absolute_uri(
+ reverse("authentik_core:if-flow", kwargs={"flow_slug": executor.flow.slug})
+ )
+
+ # Store return URL in plan context as fallback if SP doesn't echo relay_state
+ executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
+
+ for session in wsfed_sessions:
+ provider: WSFederationProvider = session.provider.wsfederationprovider
+ parts = urlparse(str(provider.acs_url))
+ parts = parts._replace(
+ query=urlencode({WS_FED_POST_KEY_ACTION: WS_FED_ACTION_SIGN_OUT_CLEANUP})
+ )
+ logout_data = {
+ "url": urlunparse(parts),
+ "provider_name": provider.name,
+ "binding": SAMLBindings.REDIRECT,
+ }
+
+ saml_sessions.append(logout_data)
+
+ if saml_sessions:
+ executor.plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = saml_sessions
+ # Stage already exists, don't reinject it
+ if not any(
+ binding.stage.view == IframeLogoutStageView for binding in executor.plan.bindings
+ ):
+ iframe_stage = in_memory_stage(IframeLogoutStageView)
+ executor.plan.insert_stage(iframe_stage, index=1)
+
+ LOGGER.debug("WSFed iframe sessions gathered")
diff --git a/authentik/enterprise/providers/ws_federation/tests/__init__.py b/authentik/enterprise/providers/ws_federation/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/providers/ws_federation/tests/test_metadata.py b/authentik/enterprise/providers/ws_federation/tests/test_metadata.py
new file mode 100644
index 0000000000..9872b6c7f2
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/tests/test_metadata.py
@@ -0,0 +1,40 @@
+from django.test import TestCase
+from lxml import etree # nosec
+
+from authentik.core.models import Application
+from authentik.core.tests.utils import RequestFactory, create_test_flow
+from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.processors.metadata import MetadataProcessor
+from authentik.lib.generators import generate_id
+from authentik.lib.xml import lxml_from_string
+
+
+class TestWSFedMetadata(TestCase):
+ def setUp(self):
+ self.flow = create_test_flow()
+ self.provider = WSFederationProvider.objects.create(
+ name=generate_id(),
+ authorization_flow=self.flow,
+ )
+ self.app = Application.objects.create(
+ name=generate_id(), slug=generate_id(), provider=self.provider
+ )
+ self.factory = RequestFactory()
+
+ def test_metadata_generation(self):
+ request = self.factory.get("/")
+ metadata_a = MetadataProcessor(self.provider, request).build_entity_descriptor()
+ metadata_b = MetadataProcessor(self.provider, request).build_entity_descriptor()
+ self.assertEqual(metadata_a, metadata_b)
+
+ def test_schema(self):
+ """Test that metadata generation is consistent"""
+ request = self.factory.get("/")
+ metadata = lxml_from_string(
+ MetadataProcessor(self.provider, request).build_entity_descriptor()
+ )
+
+ schema = etree.XMLSchema(
+ etree.parse(source="schemas/ws-federation.xsd", parser=etree.XMLParser()) # nosec
+ )
+ self.assertTrue(schema.validate(metadata))
diff --git a/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py b/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py
new file mode 100644
index 0000000000..f5bc0092f1
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/tests/test_sign_in.py
@@ -0,0 +1,85 @@
+import xmlsec
+from django.test import TestCase
+from guardian.utils import get_anonymous_user
+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.processors.constants import (
+ NS_MAP,
+ WS_FED_ACTION_SIGN_IN,
+ WS_FED_POST_KEY_RESULT,
+)
+from authentik.enterprise.providers.ws_federation.processors.sign_in import (
+ SignInProcessor,
+ SignInRequest,
+)
+from authentik.lib.generators import generate_id
+from authentik.lib.xml import lxml_from_string
+
+
+class TestWSFedSignIn(TestCase):
+ 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,
+ )
+ self.app = Application.objects.create(
+ name=generate_id(), slug=generate_id(), provider=self.provider
+ )
+ self.factory = RequestFactory()
+
+ def test_token_gen(self):
+ request = self.factory.get("/", user=get_anonymous_user())
+ proc = SignInProcessor(
+ self.provider,
+ request,
+ SignInRequest(
+ wa=WS_FED_ACTION_SIGN_IN,
+ wtrealm="",
+ wreply="",
+ wctx=None,
+ app_slug="",
+ ),
+ )
+ token = proc.response()[WS_FED_POST_KEY_RESULT]
+
+ 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)
+
+ def test_signature(self):
+ request = self.factory.get("/", user=get_anonymous_user())
+ proc = SignInProcessor(
+ self.provider,
+ request,
+ SignInRequest(
+ wa=WS_FED_ACTION_SIGN_IN,
+ wtrealm="",
+ wreply="",
+ wctx=None,
+ app_slug="",
+ ),
+ )
+ token = proc.response()[WS_FED_POST_KEY_RESULT]
+
+ root = lxml_from_string(token)
+ xmlsec.tree.add_ids(root, ["ID"])
+ signature_nodes = root.xpath("//saml:Assertion/ds:Signature", namespaces=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)
diff --git a/authentik/enterprise/providers/ws_federation/urls.py b/authentik/enterprise/providers/ws_federation/urls.py
new file mode 100644
index 0000000000..b1f8345465
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/urls.py
@@ -0,0 +1,18 @@
+"""WS Fed provider URLs"""
+
+from django.urls import path
+
+from authentik.enterprise.providers.ws_federation.api.providers import WSFederationProviderViewSet
+from authentik.enterprise.providers.ws_federation.views import WSFedEntryView
+
+urlpatterns = [
+ path(
+ "",
+ WSFedEntryView.as_view(),
+ name="wsfed",
+ ),
+]
+
+api_urlpatterns = [
+ ("providers/wsfed", WSFederationProviderViewSet),
+]
diff --git a/authentik/enterprise/providers/ws_federation/views.py b/authentik/enterprise/providers/ws_federation/views.py
new file mode 100644
index 0000000000..dbacf62346
--- /dev/null
+++ b/authentik/enterprise/providers/ws_federation/views.py
@@ -0,0 +1,162 @@
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+from django.utils.translation import gettext as _
+from structlog.stdlib import get_logger
+
+from authentik.core.models import Application, AuthenticatedSession
+from authentik.enterprise.providers.ws_federation.models import WSFederationProvider
+from authentik.enterprise.providers.ws_federation.processors.constants import (
+ WS_FED_ACTION_SIGN_IN,
+ WS_FED_ACTION_SIGN_OUT,
+)
+from authentik.enterprise.providers.ws_federation.processors.sign_in import (
+ SignInProcessor,
+ SignInRequest,
+)
+from authentik.enterprise.providers.ws_federation.processors.sign_out import SignOutRequest
+from authentik.flows.challenge import (
+ PLAN_CONTEXT_TITLE,
+ AutosubmitChallenge,
+ AutoSubmitChallengeResponse,
+)
+from authentik.flows.exceptions import FlowNonApplicableException
+from authentik.flows.models import in_memory_stage
+from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
+from authentik.flows.stage import ChallengeStageView, SessionEndStage
+from authentik.lib.views import bad_request_message
+from authentik.policies.views import PolicyAccessView, RequestValidationError
+from authentik.providers.saml.models import SAMLSession
+from authentik.stages.consent.stage import (
+ PLAN_CONTEXT_CONSENT_HEADER,
+ PLAN_CONTEXT_CONSENT_PERMISSIONS,
+)
+
+PLAN_CONTEXT_WS_FED_REQUEST = "authentik/providers/ws_federation/request"
+LOGGER = get_logger()
+
+
+class WSFedEntryView(PolicyAccessView):
+ req: SignInRequest | SignOutRequest
+
+ def pre_permission_check(self):
+ self.action = self.request.GET.get("wa")
+ try:
+ if self.action == WS_FED_ACTION_SIGN_IN:
+ self.req = SignInRequest.parse(self.request)
+ elif self.action == WS_FED_ACTION_SIGN_OUT:
+ self.req = SignOutRequest.parse(self.request)
+ else:
+ raise RequestValidationError(
+ bad_request_message(self.request, "Invalid WS-Federation action")
+ )
+ except ValueError as exc:
+ LOGGER.warning("Invalid WS-Fed request", exc=exc)
+ raise RequestValidationError(
+ bad_request_message(self.request, "Invalid WS-Federation request")
+ ) from None
+
+ def resolve_provider_application(self):
+ self.application, self.provider = self.req.get_app_provider()
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ if self.action == WS_FED_ACTION_SIGN_IN:
+ return self.ws_fed_sign_in()
+ elif self.action == WS_FED_ACTION_SIGN_OUT:
+ return self.ws_fed_sign_out()
+ else:
+ return HttpResponse("Unsupported WS-Federation action", status=400)
+
+ def ws_fed_sign_in(self) -> HttpResponse:
+ planner = FlowPlanner(self.provider.authorization_flow)
+ planner.allow_empty_flows = True
+ try:
+ plan = planner.plan(
+ self.request,
+ {
+ PLAN_CONTEXT_SSO: True,
+ PLAN_CONTEXT_APPLICATION: self.application,
+ PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
+ % {"application": self.application.name},
+ PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
+ PLAN_CONTEXT_WS_FED_REQUEST: self.req,
+ },
+ )
+ except FlowNonApplicableException:
+ raise Http404 from None
+ plan.append_stage(in_memory_stage(WSFedFlowFinalView))
+ return plan.to_redirect(
+ self.request,
+ self.provider.authorization_flow,
+ )
+
+ def ws_fed_sign_out(self) -> HttpResponse:
+ flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
+
+ planner = FlowPlanner(flow)
+ planner.allow_empty_flows = True
+ try:
+ plan = planner.plan(
+ self.request,
+ {
+ PLAN_CONTEXT_SSO: True,
+ PLAN_CONTEXT_APPLICATION: self.application,
+ PLAN_CONTEXT_WS_FED_REQUEST: self.req,
+ },
+ )
+ except FlowNonApplicableException:
+ raise Http404 from None
+ plan.append_stage(in_memory_stage(SessionEndStage))
+ return plan.to_redirect(self.request, flow)
+
+
+class WSFedFlowFinalView(ChallengeStageView):
+ response_class = AutoSubmitChallengeResponse
+
+ def get(self, request, *args, **kwargs):
+ if PLAN_CONTEXT_WS_FED_REQUEST not in self.executor.plan.context:
+ self.logger.warning("No WS-Fed request in context")
+ return self.executor.stage_invalid()
+ return super().get(request, *args, **kwargs)
+
+ def get_challenge(self, *args, **kwargs):
+ application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
+ provider: WSFederationProvider = get_object_or_404(
+ WSFederationProvider, pk=application.provider_id
+ )
+ sign_in_req: SignInRequest = self.executor.plan.context[PLAN_CONTEXT_WS_FED_REQUEST]
+ proc = SignInProcessor(provider, self.request, sign_in_req)
+ response = proc.response()
+ saml_processor = proc.saml_processor
+
+ # Create SAMLSession to track this login
+ auth_session = AuthenticatedSession.from_request(self.request, self.request.user)
+ if auth_session:
+ # Since samlsessions should only exist uniquely for an active session and a provider
+ # any existing combination is likely an old, dead session
+ SAMLSession.objects.filter(
+ session_index=saml_processor.session_index, provider=provider
+ ).delete()
+
+ SAMLSession.objects.update_or_create(
+ session_index=saml_processor.session_index,
+ provider=provider,
+ defaults={
+ "user": self.request.user,
+ "session": auth_session,
+ "name_id": saml_processor.name_id,
+ "name_id_format": saml_processor.name_id_format,
+ "expires": saml_processor.session_not_on_or_after_datetime,
+ "expiring": True,
+ },
+ )
+ return AutosubmitChallenge(
+ data={
+ "component": "ak-stage-autosubmit",
+ "title": self.executor.plan.context.get(
+ PLAN_CONTEXT_TITLE,
+ _("Redirecting to {app}...".format_map({"app": application.name})),
+ ),
+ "url": sign_in_req.wreply,
+ "attrs": response,
+ },
+ )
diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py
index e8a4164de1..0228b11d4c 100644
--- a/authentik/enterprise/settings.py
+++ b/authentik/enterprise/settings.py
@@ -10,6 +10,7 @@ TENANT_APPS = [
"authentik.enterprise.providers.radius",
"authentik.enterprise.providers.scim",
"authentik.enterprise.providers.ssf",
+ "authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.search",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
diff --git a/authentik/lib/avatars.py b/authentik/lib/avatars.py
index cd6d04ea99..735ef99626 100644
--- a/authentik/lib/avatars.py
+++ b/authentik/lib/avatars.py
@@ -10,7 +10,7 @@ from django.core.cache import cache
from django.http import HttpRequest, HttpResponseNotFound
from django.templatetags.static import static
from lxml import etree # nosec
-from lxml.etree import Element, SubElement # nosec
+from lxml.etree import Element, SubElement, _Element # nosec
from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
from authentik.lib.utils.dict import get_path_from_dict
@@ -109,7 +109,7 @@ def generate_avatar_from_name(
shape = "circle" if rounded else "rect"
font_weight = "600" if bold else "400"
- root_element: Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
+ root_element: _Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
root_element.attrib["width"] = f"{size}px"
root_element.attrib["height"] = f"{size}px"
root_element.attrib["viewBox"] = f"0 0 {size} {size}"
diff --git a/authentik/lib/xml.py b/authentik/lib/xml.py
index ec65d3475a..3348400547 100644
--- a/authentik/lib/xml.py
+++ b/authentik/lib/xml.py
@@ -1,6 +1,6 @@
"""XML Utilities"""
-from lxml.etree import XMLParser, fromstring # nosec
+from lxml.etree import XMLParser, _Element, fromstring, tostring # nosec
def get_lxml_parser():
@@ -11,3 +11,13 @@ def get_lxml_parser():
def lxml_from_string(text: str):
"""Wrapper around fromstring"""
return fromstring(text, parser=get_lxml_parser()) # nosec
+
+
+def remove_xml_newlines(parent: _Element, element: _Element):
+ """Remove newlines in a given XML element, required for xmlsec
+
+ https://github.com/xmlsec/python-xmlsec/issues/196"""
+ old_element = element
+ new_node = fromstring(tostring(element, encoding=str).replace("\n", ""))
+ parent.replace(old_element, new_node)
+ return new_node
diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py
index a63e085291..61b7d64c18 100644
--- a/authentik/providers/saml/api/providers.py
+++ b/authentik/providers/saml/api/providers.py
@@ -244,6 +244,8 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
ordering = ["name"]
search_fields = ["name"]
+ metadata_generator_class = MetadataProcessor
+
@extend_schema(
responses={
200: SAMLMetadataSerializer(many=False),
@@ -288,7 +290,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
except ValueError:
raise Http404 from None
try:
- proc = MetadataProcessor(provider, request)
+ proc = self.metadata_generator_class(provider, request)
proc.force_binding = request.query_params.get("force_binding", None)
metadata = proc.build_entity_descriptor()
if "download" in request.query_params:
diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py
index c3f0eb233c..b3e0733388 100644
--- a/authentik/providers/saml/processors/assertion.py
+++ b/authentik/providers/saml/processors/assertion.py
@@ -8,13 +8,14 @@ import xmlsec
from django.http import HttpRequest
from django.utils.timezone import now
from lxml import etree # nosec
-from lxml.etree import Element, SubElement # nosec
+from lxml.etree import Element, SubElement, _Element # nosec
from structlog.stdlib import get_logger
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.utils.time import timedelta_from_string
+from authentik.lib.xml import remove_xml_newlines
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequest
from authentik.providers.saml.utils import get_random_id
@@ -359,7 +360,7 @@ class AssertionProcessor:
response.append(self.get_assertion())
return response
- def _sign(self, element: Element):
+ def _sign(self, element: _Element):
"""Sign an XML element based on the providers' configured signing settings"""
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
@@ -389,11 +390,11 @@ class AssertionProcessor:
)
ctx.key = key
try:
- ctx.sign(signature_node)
+ ctx.sign(remove_xml_newlines(element, signature_node))
except xmlsec.Error as exc:
raise InvalidSignature() from exc
- def _encrypt(self, element: Element, parent: Element):
+ def _encrypt(self, element: _Element, parent: _Element):
"""Encrypt SAMLResponse EncryptedAssertion Element"""
# Create a standalone copy so namespace declarations are included in the encrypted content
element_xml = etree.tostring(element)
diff --git a/authentik/providers/saml/processors/logout_request.py b/authentik/providers/saml/processors/logout_request.py
index e4bf01f530..9c7506b57b 100644
--- a/authentik/providers/saml/processors/logout_request.py
+++ b/authentik/providers/saml/processors/logout_request.py
@@ -5,9 +5,10 @@ from urllib.parse import quote, urlencode
import xmlsec
from lxml import etree # nosec
-from lxml.etree import Element
+from lxml.etree import Element, _Element
from authentik.core.models import User
+from authentik.lib.xml import remove_xml_newlines
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
@@ -134,7 +135,7 @@ class LogoutRequestProcessor:
"RelayState": self.relay_state or "",
}
- def _sign_logout_request(self, logout_request: Element):
+ def _sign_logout_request(self, logout_request: _Element):
"""Sign the LogoutRequest element"""
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
@@ -154,7 +155,7 @@ class LogoutRequestProcessor:
self._sign(logout_request)
- def _sign(self, element: Element):
+ def _sign(self, element: _Element):
"""Sign an XML element based on the providers' configured signing settings"""
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
@@ -183,7 +184,7 @@ class LogoutRequestProcessor:
xmlsec.constants.KeyDataFormatCertPem,
)
ctx.key = key
- ctx.sign(signature_node)
+ ctx.sign(remove_xml_newlines(element, signature_node))
def _build_signable_query_string(self, params: dict) -> str:
"""Build query string for signing (order matters per SAML spec)"""
diff --git a/authentik/providers/saml/processors/metadata.py b/authentik/providers/saml/processors/metadata.py
index 21e3546d51..e68041c152 100644
--- a/authentik/providers/saml/processors/metadata.py
+++ b/authentik/providers/saml/processors/metadata.py
@@ -6,8 +6,9 @@ from hashlib import sha256
import xmlsec # nosec
from django.http import HttpRequest
from django.urls import reverse
-from lxml.etree import Element, SubElement, tostring # nosec
+from lxml.etree import Element, SubElement, _Element, tostring # nosec
+from authentik.lib.xml import remove_xml_newlines
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.utils.encoding import strip_pem_header
from authentik.sources.saml.processors.constants import (
@@ -117,7 +118,7 @@ class MetadataProcessor:
element.attrib["Location"] = url
yield element
- def _prepare_signature(self, entity_descriptor: Element):
+ def _prepare_signature(self, entity_descriptor: _Element):
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
)
@@ -129,7 +130,7 @@ class MetadataProcessor:
)
entity_descriptor.append(signature)
- def _sign(self, entity_descriptor: Element):
+ def _sign(self, entity_descriptor: _Element):
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
)
@@ -158,17 +159,12 @@ class MetadataProcessor:
xmlsec.constants.KeyDataFormatCertPem,
)
ctx.key = key
- ctx.sign(signature_node)
+ ctx.sign(remove_xml_newlines(assertion, signature_node))
- def build_entity_descriptor(self) -> str:
- """Build full EntityDescriptor"""
- entity_descriptor = Element(f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP)
- entity_descriptor.attrib["ID"] = self.xml_id
- entity_descriptor.attrib["entityID"] = self.provider.issuer
-
- if self.provider.signing_kp:
- self._prepare_signature(entity_descriptor)
+ def add_children(self, entity_descriptor: _Element):
+ self.add_idp_sso(entity_descriptor)
+ def add_idp_sso(self, entity_descriptor: _Element):
idp_sso_descriptor = SubElement(
entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor"
)
@@ -189,6 +185,17 @@ class MetadataProcessor:
for binding in self.get_sso_bindings():
idp_sso_descriptor.append(binding)
+ def build_entity_descriptor(self) -> str:
+ """Build full EntityDescriptor"""
+ entity_descriptor = Element(f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP)
+ entity_descriptor.attrib["ID"] = self.xml_id
+ entity_descriptor.attrib["entityID"] = self.provider.issuer
+
+ if self.provider.signing_kp:
+ self._prepare_signature(entity_descriptor)
+
+ self.add_children(entity_descriptor)
+
if self.provider.signing_kp:
self._sign(entity_descriptor)
diff --git a/authentik/providers/saml/signals.py b/authentik/providers/saml/signals.py
index 20e093450e..9dce6c4c68 100644
--- a/authentik/providers/saml/signals.py
+++ b/authentik/providers/saml/signals.py
@@ -2,12 +2,14 @@
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
+from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User
from authentik.flows.models import in_memory_stage
+from authentik.flows.views.executor import FlowExecutorView
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLSession
from authentik.providers.saml.native_logout import NativeLogoutStageView
@@ -25,7 +27,9 @@ LOGGER = get_logger()
@receiver(flow_pre_user_logout)
-def handle_saml_iframe_pre_user_logout(sender, request, user, executor, **kwargs):
+def handle_saml_iframe_pre_user_logout(
+ sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
+):
"""Handle SAML iframe logout when user logs out via flow"""
# Only proceed if this is actually a UserLogoutStage
@@ -113,7 +117,9 @@ def handle_saml_iframe_pre_user_logout(sender, request, user, executor, **kwargs
@receiver(flow_pre_user_logout)
-def handle_flow_pre_user_logout(sender, request, user, executor, **kwargs):
+def handle_flow_pre_user_logout(
+ sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
+):
"""Handle SAML native logout when user logs out via logout flow"""
# Only proceed if this is actually a UserLogoutStage
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index ac511e95b1..b7e96aecad 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -191,6 +191,7 @@ SPECTACULAR_SETTINGS = {
"DeviceFactsOSFamily": "authentik.endpoints.facts.OSFamily",
"StageModeEnum": "authentik.endpoints.models.StageMode",
"LicenseSummaryStatusEnum": "authentik.enterprise.models.LicenseUsageStatus",
+ "SAMLLogoutMethods": "authentik.providers.saml.models.SAMLLogoutMethods",
},
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,
diff --git a/authentik/root/setup.py b/authentik/root/setup.py
index fdb5f162f9..5bba527e91 100644
--- a/authentik/root/setup.py
+++ b/authentik/root/setup.py
@@ -3,6 +3,7 @@ import warnings
from cryptography.hazmat.backends.openssl.backend import backend
from defusedxml import defuse_stdlib
+from xmlsec import base64_default_line_size
from authentik.lib.config import CONFIG
@@ -19,6 +20,7 @@ def setup():
)
defuse_stdlib()
+ base64_default_line_size(size=8192)
if CONFIG.get_bool("compliance.fips.enabled", False):
backend._enable_fips()
diff --git a/authentik/sources/saml/processors/request.py b/authentik/sources/saml/processors/request.py
index 4ab853815a..9e290f9afb 100644
--- a/authentik/sources/saml/processors/request.py
+++ b/authentik/sources/saml/processors/request.py
@@ -8,6 +8,7 @@ from django.http import HttpRequest
from lxml import etree # nosec
from lxml.etree import Element # nosec
+from authentik.lib.xml import remove_xml_newlines
from authentik.providers.saml.utils import get_random_id
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
from authentik.providers.saml.utils.time import get_time_string
@@ -120,7 +121,7 @@ class RequestProcessor:
key_info = xmlsec.template.ensure_key_info(signature_node)
xmlsec.template.add_x509_data(key_info)
- ctx.sign(signature_node)
+ ctx.sign(remove_xml_newlines(auth_n_request, signature_node))
return etree.tostring(auth_n_request).decode()
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 73acb24bf5..9e550b3f74 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -976,6 +976,46 @@
}
}
},
+ {
+ "type": "object",
+ "required": [
+ "model",
+ "identifiers"
+ ],
+ "properties": {
+ "model": {
+ "const": "authentik_providers_ws_federation.wsfederationprovider"
+ },
+ "id": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "absent",
+ "created",
+ "must_created",
+ "present"
+ ],
+ "default": "present"
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "type": "boolean"
+ }
+ },
+ "permissions": {
+ "$ref": "#/$defs/model_authentik_providers_ws_federation.wsfederationprovider_permissions"
+ },
+ "attrs": {
+ "$ref": "#/$defs/model_authentik_providers_ws_federation.wsfederationprovider"
+ },
+ "identifiers": {
+ "$ref": "#/$defs/model_authentik_providers_ws_federation.wsfederationprovider"
+ }
+ }
+ },
{
"type": "object",
"required": [
@@ -5727,6 +5767,10 @@
"authentik_providers_ssf.view_ssfprovider",
"authentik_providers_ssf.view_stream",
"authentik_providers_ssf.view_streamevent",
+ "authentik_providers_ws_federation.add_wsfederationprovider",
+ "authentik_providers_ws_federation.change_wsfederationprovider",
+ "authentik_providers_ws_federation.delete_wsfederationprovider",
+ "authentik_providers_ws_federation.view_wsfederationprovider",
"authentik_rbac.access_admin_interface",
"authentik_rbac.add_initialpermissions",
"authentik_rbac.add_role",
@@ -7085,6 +7129,162 @@
}
}
},
+ "model_authentik_providers_ws_federation.wsfederationprovider": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Name"
+ },
+ "authentication_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Authentication flow",
+ "description": "Flow used for authentication when the associated application is accessed by an un-authenticated user."
+ },
+ "authorization_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Authorization flow",
+ "description": "Flow used when authorizing this provider."
+ },
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
+ "property_mappings": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "title": "Property mappings"
+ },
+ "reply_url": {
+ "type": "string",
+ "format": "uri",
+ "minLength": 1,
+ "title": "Reply url"
+ },
+ "assertion_valid_not_before": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Assertion valid not before",
+ "description": "Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3)."
+ },
+ "assertion_valid_not_on_or_after": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Assertion valid not on or after",
+ "description": "Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3)."
+ },
+ "session_valid_not_on_or_after": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Session valid not on or after",
+ "description": "Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3)."
+ },
+ "name_id_mapping": {
+ "type": "string",
+ "format": "uuid",
+ "title": "NameID Property Mapping",
+ "description": "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be considered"
+ },
+ "authn_context_class_ref_mapping": {
+ "type": "string",
+ "format": "uuid",
+ "title": "AuthnContextClassRef Property Mapping",
+ "description": "Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate."
+ },
+ "digest_algorithm": {
+ "type": "string",
+ "enum": [
+ "http://www.w3.org/2000/09/xmldsig#sha1",
+ "http://www.w3.org/2001/04/xmlenc#sha256",
+ "http://www.w3.org/2001/04/xmldsig-more#sha384",
+ "http://www.w3.org/2001/04/xmlenc#sha512"
+ ],
+ "title": "Digest algorithm"
+ },
+ "signature_algorithm": {
+ "type": "string",
+ "enum": [
+ "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1",
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
+ "http://www.w3.org/2000/09/xmldsig#dsa-sha1"
+ ],
+ "title": "Signature algorithm"
+ },
+ "signing_kp": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Signing Keypair",
+ "description": "Keypair used to sign outgoing Responses going to the Service Provider."
+ },
+ "encryption_kp": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Encryption Keypair",
+ "description": "When selected, incoming assertions are encrypted by the IdP using the public key of the encryption keypair. The assertion is decrypted by the SP using the the private key."
+ },
+ "sign_assertion": {
+ "type": "boolean",
+ "title": "Sign assertion"
+ },
+ "sign_logout_request": {
+ "type": "boolean",
+ "title": "Sign logout request"
+ },
+ "default_name_id_policy": {
+ "type": "string",
+ "enum": [
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
+ ],
+ "title": "Default name id policy"
+ }
+ },
+ "required": []
+ },
+ "model_authentik_providers_ws_federation.wsfederationprovider_permissions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "permission"
+ ],
+ "properties": {
+ "permission": {
+ "type": "string",
+ "enum": [
+ "add_wsfederationprovider",
+ "change_wsfederationprovider",
+ "delete_wsfederationprovider",
+ "view_wsfederationprovider"
+ ]
+ },
+ "user": {
+ "type": "integer"
+ },
+ "role": {
+ "type": "string"
+ }
+ }
+ }
+ },
"model_authentik_reports.dataexport": {
"type": "object",
"properties": {
@@ -8289,6 +8489,7 @@
"authentik.enterprise.providers.radius",
"authentik.enterprise.providers.scim",
"authentik.enterprise.providers.ssf",
+ "authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.search",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
@@ -8417,6 +8618,7 @@
"authentik_providers_microsoft_entra.microsoftentraprovider",
"authentik_providers_microsoft_entra.microsoftentraprovidermapping",
"authentik_providers_ssf.ssfprovider",
+ "authentik_providers_ws_federation.wsfederationprovider",
"authentik_reports.dataexport",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
@@ -10929,6 +11131,10 @@
"authentik_providers_ssf.view_ssfprovider",
"authentik_providers_ssf.view_stream",
"authentik_providers_ssf.view_streamevent",
+ "authentik_providers_ws_federation.add_wsfederationprovider",
+ "authentik_providers_ws_federation.change_wsfederationprovider",
+ "authentik_providers_ws_federation.delete_wsfederationprovider",
+ "authentik_providers_ws_federation.view_wsfederationprovider",
"authentik_rbac.access_admin_interface",
"authentik_rbac.add_initialpermissions",
"authentik_rbac.add_role",
diff --git a/pyproject.toml b/pyproject.toml
index 0dbd9f97ba..20dd14c1ea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -179,7 +179,8 @@ skip = [
"./gen-go-api", # Generated Go API
"./data", # Media files
"./media", # Legacy media files
- "**vendored**" # Vendored files
+ "./schemas/**", # XML Schemas
+ "**vendored**", # Vendored files
]
dictionary = ".github/codespell-dictionary.txt,-"
ignore-words = ".github/codespell-words.txt"
diff --git a/schema.yml b/schema.yml
index 14f9a8adec..c6ae24bd96 100644
--- a/schema.yml
+++ b/schema.yml
@@ -19432,6 +19432,433 @@ paths:
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
+ /providers/wsfed/:
+ get:
+ operationId: providers_wsfed_list
+ description: WSFederationProvider Viewset
+ parameters:
+ - in: query
+ name: acs_url
+ schema:
+ type: string
+ - in: query
+ name: assertion_valid_not_before
+ schema:
+ type: string
+ - in: query
+ name: assertion_valid_not_on_or_after
+ schema:
+ type: string
+ - in: query
+ name: audience
+ schema:
+ type: string
+ - in: query
+ name: authentication_flow
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: authn_context_class_ref_mapping
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: authorization_flow
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: backchannel_application
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: default_name_id_policy
+ schema:
+ type: string
+ enum:
+ - urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName
+ - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+ - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
+ - urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName
+ - urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+ - urn:oasis:names:tc:SAML:2.0:nameid-format:transient
+ - in: query
+ name: default_relay_state
+ schema:
+ type: string
+ - in: query
+ name: digest_algorithm
+ schema:
+ type: string
+ enum:
+ - http://www.w3.org/2000/09/xmldsig#sha1
+ - http://www.w3.org/2001/04/xmldsig-more#sha384
+ - http://www.w3.org/2001/04/xmlenc#sha256
+ - http://www.w3.org/2001/04/xmlenc#sha512
+ - in: query
+ name: encryption_kp
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: invalidation_flow
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: is_backchannel
+ schema:
+ type: boolean
+ - in: query
+ name: issuer
+ schema:
+ type: string
+ - in: query
+ name: logout_method
+ schema:
+ type: string
+ enum:
+ - backchannel
+ - frontchannel_iframe
+ - frontchannel_native
+ description: |+
+ Method to use for logout. Front-channel iframe loads all logout URLs simultaneously in hidden iframes. Front-channel native uses your active browser tab to send post requests and redirect to providers. Back-channel sends logout requests directly from the server without user interaction (requires POST SLS binding).
+
+ - $ref: '#/components/parameters/QueryName'
+ - in: query
+ name: name_id_mapping
+ schema:
+ type: string
+ format: uuid
+ - $ref: '#/components/parameters/QueryPaginationOrdering'
+ - $ref: '#/components/parameters/QueryPaginationPage'
+ - $ref: '#/components/parameters/QueryPaginationPageSize'
+ - in: query
+ name: property_mappings
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ explode: true
+ style: form
+ - $ref: '#/components/parameters/QuerySearch'
+ - in: query
+ name: session_valid_not_on_or_after
+ schema:
+ type: string
+ - in: query
+ name: sign_assertion
+ schema:
+ type: boolean
+ - in: query
+ name: sign_logout_request
+ schema:
+ type: boolean
+ - in: query
+ name: sign_response
+ schema:
+ type: boolean
+ - in: query
+ name: signature_algorithm
+ schema:
+ type: string
+ enum:
+ - http://www.w3.org/2000/09/xmldsig#dsa-sha1
+ - http://www.w3.org/2000/09/xmldsig#rsa-sha1
+ - http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1
+ - http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256
+ - http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384
+ - http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
+ - http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
+ - http://www.w3.org/2001/04/xmldsig-more#rsa-sha384
+ - http://www.w3.org/2001/04/xmldsig-more#rsa-sha512
+ - in: query
+ name: signing_kp
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: sls_binding
+ schema:
+ type: string
+ enum:
+ - post
+ - redirect
+ description: |+
+ This determines how authentik sends the logout response back to the Service Provider.
+
+ - in: query
+ name: sls_url
+ schema:
+ type: string
+ - in: query
+ name: sp_binding
+ schema:
+ type: string
+ title: Service Provider Binding
+ enum:
+ - post
+ - redirect
+ description: |+
+ This determines how authentik sends the response back to the Service Provider.
+
+ - in: query
+ name: verification_kp
+ schema:
+ type: string
+ format: uuid
+ tags:
+ - providers
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedWSFederationProviderList'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ post:
+ operationId: providers_wsfed_create
+ description: WSFederationProvider Viewset
+ tags:
+ - providers
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProviderRequest'
+ required: true
+ security:
+ - authentik: []
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProvider'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ /providers/wsfed/{id}/:
+ get:
+ operationId: providers_wsfed_retrieve
+ description: WSFederationProvider Viewset
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProvider'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ put:
+ operationId: providers_wsfed_update
+ description: WSFederationProvider Viewset
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProviderRequest'
+ required: true
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProvider'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ patch:
+ operationId: providers_wsfed_partial_update
+ description: WSFederationProvider Viewset
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PatchedWSFederationProviderRequest'
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WSFederationProvider'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ delete:
+ operationId: providers_wsfed_destroy
+ description: WSFederationProvider Viewset
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ security:
+ - authentik: []
+ responses:
+ '204':
+ description: No response body
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ /providers/wsfed/{id}/metadata/:
+ get:
+ operationId: providers_wsfed_metadata_retrieve
+ description: Return metadata as XML string
+ parameters:
+ - in: query
+ name: download
+ schema:
+ type: boolean
+ - in: query
+ name: force_binding
+ schema:
+ type: string
+ enum:
+ - urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
+ - urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect
+ description: Optionally force the metadata to only include one binding.
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ security:
+ - authentik: []
+ - {}
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SAMLMetadata'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/SAMLMetadata'
+ description: ''
+ '404':
+ description: Provider has no application assigned
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ /providers/wsfed/{id}/preview_user/:
+ get:
+ operationId: providers_wsfed_preview_user_retrieve
+ description: Preview user data for provider
+ parameters:
+ - in: query
+ name: for_user
+ schema:
+ type: integer
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PropertyMappingPreview'
+ description: ''
+ '400':
+ description: Bad request
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
+ /providers/wsfed/{id}/used_by/:
+ get:
+ operationId: providers_wsfed_used_by_list
+ description: Get a list of all objects that use this object
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this WS-Federation Provider.
+ required: true
+ tags:
+ - providers
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/UsedBy'
+ description: ''
+ '400':
+ $ref: '#/components/responses/ValidationErrorResponse'
+ '403':
+ $ref: '#/components/responses/GenericErrorResponse'
/rac/connection_tokens/:
get:
operationId: rac_connection_tokens_list
@@ -20126,6 +20553,7 @@ paths:
- authentik_providers_scim.scimmapping
- authentik_providers_scim.scimprovider
- authentik_providers_ssf.ssfprovider
+ - authentik_providers_ws_federation.wsfederationprovider
- authentik_rbac.initialpermissions
- authentik_rbac.role
- authentik_reports.dataexport
@@ -33319,6 +33747,7 @@ components:
- authentik.enterprise.providers.radius
- authentik.enterprise.providers.scim
- authentik.enterprise.providers.ssf
+ - authentik.enterprise.providers.ws_federation
- authentik.enterprise.reports
- authentik.enterprise.search
- authentik.enterprise.stages.authenticator_endpoint_gdtc
@@ -41892,6 +42321,7 @@ components:
- authentik_providers_microsoft_entra.microsoftentraprovider
- authentik_providers_microsoft_entra.microsoftentraprovidermapping
- authentik_providers_ssf.ssfprovider
+ - authentik_providers_ws_federation.wsfederationprovider
- authentik_reports.dataexport
- authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage
- authentik_stages_mtls.mutualtlsstage
@@ -45539,6 +45969,21 @@ components:
- pagination
- results
- autocomplete
+ PaginatedWSFederationProviderList:
+ type: object
+ properties:
+ pagination:
+ $ref: '#/components/schemas/Pagination'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/WSFederationProvider'
+ autocomplete:
+ $ref: '#/components/schemas/Autocomplete'
+ required:
+ - pagination
+ - results
+ - autocomplete
PaginatedWebAuthnDeviceList:
type: object
properties:
@@ -48868,7 +49313,7 @@ components:
to the Service Provider.
logout_method:
allOf:
- - $ref: '#/components/schemas/SAMLProviderLogoutMethodEnum'
+ - $ref: '#/components/schemas/SAMLLogoutMethods'
description: Method to use for logout. Front-channel iframe loads all logout
URLs simultaneously in hidden iframes. Front-channel native uses your
active browser tab to send post requests and redirect to providers. Back-channel
@@ -49725,6 +50170,91 @@ components:
$ref: '#/components/schemas/UserTypeEnum'
user_path_template:
type: string
+ PatchedWSFederationProviderRequest:
+ type: object
+ description: WSFederationProvider Serializer
+ properties:
+ name:
+ type: string
+ minLength: 1
+ authentication_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used for authentication when the associated application
+ is accessed by an un-authenticated user.
+ authorization_flow:
+ type: string
+ format: uuid
+ description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ reply_url:
+ type: string
+ format: uri
+ minLength: 1
+ assertion_valid_not_before:
+ type: string
+ minLength: 1
+ description: 'Assertion valid not before current time + this value (Format:
+ hours=-1;minutes=-2;seconds=-3).'
+ assertion_valid_not_on_or_after:
+ type: string
+ minLength: 1
+ description: 'Assertion not valid on or after current time + this value
+ (Format: hours=1;minutes=2;seconds=3).'
+ session_valid_not_on_or_after:
+ type: string
+ minLength: 1
+ description: 'Session not valid on or after current time + this value (Format:
+ hours=1;minutes=2;seconds=3).'
+ name_id_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: NameID Property Mapping
+ description: Configure how the NameID value will be created. When left empty,
+ the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
+ digest_algorithm:
+ $ref: '#/components/schemas/DigestAlgorithmEnum'
+ signature_algorithm:
+ $ref: '#/components/schemas/SignatureAlgorithmEnum'
+ signing_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Signing Keypair
+ description: Keypair used to sign outgoing Responses going to the Service
+ Provider.
+ encryption_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Encryption Keypair
+ description: When selected, incoming assertions are encrypted by the IdP
+ using the public key of the encryption keypair. The assertion is decrypted
+ by the SP using the the private key.
+ sign_assertion:
+ type: boolean
+ sign_logout_request:
+ type: boolean
+ default_name_id_policy:
+ $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
PatchedWebAuthnDeviceRequest:
type: object
description: Serializer for WebAuthn authenticator devices
@@ -50711,6 +51241,7 @@ components:
- authentik_providers_saml.samlprovider
- authentik_providers_scim.scimprovider
- authentik_providers_ssf.ssfprovider
+ - authentik_providers_ws_federation.wsfederationprovider
type: string
ProviderTypeEnum:
enum:
@@ -51929,6 +52460,12 @@ components:
- redirect
- post
type: string
+ SAMLLogoutMethods:
+ enum:
+ - frontchannel_iframe
+ - frontchannel_native
+ - backchannel
+ type: string
SAMLMetadata:
type: object
description: SAML Provider Metadata serializer
@@ -52183,7 +52720,7 @@ components:
to the Service Provider.
logout_method:
allOf:
- - $ref: '#/components/schemas/SAMLProviderLogoutMethodEnum'
+ - $ref: '#/components/schemas/SAMLLogoutMethods'
description: Method to use for logout. Front-channel iframe loads all logout
URLs simultaneously in hidden iframes. Front-channel native uses your
active browser tab to send post requests and redirect to providers. Back-channel
@@ -52259,12 +52796,6 @@ components:
- file
- invalidation_flow
- name
- SAMLProviderLogoutMethodEnum:
- enum:
- - frontchannel_iframe
- - frontchannel_native
- - backchannel
- type: string
SAMLProviderRequest:
type: object
description: SAMLProvider Serializer
@@ -52383,7 +52914,7 @@ components:
to the Service Provider.
logout_method:
allOf:
- - $ref: '#/components/schemas/SAMLProviderLogoutMethodEnum'
+ - $ref: '#/components/schemas/SAMLLogoutMethods'
description: Method to use for logout. Front-channel iframe loads all logout
URLs simultaneously in hidden iframes. Front-channel native uses your
active browser tab to send post requests and redirect to providers. Back-channel
@@ -56489,6 +57020,240 @@ components:
- id
- timestamp
- version
+ WSFederationProvider:
+ type: object
+ description: WSFederationProvider Serializer
+ properties:
+ pk:
+ type: integer
+ readOnly: true
+ title: ID
+ name:
+ type: string
+ authentication_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used for authentication when the associated application
+ is accessed by an un-authenticated user.
+ authorization_flow:
+ type: string
+ format: uuid
+ description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ component:
+ type: string
+ description: Get object component so that we know how to edit the object
+ readOnly: true
+ assigned_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
+ verbose_name:
+ type: string
+ description: Return object's verbose_name
+ readOnly: true
+ verbose_name_plural:
+ type: string
+ description: Return object's plural verbose_name
+ readOnly: true
+ meta_model_name:
+ type: string
+ description: Return internal model name
+ readOnly: true
+ reply_url:
+ type: string
+ format: uri
+ assertion_valid_not_before:
+ type: string
+ description: 'Assertion valid not before current time + this value (Format:
+ hours=-1;minutes=-2;seconds=-3).'
+ assertion_valid_not_on_or_after:
+ type: string
+ description: 'Assertion not valid on or after current time + this value
+ (Format: hours=1;minutes=2;seconds=3).'
+ session_valid_not_on_or_after:
+ type: string
+ description: 'Session not valid on or after current time + this value (Format:
+ hours=1;minutes=2;seconds=3).'
+ name_id_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: NameID Property Mapping
+ description: Configure how the NameID value will be created. When left empty,
+ the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
+ digest_algorithm:
+ $ref: '#/components/schemas/DigestAlgorithmEnum'
+ signature_algorithm:
+ $ref: '#/components/schemas/SignatureAlgorithmEnum'
+ signing_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Signing Keypair
+ description: Keypair used to sign outgoing Responses going to the Service
+ Provider.
+ encryption_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Encryption Keypair
+ description: When selected, incoming assertions are encrypted by the IdP
+ using the public key of the encryption keypair. The assertion is decrypted
+ by the SP using the the private key.
+ sign_assertion:
+ type: boolean
+ sign_logout_request:
+ type: boolean
+ default_name_id_policy:
+ $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
+ url_download_metadata:
+ type: string
+ description: Get metadata download URL
+ readOnly: true
+ url_wsfed:
+ type: string
+ description: Get WS-Fed url
+ readOnly: true
+ wtrealm:
+ type: string
+ readOnly: true
+ required:
+ - assigned_application_name
+ - assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
+ - authorization_flow
+ - component
+ - invalidation_flow
+ - meta_model_name
+ - name
+ - pk
+ - reply_url
+ - url_download_metadata
+ - url_wsfed
+ - verbose_name
+ - verbose_name_plural
+ - wtrealm
+ WSFederationProviderRequest:
+ type: object
+ description: WSFederationProvider Serializer
+ properties:
+ name:
+ type: string
+ minLength: 1
+ authentication_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used for authentication when the associated application
+ is accessed by an un-authenticated user.
+ authorization_flow:
+ type: string
+ format: uuid
+ description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
+ property_mappings:
+ type: array
+ items:
+ type: string
+ format: uuid
+ reply_url:
+ type: string
+ format: uri
+ minLength: 1
+ assertion_valid_not_before:
+ type: string
+ minLength: 1
+ description: 'Assertion valid not before current time + this value (Format:
+ hours=-1;minutes=-2;seconds=-3).'
+ assertion_valid_not_on_or_after:
+ type: string
+ minLength: 1
+ description: 'Assertion not valid on or after current time + this value
+ (Format: hours=1;minutes=2;seconds=3).'
+ session_valid_not_on_or_after:
+ type: string
+ minLength: 1
+ description: 'Session not valid on or after current time + this value (Format:
+ hours=1;minutes=2;seconds=3).'
+ name_id_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: NameID Property Mapping
+ description: Configure how the NameID value will be created. When left empty,
+ the NameIDPolicy of the incoming request will be considered
+ authn_context_class_ref_mapping:
+ type: string
+ format: uuid
+ nullable: true
+ title: AuthnContextClassRef Property Mapping
+ description: Configure how the AuthnContextClassRef value will be created.
+ When left empty, the AuthnContextClassRef will be set based on which authentication
+ methods the user used to authenticate.
+ digest_algorithm:
+ $ref: '#/components/schemas/DigestAlgorithmEnum'
+ signature_algorithm:
+ $ref: '#/components/schemas/SignatureAlgorithmEnum'
+ signing_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Signing Keypair
+ description: Keypair used to sign outgoing Responses going to the Service
+ Provider.
+ encryption_kp:
+ type: string
+ format: uuid
+ nullable: true
+ title: Encryption Keypair
+ description: When selected, incoming assertions are encrypted by the IdP
+ using the public key of the encryption keypair. The assertion is decrypted
+ by the SP using the the private key.
+ sign_assertion:
+ type: boolean
+ sign_logout_request:
+ type: boolean
+ default_name_id_policy:
+ $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
+ required:
+ - authorization_flow
+ - invalidation_flow
+ - name
+ - reply_url
WebAuthnDevice:
type: object
description: Serializer for WebAuthn authenticator devices
@@ -56570,6 +57335,7 @@ components:
- $ref: '#/components/schemas/SAMLProviderRequest'
- $ref: '#/components/schemas/SCIMProviderRequest'
- $ref: '#/components/schemas/SSFProviderRequest'
+ - $ref: '#/components/schemas/WSFederationProviderRequest'
discriminator:
propertyName: provider_model
mapping:
@@ -56583,6 +57349,7 @@ components:
authentik_providers_saml.samlprovider: '#/components/schemas/SAMLProviderRequest'
authentik_providers_scim.scimprovider: '#/components/schemas/SCIMProviderRequest'
authentik_providers_ssf.ssfprovider: '#/components/schemas/SSFProviderRequest'
+ authentik_providers_ws_federation.wsfederationprovider: '#/components/schemas/WSFederationProviderRequest'
securitySchemes:
authentik:
type: http
diff --git a/schemas/authorization.xsd b/schemas/authorization.xsd
new file mode 100644
index 0000000000..a634dc55e9
--- /dev/null
+++ b/schemas/authorization.xsd
@@ -0,0 +1,145 @@
+
+
+
+
Invalid provider type ${this.provider?.component}
`; } diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index ced944d704..215612b79e 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.ts @@ -5,12 +5,7 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { type AkCryptoCertificateSearch } from "#admin/common/ak-crypto-certificate-search"; import { BaseProviderForm } from "#admin/providers/BaseProviderForm"; -import { - ProvidersApi, - SAMLBindingsEnum, - SAMLProvider, - SAMLProviderLogoutMethodEnum, -} from "@goauthentik/api"; +import { ProvidersApi, SAMLBindingsEnum, SAMLLogoutMethods, SAMLProvider } from "@goauthentik/api"; import { customElement, state } from "lit/decorators.js"; @@ -26,8 +21,7 @@ export class SAMLProviderFormPage extends BaseProviderForm+ ${msg("Flow used when authorizing this this.instance?.")} +
++ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +
++ ${msg("Flow used when logging out of this this.instance?.")} +
++ ${msg( + "Certificate used to sign outgoing Responses going to the Service this.instance?.", + )} +
++ ${msg( + "When selected, assertions will be encrypted using this keypair.", + )} +
++ ${msg( + "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", + )} +
++ ${msg( + "Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate.", + )} +
++ ${msg( + "Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).", + )} +
+${value}