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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/schemas/oasis-200401-wss-wssecurity-secext-1.0.xsd b/schemas/oasis-200401-wss-wssecurity-secext-1.0.xsd new file mode 100644 index 0000000000..7596dc5dc1 --- /dev/null +++ b/schemas/oasis-200401-wss-wssecurity-secext-1.0.xsd @@ -0,0 +1,195 @@ + + + + + + + + + This type represents an element with arbitrary attributes. + + + + + + + + + + + This type is used for password elements per Section 4.1. + + + + + + + + + + This type is used for elements containing stringified binary data. + + + + + + + + + + This type represents a username token per Section 4.1 + + + + + + + + + + + A security token that is encoded in binary + + + + + + + + + + A security token key identifier + + + + + + + + + + Typedef to allow a list of usages (as URIs). + + + + + + This global attribute is used to indicate the usage of a referenced or indicated token within the containing context + + + + + This type represents a reference to an external security token. + + + + + + + + This type represents a reference to an embedded security token. + + + + + + + + + + This type is used reference a security token. + + + + + + + + + + + This complexType defines header block to use for security-relevant data directed at a specific SOAP actor. + + + + + The use of "any" is to allow extensibility and different forms of security data. + + + + + + + + This complexType defines a container for elements to be specified from any namespace as properties/parameters of a DSIG transformation. + + + + + The use of "any" is to allow extensibility from any namespace. + + + + + + + + This element defines the wsse:UsernameToken element per Section 4.1. + + + + + This element defines the wsse:BinarySecurityToken element per Section 4.2. + + + + + This element defines a security token reference + + + + + This element defines a security token embedded reference + + + + + This element defines a key identifier reference + + + + + This element defines the wsse:SecurityTokenReference per Section 4.3. + + + + + This element defines the wsse:Security SOAP header element per Section 4. + + + + + This element contains properties for transformations from any namespace, including DSIG. + + + + + + + + + + + + + + + + diff --git a/schemas/oasis-200401-wss-wssecurity-utility-1.0.xsd b/schemas/oasis-200401-wss-wssecurity-utility-1.0.xsd new file mode 100644 index 0000000000..e088d137e4 --- /dev/null +++ b/schemas/oasis-200401-wss-wssecurity-utility-1.0.xsd @@ -0,0 +1,108 @@ + + + + + + + +This type defines the fault code value for Timestamp message expiration. + + + + + + + + + + +This global attribute supports annotating arbitrary elements with an ID. + + + + + + +Convenience attribute group used to simplify this schema. + + + + + + + + + +This type is for elements whose [children] is a psuedo-dateTime and can have arbitrary attributes. + + + + + + + + + + + +This type is for elements whose [children] is an anyURI and can have arbitrary attributes. + + + + + + + + + + + + +This complex type ties together the timestamp related elements into a composite type. + + + + + + + + + + + + + + +This element allows Timestamps to be applied anywhere element wildcards are present, +including as a SOAP header. + + + + + + + +This element allows an expiration time to be applied anywhere element wildcards are present. + + + + + + +This element allows a creation time to be applied anywhere element wildcards are present. + + + + diff --git a/schemas/w3-addr.xsd b/schemas/w3-addr.xsd new file mode 100644 index 0000000000..47362edbe2 --- /dev/null +++ b/schemas/w3-addr.xsd @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/schemas/ws-addr.xsd b/schemas/ws-addr.xsd new file mode 100644 index 0000000000..8a668e2d39 --- /dev/null +++ b/schemas/ws-addr.xsd @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + If "Policy" elements from namespace "http://schemas.xmlsoap.org/ws/2002/12/policy#policy" are used, they must appear first (before any extensibility elements). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/schemas/ws-authorization.xsd b/schemas/ws-authorization.xsd new file mode 100644 index 0000000000..a634dc55e9 --- /dev/null +++ b/schemas/ws-authorization.xsd @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/schemas/ws-federation.xsd b/schemas/ws-federation.xsd new file mode 100644 index 0000000000..5c94454028 --- /dev/null +++ b/schemas/ws-federation.xsd @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/schemas/ws-securitypolicy-1.2.xsd b/schemas/ws-securitypolicy-1.2.xsd new file mode 100644 index 0000000000..710acf4b53 --- /dev/null +++ b/schemas/ws-securitypolicy-1.2.xsd @@ -0,0 +1,1205 @@ + + + + + + + + + + + 4.1.1 SignedParts Assertion + + + + + + + 4.2.1 EncryptedParts Assertion + + + + + + + + + + + + + + + + + + + + + + + 4.1.2 SignedElements Assertion + + + + + + + 4.2.2 EncryptedElements Assertion + + + + + + + 4.3.1 RequiredElements Assertion + + + + + + + + + + + + + + + + + 5.1 Token Inclusion + + + + + + + + + + + + + + + + + + + + 5.4.1 UsernameToken Assertion + + + + + + + + + + + + + + + + + + + + 5.4.1 UsernameToken Assertion + + + + + + + 5.4.1 UsernameToken Assertion + + + + + + + 5.4.1 UsernameToken Assertion + + + + + + + 5.4.1 UsernameToken Assertion + + + + + + + + + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + 5.4.2 IssuedToken Assertion + + + + + + + + 5.4.3 X509Token Assertion + + + + + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + 5.4.3 X509Token Assertion + + + + + + + + 5.4.4 KerberosToken Assertion + + + + + + + + + + + + 5.4.4 KerberosToken Assertion + + + + + + + 5.4.4 KerberosToken Assertion + + + + + + + + 5.4.5 SpnegoContextToken Assertion + + + + + + + + + + + + + + + + + + + + + + 5.4.5 SpnegoContextToken Assertion + + + + + + + 5.4.5 SpnegoContextToken Assertion + + + + + + + 5.4.5 SpnegoContextToken Assertion + + + + + + + + 5.4.6 SecurityContextToken Assertion + + + + + + + + + + + 5.4.6 SecurityContextToken Assertion + + + + + + + 5.4.6 SecurityContextToken Assertion + + + + + + + + 5.4.7 SecureConversationToken Assertion + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5.4.7 SecureConversationToken Assertion + + + + + + + + 5.4.8 SamlToken Assertion + + + + + + + + + + + + 5.4.8 SamlToken Assertion + + + + + + + 5.4.8 SamlToken Assertion + + + + + + + 5.4.8 SamlToken Assertion + + + + + + + + 5.4.9 RelToken Assertion + + + + + + + + + + + + 5.4.9 RelToken Assertion + + + + + + + 5.4.9 RelToken Assertion + + + + + + + 5.4.9 RelToken Assertion + + + + + + + 5.4.9 RelToken Assertion + + + + + + + + 5.4.10 HttpsToken Assertion + + + + + + + 5.4.10 HttpsToken Assertion + + + + + + + 5.4.10 HttpsToken Assertion + + + + + + + 5.4.10 HttpsToken Assertion + + + + + + + + 5.4.11 KeyValueToken Assertion + + + + + + + + + + + + + + + 5.4.11 KeyValueToken Assertion + + + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + 7.1 AlgorithmSuite Assertion + + + + + + + + 7.2 Layout Assertion + + + + + + + + 7.2 Layout Assertion + + + + + + + 7.2 Layout Assertion + + + + + + + 7.2 Layout Assertion + + + + + + + 7.2 Layout Assertion + + + + + + + + 7.3 TransportBinding Assertion + + + + + + + + 7.3 TransportBinding Assertion + + + + + + + + + + 7.3 TransportBinding Assertion + + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + 8=7.4 SymmetricBinding Assertion + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + 7.4 SymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + 7.5 AsymmetricBinding Assertion + + + + + + + + + + + + + + + + 8.1 SupportingTokens Assertion + + + + + + + + + + + + + 8.2 SignedSupportingTokens Assertion + + + + + + + + + + + + + 8.3 EndorsingSupportingTokens Assertion + + + + + + + + + + + + + 8.4 SignedEndorsingSupportingTokens Assertion + + + + + + + + + + + + + 8.5 SignedEncryptedSupportingTokens Assertion + + + + + + + + + + + + + 8.6 EncryptedSupportingTokens Assertion + + + + + + + + + + + + + 8.7 EndorsingEncryptedSupportingTokens Assertion + + + + + + + + + + + + + 8.8 SignedEndorsingEncryptedSupportingTokens Assertion + + + + + + + + + + + + + + 9.1 Wss10 Assertion + + + + + + + + 9.1 Wss10 Assertion + + + + + + + 9.1 Wss10 Assertion + + + + + + + 9.1 Wss10 Assertion + + + + + + + 9.1 Wss10 Assertion + + + + + + + + 9.2 Wss11 Assertion + + + + + + + + + + + + 9.2 Wss11 Assertion + + + + + + + 9.2 Wss11 Assertion + + + + + + + 9.2 Wss11 Assertion + + + + + + + + + 10.1 Trust13 Assertion + + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + + + 10.1 Trust13 Assertion + + + + + \ No newline at end of file diff --git a/schemas/ws-trust.xsd b/schemas/ws-trust.xsd new file mode 100644 index 0000000000..25a0f640f1 --- /dev/null +++ b/schemas/ws-trust.xsd @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + Actual content model is non-deterministic, hence wildcard. The following shows intended content model: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Actual content model is non-deterministic, hence wildcard. The following shows intended content model: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/test_provider_ws_fed.py b/tests/e2e/test_provider_ws_fed.py new file mode 100644 index 0000000000..5f0cd99000 --- /dev/null +++ b/tests/e2e/test_provider_ws_fed.py @@ -0,0 +1,218 @@ +"""test WSFed Provider flow""" + +from json import dumps + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as ec + +from authentik.blueprints.tests import apply_blueprint, reconcile_app +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_cert +from authentik.enterprise.providers.ws_federation.models import WSFederationProvider +from authentik.flows.models import Flow +from authentik.lib.generators import generate_id +from authentik.providers.saml.models import SAMLPropertyMapping +from tests.e2e.utils import SeleniumTestCase, retry + + +class TestProviderWSFed(SeleniumTestCase): + """test WS Federation flow""" + + def setup_client(self, provider: WSFederationProvider, app: Application, **kwargs): + metadata_url = ( + self.url( + "authentik_api:wsfederationprovider-metadata", + pk=provider.pk, + ) + + "?download" + ) + self.run_container( + image="ghcr.io/beryju/wsfed-test-sp:v0.1.2", + ports={ + "8080": "8080", + }, + environment={ + "WSFED_TEST_SP_WTREALM": f"goauthentik.io://app/{app.slug}", + "WSFED_TEST_SP_METADATA": metadata_url, + **kwargs, + }, + ) + + @retry() + @apply_blueprint( + "default/flow-default-authentication-flow.yaml", + "default/flow-default-invalidation-flow.yaml", + ) + @apply_blueprint( + "default/flow-default-provider-authorization-implicit-consent.yaml", + "default/flow-default-provider-invalidation.yaml", + ) + @apply_blueprint( + "system/providers-saml.yaml", + ) + @reconcile_app("authentik_crypto") + def test_sp_initiated_implicit(self): + """test WSFed Provider flow SP-initiated flow (implicit consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) + invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow") + provider = WSFederationProvider.objects.create( + name=generate_id(), + acs_url="http://localhost:8080", + authorization_flow=authorization_flow, + invalidation_flow=invalidation_flow, + signing_kp=create_test_cert(), + ) + provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + provider.save() + app = Application.objects.create( + name="WSFed", + slug=generate_id(), + provider=provider, + ) + self.setup_client(provider, app) + self.driver.get("http://localhost:8080") + self.login() + self.wait_for_url("http://localhost:8080/") + + body = self.parse_json_content(self.driver.find_element(By.CSS_SELECTOR, "pre")) + snippet = dumps(body, indent=2)[:500].replace("\n", " ") + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"), + self.user.name, + f"Claim 'name' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"), + self.user.username, + f"Claim 'windowsaccountname' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.goauthentik.io/2021/02/saml/username"), + self.user.username, + f"Claim 'saml/username' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.goauthentik.io/2021/02/saml/uid"), + str(self.user.pk), + f"Claim 'saml/uid' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"), + self.user.email, + f"Claim 'emailaddress' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"), + self.user.email, + f"Claim 'upn' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.driver.get("http://localhost:8080/Logout") + should_url = self.url( + "authentik_core:if-flow", + flow_slug=invalidation_flow.slug, + ) + self.wait.until( + lambda driver: driver.current_url.startswith(should_url), + f"URL {self.driver.current_url} doesn't match expected URL {should_url}", + ) + + @retry() + @apply_blueprint( + "default/flow-default-authentication-flow.yaml", + "default/flow-default-invalidation-flow.yaml", + ) + @apply_blueprint( + "default/flow-default-provider-authorization-explicit-consent.yaml", + ) + @apply_blueprint( + "system/providers-saml.yaml", + ) + @reconcile_app("authentik_crypto") + def test_sp_initiated_explicit(self): + """test WSFed Provider flow SP-initiated flow (explicit consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-explicit-consent" + ) + provider = WSFederationProvider.objects.create( + name=generate_id(), + acs_url="http://localhost:8080", + authorization_flow=authorization_flow, + signing_kp=create_test_cert(), + ) + provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + provider.save() + app = Application.objects.create( + name="WSFed", + slug=generate_id(), + provider=provider, + ) + self.setup_client(provider, app) + self.driver.get("http://localhost:8080") + self.login() + + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor"))) + + flow_executor = self.get_shadow_root("ak-flow-executor") + consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor) + + self.assertIn( + app.name, + consent_stage.find_element(By.CSS_SELECTOR, "[data-test-id='stage-heading']").text, + "Consent stage header mismatch", + ) + consent_stage.find_element( + By.CSS_SELECTOR, + "[type=submit]", + ).click() + + self.wait_for_url("http://localhost:8080/") + + body = self.parse_json_content(self.driver.find_element(By.CSS_SELECTOR, "pre")) + snippet = dumps(body, indent=2)[:500].replace("\n", " ") + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"), + self.user.name, + f"Claim 'name' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"), + self.user.username, + f"Claim 'windowsaccountname' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.goauthentik.io/2021/02/saml/username"), + self.user.username, + f"Claim 'saml/username' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.goauthentik.io/2021/02/saml/uid"), + str(self.user.pk), + f"Claim 'saml/uid' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"), + self.user.email, + f"Claim 'emailaddress' mismatch at {self.driver.current_url}: {snippet}", + ) + + self.assertEqual( + body.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"), + self.user.email, + f"Claim 'upn' mismatch at {self.driver.current_url}: {snippet}", + ) diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index fd1cdcc032..4d0eefd6e3 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -164,11 +164,20 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase): def wait_for_url(self, desired_url: str): """Wait until URL is `desired_url`.""" - self.wait.until( - lambda driver: driver.current_url == desired_url, - f"URL {self.driver.current_url} doesn't match expected URL {desired_url}. " - f"HTML: {self.driver.page_source[:1000]}", - ) + def waiter(driver: WebDriver): + current = driver.current_url + return current == desired_url + + # We catch and re-throw the exception from `wait.until`, as we can supply it + # an error message, however that message is evaluated when we call `.until()`, + # not when the error is thrown, so the URL in the error message will be incorrect. + try: + self.wait.until(waiter) + except TimeoutException as exc: + raise TimeoutException( + f"URL {self.driver.current_url} doesn't match expected URL {desired_url}. " + f"HTML: {self.driver.page_source[:1000]}" + ) from exc def url(self, view: str, query: dict | None = None, **kwargs) -> str: """reverse `view` with `**kwargs` into full URL using live_server_url""" diff --git a/web/authentik/sources/wsfed.svg b/web/authentik/sources/wsfed.svg new file mode 100644 index 0000000000..f34bb4a130 --- /dev/null +++ b/web/authentik/sources/wsfed.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml.ts b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml.ts index 2d013e7613..21d604d79e 100644 --- a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml.ts +++ b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-saml.ts @@ -6,7 +6,7 @@ import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.j import { type AkCryptoCertificateSearch } from "#admin/common/ak-crypto-certificate-search"; import { renderForm } from "#admin/providers/saml/SAMLProviderFormForm"; -import { SAMLBindingsEnum, SAMLProvider, SAMLProviderLogoutMethodEnum } from "@goauthentik/api"; +import { SAMLBindingsEnum, SAMLLogoutMethods, SAMLProvider } from "@goauthentik/api"; import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; @@ -26,18 +26,18 @@ export class ApplicationWizardProviderSamlForm extends ApplicationWizardProvider protected hasPostBinding = false; @state() - protected logoutMethod: string = SAMLProviderLogoutMethodEnum.FrontchannelIframe; + protected logoutMethod: string = SAMLLogoutMethods.FrontchannelIframe; get formValues() { const values = super.formValues; // If SLS binding is redirect, ensure logout method is not backchannel if ( values.slsBinding === SAMLBindingsEnum.Redirect && - values.logoutMethod === SAMLProviderLogoutMethodEnum.Backchannel + values.logoutMethod === SAMLLogoutMethods.Backchannel ) { return { ...values, - logoutMethod: SAMLProviderLogoutMethodEnum.FrontchannelIframe, + logoutMethod: SAMLLogoutMethods.FrontchannelIframe, }; } return values; @@ -65,9 +65,9 @@ export class ApplicationWizardProviderSamlForm extends ApplicationWizardProvider // If switching to redirect binding, change logout method from backchannel if needed if ( target.value === SAMLBindingsEnum.Redirect && - this.logoutMethod === SAMLProviderLogoutMethodEnum.Backchannel + this.logoutMethod === SAMLLogoutMethods.Backchannel ) { - this.logoutMethod = SAMLProviderLogoutMethodEnum.FrontchannelIframe; + this.logoutMethod = SAMLLogoutMethods.FrontchannelIframe; } }; diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index 589a0360ec..4a4d7b12d2 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -10,6 +10,7 @@ import "#admin/providers/radius/RadiusProviderForm"; import "#admin/providers/saml/SAMLProviderForm"; import "#admin/providers/scim/SCIMProviderForm"; import "#admin/providers/ssf/SSFProviderFormPage"; +import "#admin/providers/wsfed/WSFederationProviderForm"; import "#elements/buttons/SpinnerButton/index"; import "#elements/forms/DeleteBulkForm"; import "#elements/forms/ModalForm"; diff --git a/web/src/admin/providers/ProviderViewPage.ts b/web/src/admin/providers/ProviderViewPage.ts index 24552a38d7..605a754697 100644 --- a/web/src/admin/providers/ProviderViewPage.ts +++ b/web/src/admin/providers/ProviderViewPage.ts @@ -8,6 +8,7 @@ import "#admin/providers/radius/RadiusProviderViewPage"; import "#admin/providers/saml/SAMLProviderViewPage"; import "#admin/providers/scim/SCIMProviderViewPage"; import "#admin/providers/ssf/SSFProviderViewPage"; +import "#admin/providers/wsfed/WSFederationProviderViewPage"; import "#elements/EmptyState"; import "#elements/buttons/SpinnerButton/ak-spinner-button"; @@ -86,6 +87,8 @@ export class ProviderViewPage extends AKElement { >`; case "ak-provider-ssf-form": return html``; + case "ak-provider-wsfed-form": + return html``; default: return html`

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 { protected hasPostBinding = false; @state() - protected logoutMethod: SAMLProviderLogoutMethodEnum = - SAMLProviderLogoutMethodEnum.FrontchannelIframe; + protected logoutMethod: SAMLLogoutMethods = SAMLLogoutMethods.FrontchannelIframe; public override reset(): void { super.reset(); @@ -35,7 +29,7 @@ export class SAMLProviderFormPage extends BaseProviderForm { this.hasSigningKp = false; this.hasSlsUrl = false; this.hasPostBinding = false; - this.logoutMethod = SAMLProviderLogoutMethodEnum.FrontchannelIframe; + this.logoutMethod = SAMLLogoutMethods.FrontchannelIframe; } async loadInstance(pk: number): Promise { @@ -45,8 +39,7 @@ export class SAMLProviderFormPage extends BaseProviderForm { this.hasSigningKp = !!provider.signingKp; this.hasSlsUrl = !!provider.slsUrl; this.hasPostBinding = provider.slsBinding === SAMLBindingsEnum.Post; - this.logoutMethod = - provider.logoutMethod ?? SAMLProviderLogoutMethodEnum.FrontchannelIframe; + this.logoutMethod = provider.logoutMethod ?? SAMLLogoutMethods.FrontchannelIframe; return provider; } @@ -54,9 +47,9 @@ export class SAMLProviderFormPage extends BaseProviderForm { // If SLS binding is redirect, ensure logout method is not backchannel if ( data.slsBinding === SAMLBindingsEnum.Redirect && - data.logoutMethod === SAMLProviderLogoutMethodEnum.Backchannel + data.logoutMethod === SAMLLogoutMethods.Backchannel ) { - data.logoutMethod = SAMLProviderLogoutMethodEnum.FrontchannelIframe; + data.logoutMethod = SAMLLogoutMethods.FrontchannelIframe; } if (this.instance) { @@ -92,15 +85,15 @@ export class SAMLProviderFormPage extends BaseProviderForm { // If switching to redirect binding, change logout method from backchannel if needed if ( target.value === SAMLBindingsEnum.Redirect && - this.logoutMethod === SAMLProviderLogoutMethodEnum.Backchannel + this.logoutMethod === SAMLLogoutMethods.Backchannel ) { - this.logoutMethod = SAMLProviderLogoutMethodEnum.FrontchannelIframe; + this.logoutMethod = SAMLLogoutMethods.FrontchannelIframe; } }; const setLogoutMethod = (ev: Event) => { const target = ev.target as HTMLInputElement; - this.logoutMethod = target.value as SAMLProviderLogoutMethodEnum; + this.logoutMethod = target.value as SAMLLogoutMethods; }; return renderForm({ diff --git a/web/src/admin/providers/saml/SAMLProviderFormForm.ts b/web/src/admin/providers/saml/SAMLProviderFormForm.ts index e88605f2f4..2ca06677ff 100644 --- a/web/src/admin/providers/saml/SAMLProviderFormForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts @@ -22,10 +22,10 @@ import { PropertymappingsApi, PropertymappingsProviderSamlListRequest, SAMLBindingsEnum, + SAMLLogoutMethods, SAMLNameIDPolicyEnum, SAMLPropertyMapping, SAMLProvider, - SAMLProviderLogoutMethodEnum, ValidationError, } from "@goauthentik/api"; @@ -79,16 +79,16 @@ function renderHasSlsUrl( const logoutMethodOptions: RadioOption[] = [ { label: msg("Front-channel (Iframe)"), - value: SAMLProviderLogoutMethodEnum.FrontchannelIframe, + value: SAMLLogoutMethods.FrontchannelIframe, default: true, }, { label: msg("Front-channel (Native)"), - value: SAMLProviderLogoutMethodEnum.FrontchannelNative, + value: SAMLLogoutMethods.FrontchannelNative, }, { label: msg("Back-channel (POST)"), - value: SAMLProviderLogoutMethodEnum.Backchannel, + value: SAMLLogoutMethods.Backchannel, disabled: !hasPostBinding, }, ]; diff --git a/web/src/admin/providers/saml/SAMLProviderViewPage.ts b/web/src/admin/providers/saml/SAMLProviderViewPage.ts index 1fe0a80913..e15c885b2a 100644 --- a/web/src/admin/providers/saml/SAMLProviderViewPage.ts +++ b/web/src/admin/providers/saml/SAMLProviderViewPage.ts @@ -47,7 +47,7 @@ import PFList from "@patternfly/patternfly/components/List/list.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -interface SAMLPreviewAttribute { +export interface SAMLPreviewAttribute { attributes: { Name: string; Value: string[]; diff --git a/web/src/admin/providers/wsfed/WSFederationProviderForm.ts b/web/src/admin/providers/wsfed/WSFederationProviderForm.ts new file mode 100644 index 0000000000..974b04a13f --- /dev/null +++ b/web/src/admin/providers/wsfed/WSFederationProviderForm.ts @@ -0,0 +1,372 @@ +import "#admin/common/ak-crypto-certificate-search"; +import "#components/ak-text-input"; +import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider"; +import "#elements/ak-dual-select/ak-dual-select-provider"; +import "#elements/forms/FormGroup"; +import "#elements/forms/HorizontalFormElement"; +import "#elements/forms/SearchSelect/index"; +import "#elements/utils/TimeDeltaHelp"; +import "#components/ak-radio-input"; +import "#components/ak-switch-input"; +import "#admin/common/ak-flow-search/ak-flow-search"; +import "#elements/forms/Radio"; + +import { DEFAULT_CONFIG } from "#common/api/config"; + +import AkCryptoCertificateSearch from "#admin/common/ak-crypto-certificate-search"; +import { BaseProviderForm } from "#admin/providers/BaseProviderForm"; +import { + propertyMappingsProvider, + propertyMappingsSelector, +} from "#admin/providers/saml/SAMLProviderFormHelpers"; +import { + digestAlgorithmOptions, + signatureAlgorithmOptions, +} from "#admin/providers/saml/SAMLProviderOptions"; + +import { + FlowsInstancesListDesignationEnum, + PropertymappingsApi, + PropertymappingsProviderSamlListRequest, + ProvidersApi, + SAMLNameIDPolicyEnum, + SAMLPropertyMapping, + WSFederationProvider, +} from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, nothing, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +/** + * Form page for SSF Authentication Method + * + * @element ak-provider-ssf-form + * + */ + +@customElement("ak-provider-wsfed-form") +export class WSFederationProviderForm extends BaseProviderForm { + @state() + protected hasSigningKp = false; + + async loadInstance(pk: number): Promise { + const provider = await new ProvidersApi(DEFAULT_CONFIG).providersWsfedRetrieve({ + id: pk, + }); + this.hasSigningKp = !!provider.signingKp; + return provider; + } + + async send(data: WSFederationProvider): Promise { + if (this.instance) { + return new ProvidersApi(DEFAULT_CONFIG).providersWsfedUpdate({ + id: this.instance.pk, + wSFederationProviderRequest: data, + }); + } + return new ProvidersApi(DEFAULT_CONFIG).providersWsfedCreate({ + wSFederationProviderRequest: data, + }); + } + + renderForm(): TemplateResult { + const provider = this.instance; + + return html` + + +

+ ${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?.")} +

+
+
+
+ + +
+ + { + const target = ev.target as AkCryptoCertificateSearch; + if (!target) return; + this.hasSigningKp = !!target.selectedKeypair; + }} + singleton + > +

+ ${msg( + "Certificate used to sign outgoing Responses going to the Service this.instance?.", + )} +

+
+ ${this.hasSigningKp + ? html` + + + ` + : nothing} + + + +

+ ${msg( + "When selected, assertions will be encrypted using this keypair.", + )} +

+
+ + + + + => { + const args: PropertymappingsProviderSamlListRequest = { + ordering: "saml_name", + }; + if (query !== undefined) { + args.search = query; + } + const items = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderSamlList(args); + return items.results; + }} + .renderElement=${(item: SAMLPropertyMapping): string => { + return item.name; + }} + .value=${( + item: SAMLPropertyMapping | undefined, + ): string | undefined => { + return item?.pk; + }} + .selected=${(item: SAMLPropertyMapping): boolean => { + return this.instance?.nameIdMapping === item.pk; + }} + blankable + > + +

+ ${msg( + "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", + )} +

+
+ + => { + const args: PropertymappingsProviderSamlListRequest = { + ordering: "saml_name", + }; + if (query !== undefined) { + args.search = query; + } + const items = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderSamlList(args); + return items.results; + }} + .renderElement=${(item: SAMLPropertyMapping): string => { + return item.name; + }} + .value=${( + item: SAMLPropertyMapping | undefined, + ): string | undefined => { + return item?.pk; + }} + .selected=${(item: SAMLPropertyMapping): boolean => { + return this.instance?.authnContextClassRefMapping === item.pk; + }} + blankable + > + +

+ ${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).", + )} +

+
+ + + + + + +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-wsfed-form": WSFederationProviderForm; + } +} diff --git a/web/src/admin/providers/wsfed/WSFederationProviderViewPage.ts b/web/src/admin/providers/wsfed/WSFederationProviderViewPage.ts new file mode 100644 index 0000000000..e6388758c2 --- /dev/null +++ b/web/src/admin/providers/wsfed/WSFederationProviderViewPage.ts @@ -0,0 +1,511 @@ +import "#admin/providers/RelatedApplicationButton"; +import "#admin/providers/wsfed/WSFederationProviderForm"; +import "#admin/rbac/ObjectPermissionsPage"; +import "#components/events/ObjectChangelog"; +import "#elements/CodeMirror"; +import "#elements/EmptyState"; +import "#elements/Tabs"; +import "#elements/buttons/ActionButton/index"; +import "#elements/buttons/ModalButton"; +import "#elements/buttons/SpinnerButton/index"; + +import { DEFAULT_CONFIG } from "#common/api/config"; +import { EVENT_REFRESH } from "#common/constants"; +import { MessageLevel } from "#common/messages"; + +import { AKElement } from "#elements/Base"; +import { showMessage } from "#elements/messages/MessageContainer"; +import { SlottedTemplateResult } from "#elements/types"; + +import renderDescriptionList from "#components/DescriptionList"; + +import { SAMLPreviewAttribute } from "#admin/providers/saml/SAMLProviderViewPage"; + +import { + CertificateKeyPair, + CoreApi, + CoreUsersListRequest, + CryptoApi, + ProvidersApi, + RbacPermissionsAssignedByRolesListModelEnum, + SAMLMetadata, + User, + WSFederationProvider, +} from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; + +@customElement("ak-provider-wsfed-view") +export class WSFederationProviderViewPage extends AKElement { + @property({ type: Number }) + public providerID: number | null = null; + + @state() + protected provider: WSFederationProvider | null = null; + + @state() + protected preview: SAMLPreviewAttribute | null = null; + + @state() + protected metadata: SAMLMetadata | null = null; + + @state() + protected signer: CertificateKeyPair | null = null; + + @state() + protected verifier: CertificateKeyPair | null = null; + + @state() + protected previewUser: User | null = null; + + static styles: CSSResult[] = [ + PFButton, + PFPage, + PFGrid, + PFContent, + PFCard, + PFList, + PFDescriptionList, + PFForm, + PFFormControl, + PFBanner, + ]; + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this.provider?.pk) return; + this.fetchProvider(this.provider.pk); + }); + } + + fetchPreview(): void { + new ProvidersApi(DEFAULT_CONFIG) + .providersWsfedPreviewUserRetrieve({ + id: this.provider?.pk || 0, + forUser: this.previewUser?.pk, + }) + .then((preview) => { + this.preview = preview.preview as SAMLPreviewAttribute; + }); + } + + fetchCertificate(kpUuid: string) { + return new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsRetrieve({ kpUuid }); + } + + fetchSigningCertificate(kpUuid: string) { + this.fetchCertificate(kpUuid).then((kp) => { + this.signer = kp; + this.requestUpdate("signer"); + }); + } + + fetchProvider(id: number) { + new ProvidersApi(DEFAULT_CONFIG).providersWsfedRetrieve({ id }).then((prov) => { + this.provider = prov; + // Clear existing signing certificate if the provider has none + if (!this.provider.signingKp) { + this.signer = null; + } else { + this.fetchSigningCertificate(this.provider.signingKp); + } + }); + } + + protected override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("providerID") && this.providerID) { + this.fetchProvider(this.providerID); + } + } + + renderRelatedObjects(): TemplateResult { + const relatedObjects = []; + if (this.provider?.assignedApplicationName) { + relatedObjects.push( + html`
+
+ ${msg("Metadata")} +
+
+
+ + ${msg("Download")} + + { + if (!navigator.clipboard) { + return Promise.resolve( + showMessage({ + level: MessageLevel.info, + message: this.provider?.urlDownloadMetadata || "", + }), + ); + } + return navigator.clipboard.writeText( + this.provider?.urlDownloadMetadata || "", + ); + }} + > + ${msg("Copy download URL")} + +
+
+
`, + ); + } + if (this.signer) { + relatedObjects.push( + html`
+
+ ${msg("Download signing certificate")} +
+
+ +
+
`, + ); + } + return html`
+
${msg("Related objects")}
+
+
+ ${relatedObjects.length > 0 ? relatedObjects : html`-`} +
+
+
`; + } + + render(): SlottedTemplateResult { + if (!this.provider) { + return nothing; + } + return html`
+ +
+ ${this.renderTabOverview()} +
+ ${this.renderTabMetadata()} +
{ + this.fetchPreview(); + }} + > + ${this.renderTabPreview()} +
+
+
+
+ + +
+
+
+ +
+
`; + } + + renderTabOverview(): SlottedTemplateResult { + if (!this.provider) { + return nothing; + } + return html`${ + this.provider?.assignedApplicationName + ? nothing + : html`
+ ${msg("Warning: Provider is not used by an Application.")} +
` + } +
+
+
+ ${renderDescriptionList( + [ + [msg("Name"), this.provider.name], + [ + msg("Assigned to application"), + html``, + ], + [msg("Reply URL"), this.provider.replyUrl], + ], + { threecolumn: true }, + )} +
+ +
+ ${this.renderRelatedObjects()} + ${ + this.provider.assignedApplicationName + ? html`
+
+ ${msg("WS-Federation Configuration")} +
+
+
+
+ + +
+
+ + +
+
+
+
` + : nothing + } +
+ `; + } + + renderTabMetadata(): SlottedTemplateResult { + if (!this.provider) { + return nothing; + } + return html` + ${this.provider.assignedApplicationName + ? html`
{ + new ProvidersApi(DEFAULT_CONFIG) + .providersWsfedMetadataRetrieve({ + id: this.provider?.pk || 0, + }) + .then((metadata) => (this.metadata = metadata)); + }} + > +
+
+
${msg("WS-Federation Metadata")}
+
+ + ${msg("Download")} + + { + if (!navigator.clipboard) { + return Promise.resolve( + showMessage({ + level: MessageLevel.info, + message: + this.provider?.urlDownloadMetadata || "", + }), + ); + } + return navigator.clipboard.writeText( + this.provider?.urlDownloadMetadata || "", + ); + }} + > + ${msg("Copy download URL")} + +
+ +
+
+
` + : nothing} + `; + } + + renderTabPreview(): SlottedTemplateResult { + if (!this.preview) { + return html``; + } + return html`
+
+
${msg("Example WS-Federation attributes")}
+
+ ${renderDescriptionList([ + [ + msg("Preview for user"), + html` + => { + const args: CoreUsersListRequest = { + ordering: "username", + }; + if (query !== undefined) { + args.search = query; + } + const users = await new CoreApi( + DEFAULT_CONFIG, + ).coreUsersList(args); + return users.results; + }} + .renderElement=${(user: User): string => { + return user.username; + }} + .renderDescription=${(user: User): TemplateResult => { + return html`${user.name}`; + }} + .value=${(user: User | undefined): number | undefined => { + return user?.pk; + }} + .selected=${(user: User): boolean => { + return user.pk === this.previewUser?.pk; + }} + blankable + @ak-change=${(ev: CustomEvent) => { + this.previewUser = ev.detail.value; + this.fetchPreview(); + }} + > + + `, + ], + ])} +
+
+
+
+
+ ${msg("NameID attribute")} +
+
+
+ ${this.preview?.nameID} +
+
+
+
+
+
+
+ ${this.preview?.attributes.map((attr) => { + return html`
+
+ ${attr.Name} +
+
+
+
    + ${attr.Value.map((value) => { + return html`
  • ${value}
  • `; + })} +
+
+
+
`; + })} +
+
+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-wsfed-view": WSFederationProviderViewPage; + } +}