sources/saml: revamp SAML Source (#3785)
* update saml source to use user connections, add all attributes to flow context Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * check for SAML Status in response, add tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * package apple icon Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add webui for connections Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							| @ -53,7 +53,7 @@ migrate: | ||||
| 	python -m lifecycle.migrate | ||||
|  | ||||
| run: | ||||
| 	go run -v cmd/server/main.go | ||||
| 	go run -v ./cmd/server/ | ||||
|  | ||||
| i18n-extract: i18n-extract-core web-extract | ||||
|  | ||||
|  | ||||
| @ -59,7 +59,8 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet | ||||
| from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet | ||||
| from authentik.sources.plex.api.source import PlexSourceViewSet | ||||
| from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet | ||||
| from authentik.sources.saml.api import SAMLSourceViewSet | ||||
| from authentik.sources.saml.api.source import SAMLSourceViewSet | ||||
| from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionViewSet | ||||
| from authentik.stages.authenticator_duo.api import ( | ||||
|     AuthenticatorDuoStageViewSet, | ||||
|     DuoAdminDeviceViewSet, | ||||
| @ -138,6 +139,7 @@ router.register("sources/all", SourceViewSet) | ||||
| router.register("sources/user_connections/all", UserSourceConnectionViewSet) | ||||
| router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet) | ||||
| router.register("sources/user_connections/plex", PlexSourceConnectionViewSet) | ||||
| router.register("sources/user_connections/saml", UserSAMLSourceConnectionViewSet) | ||||
| router.register("sources/ldap", LDAPSourceViewSet) | ||||
| router.register("sources/saml", SAMLSourceViewSet) | ||||
| router.register("sources/oauth", OAuthSourceViewSet) | ||||
|  | ||||
| @ -130,8 +130,8 @@ class TestAuthNRequest(TestCase): | ||||
|         http_request.POST = QueryDict(mutable=True) | ||||
|         http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() | ||||
|  | ||||
|         response_parser = ResponseProcessor(self.source) | ||||
|         response_parser.parse(http_request) | ||||
|         response_parser = ResponseProcessor(self.source, http_request) | ||||
|         response_parser.parse() | ||||
|  | ||||
|     def test_request_id_invalid(self): | ||||
|         """Test generated AuthNRequest with invalid request ID""" | ||||
| @ -157,10 +157,10 @@ class TestAuthNRequest(TestCase): | ||||
|         http_request.POST = QueryDict(mutable=True) | ||||
|         http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() | ||||
|  | ||||
|         response_parser = ResponseProcessor(self.source) | ||||
|         response_parser = ResponseProcessor(self.source, http_request) | ||||
|  | ||||
|         with self.assertRaises(MismatchedRequestID): | ||||
|             response_parser.parse(http_request) | ||||
|             response_parser.parse() | ||||
|  | ||||
|     def test_signed_valid_detached(self): | ||||
|         """Test generated AuthNRequest with valid signature (detached)""" | ||||
|  | ||||
| @ -114,9 +114,6 @@ class AppleType(SourceType): | ||||
|     access_token_url = "https://appleid.apple.com/auth/token"  # nosec | ||||
|     profile_url = "" | ||||
|  | ||||
|     def icon_url(self) -> str: | ||||
|         return "https://appleid.cdn-apple.com/appleid/button/logo" | ||||
|  | ||||
|     def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge: | ||||
|         """Pre-general all the things required for the JS SDK""" | ||||
|         apple_client = AppleOAuthClient( | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/sources/saml/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/saml/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										29
									
								
								authentik/sources/saml/api/source_connection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								authentik/sources/saml/api/source_connection.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| """SAML Source Serializer""" | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | ||||
| from authentik.core.api.sources import UserSourceConnectionSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.sources.saml.models import UserSAMLSourceConnection | ||||
|  | ||||
|  | ||||
| class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|     """SAML Source Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = UserSAMLSourceConnection | ||||
|         fields = ["pk", "user", "source", "identifier"] | ||||
|  | ||||
|  | ||||
| class UserSAMLSourceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Source Viewset""" | ||||
|  | ||||
|     queryset = UserSAMLSourceConnection.objects.all() | ||||
|     serializer_class = UserSAMLSourceConnectionSerializer | ||||
|     filterset_fields = ["source__slug"] | ||||
|     search_fields = ["source__slug"] | ||||
|     permission_classes = [OwnerSuperuserPermissions] | ||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||
|     ordering = ["source__slug"] | ||||
| @ -0,0 +1,37 @@ | ||||
| # Generated by Django 4.1.2 on 2022-10-14 12:01 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0022_alter_group_parent"), | ||||
|         ("authentik_sources_saml", "0011_auto_20210324_0736"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="UserSAMLSourceConnection", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "usersourceconnection_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_core.usersourceconnection", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("identifier", models.TextField()), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "User SAML Source Connection", | ||||
|                 "verbose_name_plural": "User SAML Source Connections", | ||||
|             }, | ||||
|             bases=("authentik_core.usersourceconnection",), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,13 +1,15 @@ | ||||
| """saml sp models""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.db import models | ||||
| from django.http import HttpRequest | ||||
| from django.templatetags.static import static | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.serializers import Serializer | ||||
|  | ||||
| from authentik.core.models import Source | ||||
| from authentik.core.types import UILoginButton | ||||
| from authentik.core.models import Source, UserSourceConnection | ||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.flows.challenge import ChallengeTypes, RedirectChallenge | ||||
| from authentik.flows.models import Flow | ||||
| @ -161,7 +163,7 @@ class SAMLSource(Source): | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.sources.saml.api import SAMLSourceSerializer | ||||
|         from authentik.sources.saml.api.source import SAMLSourceSerializer | ||||
|  | ||||
|         return SAMLSourceSerializer | ||||
|  | ||||
| @ -191,6 +193,19 @@ class SAMLSource(Source): | ||||
|             name=self.name, | ||||
|         ) | ||||
|  | ||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||
|         return UserSettingSerializer( | ||||
|             data={ | ||||
|                 "title": self.name, | ||||
|                 "component": "ak-user-settings-source-saml", | ||||
|                 "configure_url": reverse( | ||||
|                     "authentik_sources_saml:login", | ||||
|                     kwargs={"source_slug": self.slug}, | ||||
|                 ), | ||||
|                 "icon_url": static(f"authentik/sources/{self.slug}.svg"), | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"SAML Source {self.name}" | ||||
|  | ||||
| @ -198,3 +213,20 @@ class SAMLSource(Source): | ||||
|  | ||||
|         verbose_name = _("SAML Source") | ||||
|         verbose_name_plural = _("SAML Sources") | ||||
|  | ||||
|  | ||||
| class UserSAMLSourceConnection(UserSourceConnection): | ||||
|     """Connection to configured SAML Sources.""" | ||||
|  | ||||
|     identifier = models.TextField() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> Serializer: | ||||
|         from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer | ||||
|  | ||||
|         return UserSAMLSourceConnectionSerializer | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("User SAML Source Connection") | ||||
|         verbose_name_plural = _("User SAML Source Connections") | ||||
|  | ||||
| @ -7,7 +7,7 @@ import xmlsec | ||||
| from defusedxml.lxml import fromstring | ||||
| from django.core.cache import cache | ||||
| from django.core.exceptions import SuspiciousOperation | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.http import HttpRequest | ||||
| from django.utils.timezone import now | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -18,17 +18,9 @@ from authentik.core.models import ( | ||||
|     USER_ATTRIBUTE_SOURCES, | ||||
|     User, | ||||
| ) | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_PENDING_USER, | ||||
|     PLAN_CONTEXT_REDIRECT, | ||||
|     PLAN_CONTEXT_SOURCE, | ||||
|     PLAN_CONTEXT_SSO, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||
| from authentik.core.sources.flow_manager import SourceFlowManager | ||||
| from authentik.lib.expression.evaluator import BaseEvaluator | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.policies.utils import delete_none_keys | ||||
| from authentik.sources.saml.exceptions import ( | ||||
|     InvalidSignature, | ||||
| @ -36,9 +28,11 @@ from authentik.sources.saml.exceptions import ( | ||||
|     MissingSAMLResponse, | ||||
|     UnsupportedNameIDFormat, | ||||
| ) | ||||
| from authentik.sources.saml.models import SAMLSource | ||||
| from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection | ||||
| from authentik.sources.saml.processors.constants import ( | ||||
|     NS_MAP, | ||||
|     NS_SAML_ASSERTION, | ||||
|     NS_SAML_PROTOCOL, | ||||
|     SAML_NAME_ID_FORMAT_EMAIL, | ||||
|     SAML_NAME_ID_FORMAT_PERSISTENT, | ||||
|     SAML_NAME_ID_FORMAT_TRANSIENT, | ||||
| @ -46,9 +40,6 @@ from authentik.sources.saml.processors.constants import ( | ||||
|     SAML_NAME_ID_FORMAT_X509, | ||||
| ) | ||||
| from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID | ||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
| from authentik.stages.user_login.stage import BACKEND_INBUILT | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| if TYPE_CHECKING: | ||||
| @ -67,14 +58,14 @@ class ResponseProcessor: | ||||
|  | ||||
|     _http_request: HttpRequest | ||||
|  | ||||
|     def __init__(self, source: SAMLSource): | ||||
|     def __init__(self, source: SAMLSource, request: HttpRequest): | ||||
|         self._source = source | ||||
|  | ||||
|     def parse(self, request: HttpRequest): | ||||
|         """Check if `request` contains SAML Response data, parse and validate it.""" | ||||
|         self._http_request = request | ||||
|  | ||||
|     def parse(self): | ||||
|         """Check if `request` contains SAML Response data, parse and validate it.""" | ||||
|         # First off, check if we have any SAML Data at all. | ||||
|         raw_response = request.POST.get("SAMLResponse", None) | ||||
|         raw_response = self._http_request.POST.get("SAMLResponse", None) | ||||
|         if not raw_response: | ||||
|             raise MissingSAMLResponse("Request does not contain 'SAMLResponse'") | ||||
|         # Check if response is compressed, b64 decode it | ||||
| @ -83,7 +74,8 @@ class ResponseProcessor: | ||||
|  | ||||
|         if self._source.signing_kp: | ||||
|             self._verify_signed() | ||||
|         self._verify_request_id(request) | ||||
|         self._verify_request_id() | ||||
|         self._verify_status() | ||||
|  | ||||
|     def _verify_signed(self): | ||||
|         """Verify SAML Response's Signature""" | ||||
| @ -109,7 +101,7 @@ class ResponseProcessor: | ||||
|             raise InvalidSignature from exc | ||||
|         LOGGER.debug("Successfully verified signautre") | ||||
|  | ||||
|     def _verify_request_id(self, request: HttpRequest): | ||||
|     def _verify_request_id(self): | ||||
|         if self._source.allow_idp_initiated: | ||||
|             # If IdP-initiated SSO flows are enabled, we want to cache the Response ID | ||||
|             # somewhat mitigate replay attacks | ||||
| @ -119,14 +111,26 @@ class ResponseProcessor: | ||||
|             seen_ids.append(self._root.attrib["ID"]) | ||||
|             cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids) | ||||
|             return | ||||
|         if SESSION_KEY_REQUEST_ID not in request.session or "InResponseTo" not in self._root.attrib: | ||||
|         if ( | ||||
|             SESSION_KEY_REQUEST_ID not in self._http_request.session | ||||
|             or "InResponseTo" not in self._root.attrib | ||||
|         ): | ||||
|             raise MismatchedRequestID( | ||||
|                 "Missing InResponseTo and IdP-initiated Logins are not allowed" | ||||
|             ) | ||||
|         if request.session[SESSION_KEY_REQUEST_ID] != self._root.attrib["InResponseTo"]: | ||||
|         if self._http_request.session[SESSION_KEY_REQUEST_ID] != self._root.attrib["InResponseTo"]: | ||||
|             raise MismatchedRequestID("Mismatched request ID") | ||||
|  | ||||
|     def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: | ||||
|     def _verify_status(self): | ||||
|         """Check for SAML Status elements""" | ||||
|         status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status") | ||||
|         if status is None: | ||||
|             return | ||||
|         message = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusMessage") | ||||
|         if message is not None: | ||||
|             raise ValueError(message.text) | ||||
|  | ||||
|     def _handle_name_id_transient(self) -> SourceFlowManager: | ||||
|         """Handle a NameID with the Format of Transient. This is a bit more complex than other | ||||
|         formats, as we need to create a temporary User that is used in the session. This | ||||
|         user has an attribute that refers to our Source for cleanup. The user is also deleted | ||||
| @ -151,24 +155,23 @@ class ResponseProcessor: | ||||
|         LOGGER.debug("Created temporary user for NameID Transient", username=name_id) | ||||
|         user.set_unusable_password() | ||||
|         user.save() | ||||
|         return self._flow_response( | ||||
|             request, | ||||
|             self._source.authentication_flow, | ||||
|             **{ | ||||
|                 PLAN_CONTEXT_PENDING_USER: user, | ||||
|                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, | ||||
|             }, | ||||
|         UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id) | ||||
|         return SAMLSourceFlowManager( | ||||
|             self._source, | ||||
|             self._http_request, | ||||
|             name_id, | ||||
|             delete_none_keys(self.get_attributes()), | ||||
|         ) | ||||
|  | ||||
|     def _get_name_id(self) -> "Element": | ||||
|         """Get NameID Element""" | ||||
|         assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") | ||||
|         if not assertion: | ||||
|         assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion") | ||||
|         if assertion is None: | ||||
|             raise ValueError("Assertion element not found") | ||||
|         subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") | ||||
|         if not subject: | ||||
|         subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject") | ||||
|         if subject is None: | ||||
|             raise ValueError("Subject element not found") | ||||
|         name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID") | ||||
|         name_id = subject.find(f"{{{NS_SAML_ASSERTION}}}NameID") | ||||
|         if name_id is None: | ||||
|             raise ValueError("NameID element not found") | ||||
|         return name_id | ||||
| @ -195,7 +198,27 @@ class ResponseProcessor: | ||||
|             f"Assertion contains NameID with unsupported format {_format}." | ||||
|         ) | ||||
|  | ||||
|     def prepare_flow(self, request: HttpRequest) -> HttpResponse: | ||||
|     def get_attributes(self) -> dict[str, list[str] | str]: | ||||
|         """Get all attributes sent""" | ||||
|         attributes = {} | ||||
|         assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion") | ||||
|         if not assertion: | ||||
|             raise ValueError("Assertion element not found") | ||||
|         attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") | ||||
|         if not attribute_statement: | ||||
|             raise ValueError("Attribute statement element not found") | ||||
|         # Get all attributes and their values into a dict | ||||
|         for attribute in attribute_statement.iterchildren(): | ||||
|             key = attribute.attrib["Name"] | ||||
|             attributes.setdefault(key, []) | ||||
|             for value in attribute.iterchildren(): | ||||
|                 attributes[key].append(value.text) | ||||
|         # Flatten all lists in the dict | ||||
|         for key, value in attributes.items(): | ||||
|             attributes[key] = BaseEvaluator.expr_flatten(value) | ||||
|         return attributes | ||||
|  | ||||
|     def prepare_flow_manager(self) -> SourceFlowManager: | ||||
|         """Prepare flow plan depending on whether or not the user exists""" | ||||
|         name_id = self._get_name_id() | ||||
|         # Sanity check, show a warning if NameIDPolicy doesn't match what we go | ||||
| @ -207,38 +230,17 @@ class ResponseProcessor: | ||||
|             ) | ||||
|         # transient NameIDs are handled separately as they don't have to go through flows. | ||||
|         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: | ||||
|             return self._handle_name_id_transient(request) | ||||
|             return self._handle_name_id_transient() | ||||
|  | ||||
|         name_id_filter = self._get_name_id_filter() | ||||
|         matching_users = User.objects.filter(**name_id_filter) | ||||
|         # Ensure redirect is carried through when user was trying to | ||||
|         # authorize application | ||||
|         final_redirect = self._http_request.session.get(SESSION_KEY_GET, {}).get( | ||||
|             NEXT_ARG_NAME, "authentik_core:if-user" | ||||
|         ) | ||||
|         if matching_users.exists(): | ||||
|             # User exists already, switch to authentication flow | ||||
|             return self._flow_response( | ||||
|                 request, | ||||
|                 self._source.authentication_flow, | ||||
|                 **{ | ||||
|                     PLAN_CONTEXT_PENDING_USER: matching_users.first(), | ||||
|                     PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, | ||||
|                     PLAN_CONTEXT_REDIRECT: final_redirect, | ||||
|                 }, | ||||
|             ) | ||||
|         return self._flow_response( | ||||
|             request, | ||||
|             self._source.enrollment_flow, | ||||
|             **{PLAN_CONTEXT_PROMPT: delete_none_keys(name_id_filter)}, | ||||
|         return SAMLSourceFlowManager( | ||||
|             self._source, | ||||
|             self._http_request, | ||||
|             name_id.text, | ||||
|             delete_none_keys(self.get_attributes()), | ||||
|         ) | ||||
|  | ||||
|     def _flow_response(self, request: HttpRequest, flow: Flow, **kwargs) -> HttpResponse: | ||||
|         kwargs[PLAN_CONTEXT_SSO] = True | ||||
|         kwargs[PLAN_CONTEXT_SOURCE] = self._source | ||||
|         request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs) | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=flow.slug, | ||||
|         ) | ||||
|  | ||||
| class SAMLSourceFlowManager(SourceFlowManager): | ||||
|     """Source flow manager for SAML Sources""" | ||||
|  | ||||
|     connection_type = UserSAMLSourceConnection | ||||
|  | ||||
| @ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase | ||||
| from lxml import etree  # nosec | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_cert, create_test_flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.xml import lxml_from_string | ||||
| from authentik.sources.saml.models import SAMLSource | ||||
| from authentik.sources.saml.processors.metadata import MetadataProcessor | ||||
| @ -14,17 +15,17 @@ class TestMetadataProcessor(TestCase): | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|  | ||||
|     def test_metadata_schema(self): | ||||
|         """Test Metadata generation being valid""" | ||||
|         source = SAMLSource.objects.create( | ||||
|             slug="provider", | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             signing_kp=create_test_cert(), | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|  | ||||
|     def test_metadata_schema(self): | ||||
|         """Test Metadata generation being valid""" | ||||
|         request = self.factory.get("/") | ||||
|         xml = MetadataProcessor(source, request).build_entity_descriptor() | ||||
|         xml = MetadataProcessor(self.source, request).build_entity_descriptor() | ||||
|         metadata = lxml_from_string(xml) | ||||
|  | ||||
|         schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd"))  # nosec | ||||
| @ -32,38 +33,23 @@ class TestMetadataProcessor(TestCase): | ||||
|  | ||||
|     def test_metadata_consistent(self): | ||||
|         """Test Metadata generation being consistent (xml stays the same)""" | ||||
|         source = SAMLSource.objects.create( | ||||
|             slug="provider", | ||||
|             issuer="authentik", | ||||
|             signing_kp=create_test_cert(), | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|         request = self.factory.get("/") | ||||
|         xml_a = MetadataProcessor(source, request).build_entity_descriptor() | ||||
|         xml_b = MetadataProcessor(source, request).build_entity_descriptor() | ||||
|         xml_a = MetadataProcessor(self.source, request).build_entity_descriptor() | ||||
|         xml_b = MetadataProcessor(self.source, request).build_entity_descriptor() | ||||
|         self.assertEqual(xml_a, xml_b) | ||||
|  | ||||
|     def test_metadata(self): | ||||
|         """Test Metadata generation being valid""" | ||||
|         source = SAMLSource.objects.create( | ||||
|             slug="provider", | ||||
|             issuer="authentik", | ||||
|             signing_kp=create_test_cert(), | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|         request = self.factory.get("/") | ||||
|         xml = MetadataProcessor(source, request).build_entity_descriptor() | ||||
|         xml = MetadataProcessor(self.source, request).build_entity_descriptor() | ||||
|         metadata = ElementTree.fromstring(xml) | ||||
|         self.assertEqual(metadata.attrib["entityID"], "authentik") | ||||
|  | ||||
|     def test_metadata_without_signautre(self): | ||||
|         """Test Metadata generation being valid""" | ||||
|         source = SAMLSource.objects.create( | ||||
|             slug="provider", | ||||
|             issuer="authentik", | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|         self.source.signing_kp = None | ||||
|         self.source.save() | ||||
|         request = self.factory.get("/") | ||||
|         xml = MetadataProcessor(source, request).build_entity_descriptor() | ||||
|         xml = MetadataProcessor(self.source, request).build_entity_descriptor() | ||||
|         metadata = ElementTree.fromstring(xml) | ||||
|         self.assertEqual(metadata.attrib["entityID"], "authentik") | ||||
|  | ||||
							
								
								
									
										112
									
								
								authentik/sources/saml/tests/test_response.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								authentik/sources/saml/tests/test_response.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,112 @@ | ||||
| """SAML Source tests""" | ||||
| from base64 import b64encode | ||||
|  | ||||
| from django.contrib.sessions.middleware import SessionMiddleware | ||||
| from django.test import RequestFactory, TestCase | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import dummy_get_response | ||||
| from authentik.sources.saml.models import SAMLSource | ||||
| from authentik.sources.saml.processors.response import ResponseProcessor | ||||
|  | ||||
| RESPONSE_ERROR = """<?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://127.0.0.1:9443/source/saml/google/acs/" ID="_ee7a8865ac457e7b22cb4f16b39ceca9" IssueInstant="2022-10-14T13:52:04.479Z" Version="2.0"> | ||||
|     <saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> | ||||
|     <saml2p:Status> | ||||
|         <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Requester"> | ||||
|             <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:RequestDenied"></saml2p:StatusCode> | ||||
|         </saml2p:StatusCode> | ||||
|         <saml2p:StatusMessage>Invalid request, ACS Url in request http://localhost:9000/source/saml/google/acs/ doesn't match configured ACS Url https://127.0.0.1:9443/source/saml/google/acs/.</saml2p:StatusMessage> | ||||
|     </saml2p:Status> | ||||
| </saml2p:Response> | ||||
| """ | ||||
|  | ||||
| RESPONSE_SUCCESS = """<?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://127.0.0.1:9443/source/saml/google/acs/" ID="_1e17063957f10819a5a8e147971fec22" InResponseTo="_157fb504b59f4ae3919f74896a6b8565" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0"> | ||||
|     <saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> | ||||
|     <saml2p:Status> | ||||
|         <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"></saml2p:StatusCode> | ||||
|     </saml2p:Status> | ||||
|     <saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_346001c5708ffd118c40edbc0c72fc60" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0"> | ||||
|         <saml2:Issuer>https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> | ||||
|         <saml2:Subject> | ||||
|             <saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">jens@beryju.org</saml2:NameID> | ||||
|             <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> | ||||
|                 <saml2:SubjectConfirmationData InResponseTo="_157fb504b59f4ae3919f74896a6b8565" NotOnOrAfter="2022-10-14T14:16:49.590Z" Recipient="https://127.0.0.1:9443/source/saml/google/acs/"></saml2:SubjectConfirmationData> | ||||
|             </saml2:SubjectConfirmation> | ||||
|         </saml2:Subject> | ||||
|         <saml2:Conditions NotBefore="2022-10-14T14:06:49.590Z" NotOnOrAfter="2022-10-14T14:16:49.590Z"> | ||||
|             <saml2:AudienceRestriction> | ||||
|                 <saml2:Audience>https://accounts.google.com/o/saml2?idpid=</saml2:Audience> | ||||
|             </saml2:AudienceRestriction> | ||||
|         </saml2:Conditions> | ||||
|         <saml2:AttributeStatement> | ||||
|             <saml2:Attribute Name="name"> | ||||
|                 <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" | ||||
|                     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo</saml2:AttributeValue> | ||||
|             </saml2:Attribute> | ||||
|             <saml2:Attribute Name="sn"> | ||||
|                 <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" | ||||
|                     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">bar</saml2:AttributeValue> | ||||
|             </saml2:Attribute> | ||||
|             <saml2:Attribute Name="email"> | ||||
|                 <saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" | ||||
|                     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.baz</saml2:AttributeValue> | ||||
|             </saml2:Attribute> | ||||
|         </saml2:AttributeStatement> | ||||
|         <saml2:AuthnStatement AuthnInstant="2022-10-14T12:16:21.000Z" SessionIndex="_346001c5708ffd118c40edbc0c72fc60"> | ||||
|             <saml2:AuthnContext> | ||||
|                 <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef> | ||||
|             </saml2:AuthnContext> | ||||
|         </saml2:AuthnStatement> | ||||
|     </saml2:Assertion> | ||||
| </saml2p:Response> | ||||
| """ | ||||
|  | ||||
|  | ||||
| class TestResponseProcessor(TestCase): | ||||
|     """Test ResponseProcessor""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             allow_idp_initiated=True, | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|  | ||||
|     def test_status_error(self): | ||||
|         """Test error status""" | ||||
|         request = self.factory.post( | ||||
|             "/", data={"SAMLResponse": b64encode(RESPONSE_ERROR.encode()).decode()} | ||||
|         ) | ||||
|  | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(request) | ||||
|         request.session.save() | ||||
|  | ||||
|         with self.assertRaisesMessage( | ||||
|             ValueError, | ||||
|             ( | ||||
|                 "Invalid request, ACS Url in request http://localhost:9000/source/saml/google/acs/ " | ||||
|                 "doesn't match configured ACS Url https://127.0.0.1:9443/source/saml/google/acs/." | ||||
|             ), | ||||
|         ): | ||||
|             ResponseProcessor(self.source, request).parse() | ||||
|  | ||||
|     def test_success(self): | ||||
|         """Test success""" | ||||
|         request = self.factory.post( | ||||
|             "/", data={"SAMLResponse": b64encode(RESPONSE_SUCCESS.encode()).decode()} | ||||
|         ) | ||||
|  | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(request) | ||||
|         request.session.save() | ||||
|  | ||||
|         parser = ResponseProcessor(self.source, request) | ||||
|         parser.parse() | ||||
|         sfm = parser.prepare_flow_manager() | ||||
|         self.assertEqual(sfm.enroll_info, {"email": "foo@bar.baz", "name": "foo", "sn": "bar"}) | ||||
| @ -153,16 +153,16 @@ class ACSView(View): | ||||
|         source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) | ||||
|         if not source.enabled: | ||||
|             raise Http404 | ||||
|         processor = ResponseProcessor(source) | ||||
|         processor = ResponseProcessor(source, request) | ||||
|         try: | ||||
|             processor.parse(request) | ||||
|             processor.parse() | ||||
|         except MissingSAMLResponse as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|         except VerificationError as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|  | ||||
|         try: | ||||
|             return processor.prepare_flow(request) | ||||
|             return processor.prepare_flow_manager().get_flow() | ||||
|         except (UnsupportedNameIDFormat, ValueError) as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|  | ||||
|  | ||||
| @ -89,6 +89,7 @@ | ||||
|                             "authentik_sources_plex.plexsource", | ||||
|                             "authentik_sources_plex.plexsourceconnection", | ||||
|                             "authentik_sources_saml.samlsource", | ||||
|                             "authentik_sources_saml.usersamlsourceconnection", | ||||
|                             "authentik_stages_authenticator_duo.authenticatorduostage", | ||||
|                             "authentik_stages_authenticator_duo.duodevice", | ||||
|                             "authentik_stages_authenticator_sms.authenticatorsmsstage", | ||||
|  | ||||
							
								
								
									
										341
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										341
									
								
								schema.yml
									
									
									
									
									
								
							| @ -18164,6 +18164,270 @@ paths: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|   /sources/user_connections/saml/: | ||||
|     get: | ||||
|       operationId: sources_user_connections_saml_list | ||||
|       description: Source Viewset | ||||
|       parameters: | ||||
|       - name: ordering | ||||
|         required: false | ||||
|         in: query | ||||
|         description: Which field to use when ordering the results. | ||||
|         schema: | ||||
|           type: string | ||||
|       - name: page | ||||
|         required: false | ||||
|         in: query | ||||
|         description: A page number within the paginated result set. | ||||
|         schema: | ||||
|           type: integer | ||||
|       - name: page_size | ||||
|         required: false | ||||
|         in: query | ||||
|         description: Number of results to return per page. | ||||
|         schema: | ||||
|           type: integer | ||||
|       - name: search | ||||
|         required: false | ||||
|         in: query | ||||
|         description: A search term. | ||||
|         schema: | ||||
|           type: string | ||||
|       - in: query | ||||
|         name: source__slug | ||||
|         schema: | ||||
|           type: string | ||||
|       tags: | ||||
|       - sources | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/PaginatedUserSAMLSourceConnectionList' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|     post: | ||||
|       operationId: sources_user_connections_saml_create | ||||
|       description: Source Viewset | ||||
|       tags: | ||||
|       - sources | ||||
|       requestBody: | ||||
|         content: | ||||
|           application/json: | ||||
|             schema: | ||||
|               $ref: '#/components/schemas/UserSAMLSourceConnectionRequest' | ||||
|         required: true | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '201': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/UserSAMLSourceConnection' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|   /sources/user_connections/saml/{id}/: | ||||
|     get: | ||||
|       operationId: sources_user_connections_saml_retrieve | ||||
|       description: Source Viewset | ||||
|       parameters: | ||||
|       - in: path | ||||
|         name: id | ||||
|         schema: | ||||
|           type: integer | ||||
|         description: A unique integer value identifying this User SAML Source Connection. | ||||
|         required: true | ||||
|       tags: | ||||
|       - sources | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/UserSAMLSourceConnection' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|     put: | ||||
|       operationId: sources_user_connections_saml_update | ||||
|       description: Source Viewset | ||||
|       parameters: | ||||
|       - in: path | ||||
|         name: id | ||||
|         schema: | ||||
|           type: integer | ||||
|         description: A unique integer value identifying this User SAML Source Connection. | ||||
|         required: true | ||||
|       tags: | ||||
|       - sources | ||||
|       requestBody: | ||||
|         content: | ||||
|           application/json: | ||||
|             schema: | ||||
|               $ref: '#/components/schemas/UserSAMLSourceConnectionRequest' | ||||
|         required: true | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/UserSAMLSourceConnection' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|     patch: | ||||
|       operationId: sources_user_connections_saml_partial_update | ||||
|       description: Source Viewset | ||||
|       parameters: | ||||
|       - in: path | ||||
|         name: id | ||||
|         schema: | ||||
|           type: integer | ||||
|         description: A unique integer value identifying this User SAML Source Connection. | ||||
|         required: true | ||||
|       tags: | ||||
|       - sources | ||||
|       requestBody: | ||||
|         content: | ||||
|           application/json: | ||||
|             schema: | ||||
|               $ref: '#/components/schemas/PatchedUserSAMLSourceConnectionRequest' | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/UserSAMLSourceConnection' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|     delete: | ||||
|       operationId: sources_user_connections_saml_destroy | ||||
|       description: Source Viewset | ||||
|       parameters: | ||||
|       - in: path | ||||
|         name: id | ||||
|         schema: | ||||
|           type: integer | ||||
|         description: A unique integer value identifying this User SAML Source Connection. | ||||
|         required: true | ||||
|       tags: | ||||
|       - sources | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '204': | ||||
|           description: No response body | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|   /sources/user_connections/saml/{id}/used_by/: | ||||
|     get: | ||||
|       operationId: sources_user_connections_saml_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 User SAML Source Connection. | ||||
|         required: true | ||||
|       tags: | ||||
|       - sources | ||||
|       security: | ||||
|       - authentik: [] | ||||
|       responses: | ||||
|         '200': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: array | ||||
|                 items: | ||||
|                   $ref: '#/components/schemas/UsedBy' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/ValidationError' | ||||
|           description: '' | ||||
|         '403': | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: '#/components/schemas/GenericError' | ||||
|           description: '' | ||||
|   /stages/all/: | ||||
|     get: | ||||
|       operationId: stages_all_list | ||||
| @ -32190,6 +32454,41 @@ components: | ||||
|       required: | ||||
|       - pagination | ||||
|       - results | ||||
|     PaginatedUserSAMLSourceConnectionList: | ||||
|       type: object | ||||
|       properties: | ||||
|         pagination: | ||||
|           type: object | ||||
|           properties: | ||||
|             next: | ||||
|               type: number | ||||
|             previous: | ||||
|               type: number | ||||
|             count: | ||||
|               type: number | ||||
|             current: | ||||
|               type: number | ||||
|             total_pages: | ||||
|               type: number | ||||
|             start_index: | ||||
|               type: number | ||||
|             end_index: | ||||
|               type: number | ||||
|           required: | ||||
|           - next | ||||
|           - previous | ||||
|           - count | ||||
|           - current | ||||
|           - total_pages | ||||
|           - start_index | ||||
|           - end_index | ||||
|         results: | ||||
|           type: array | ||||
|           items: | ||||
|             $ref: '#/components/schemas/UserSAMLSourceConnection' | ||||
|       required: | ||||
|       - pagination | ||||
|       - results | ||||
|     PaginatedUserSourceConnectionList: | ||||
|       type: object | ||||
|       properties: | ||||
| @ -34432,6 +34731,15 @@ components: | ||||
|         path: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|     PatchedUserSAMLSourceConnectionRequest: | ||||
|       type: object | ||||
|       description: SAML Source Serializer | ||||
|       properties: | ||||
|         user: | ||||
|           type: integer | ||||
|         identifier: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|     PatchedUserWriteStageRequest: | ||||
|       type: object | ||||
|       description: UserWriteStage Serializer | ||||
| @ -37376,6 +37684,39 @@ components: | ||||
|       - groups | ||||
|       - name | ||||
|       - username | ||||
|     UserSAMLSourceConnection: | ||||
|       type: object | ||||
|       description: SAML Source Serializer | ||||
|       properties: | ||||
|         pk: | ||||
|           type: integer | ||||
|           readOnly: true | ||||
|           title: ID | ||||
|         user: | ||||
|           type: integer | ||||
|         source: | ||||
|           allOf: | ||||
|           - $ref: '#/components/schemas/Source' | ||||
|           readOnly: true | ||||
|         identifier: | ||||
|           type: string | ||||
|       required: | ||||
|       - identifier | ||||
|       - pk | ||||
|       - source | ||||
|       - user | ||||
|     UserSAMLSourceConnectionRequest: | ||||
|       type: object | ||||
|       description: SAML Source Serializer | ||||
|       properties: | ||||
|         user: | ||||
|           type: integer | ||||
|         identifier: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|       required: | ||||
|       - identifier | ||||
|       - user | ||||
|     UserSelf: | ||||
|       type: object | ||||
|       description: User Serializer for information a user can retrieve about themselves | ||||
|  | ||||
							
								
								
									
										3
									
								
								web/authentik/sources/apple.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								web/authentik/sources/apple.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000"> | ||||
|     <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 663 B | 
| @ -1,3 +1,4 @@ | ||||
| import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { first } from "@goauthentik/common/utils"; | ||||
| import "@goauthentik/elements/forms/FormGroup"; | ||||
| @ -22,6 +23,7 @@ import { | ||||
|     SAMLSource, | ||||
|     SignatureAlgorithmEnum, | ||||
|     SourcesApi, | ||||
|     UserMatchingModeEnum, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-source-saml-form") | ||||
| @ -81,6 +83,49 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> { | ||||
|                     <label class="pf-c-check__label"> ${t`Enabled`} </label> | ||||
|                 </div> | ||||
|             </ak-form-element-horizontal> | ||||
|             <ak-form-element-horizontal | ||||
|                 label=${t`User matching mode`} | ||||
|                 ?required=${true} | ||||
|                 name="userMatchingMode" | ||||
|             > | ||||
|                 <select class="pf-c-form-control"> | ||||
|                     <option | ||||
|                         value=${UserMatchingModeEnum.Identifier} | ||||
|                         ?selected=${this.instance?.userMatchingMode === | ||||
|                         UserMatchingModeEnum.Identifier} | ||||
|                     > | ||||
|                         ${UserMatchingModeToLabel(UserMatchingModeEnum.Identifier)} | ||||
|                     </option> | ||||
|                     <option | ||||
|                         value=${UserMatchingModeEnum.EmailLink} | ||||
|                         ?selected=${this.instance?.userMatchingMode === | ||||
|                         UserMatchingModeEnum.EmailLink} | ||||
|                     > | ||||
|                         ${UserMatchingModeToLabel(UserMatchingModeEnum.EmailLink)} | ||||
|                     </option> | ||||
|                     <option | ||||
|                         value=${UserMatchingModeEnum.EmailDeny} | ||||
|                         ?selected=${this.instance?.userMatchingMode === | ||||
|                         UserMatchingModeEnum.EmailDeny} | ||||
|                     > | ||||
|                         ${UserMatchingModeToLabel(UserMatchingModeEnum.EmailDeny)} | ||||
|                     </option> | ||||
|                     <option | ||||
|                         value=${UserMatchingModeEnum.UsernameLink} | ||||
|                         ?selected=${this.instance?.userMatchingMode === | ||||
|                         UserMatchingModeEnum.UsernameLink} | ||||
|                     > | ||||
|                         ${UserMatchingModeToLabel(UserMatchingModeEnum.UsernameLink)} | ||||
|                     </option> | ||||
|                     <option | ||||
|                         value=${UserMatchingModeEnum.UsernameDeny} | ||||
|                         ?selected=${this.instance?.userMatchingMode === | ||||
|                         UserMatchingModeEnum.UsernameDeny} | ||||
|                     > | ||||
|                         ${UserMatchingModeToLabel(UserMatchingModeEnum.UsernameDeny)} | ||||
|                     </option> | ||||
|                 </select> | ||||
|             </ak-form-element-horizontal> | ||||
|  | ||||
|             <ak-form-group .expanded=${true}> | ||||
|                 <span slot="header"> ${t`Protocol settings`} </span> | ||||
|  | ||||
| @ -534,7 +534,7 @@ export class FlowExecutor extends AKElement implements StageHost { | ||||
|                                                     ? html` | ||||
|                                                           <li> | ||||
|                                                               <a | ||||
|                                                                   href="https://unsplash.com/@impatrickt" | ||||
|                                                                   href="https://unsplash.com/@brendan_k_steeves" | ||||
|                                                                   >${t`Background image`}</a | ||||
|                                                               > | ||||
|                                                           </li> | ||||
|  | ||||
| @ -4,6 +4,7 @@ import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import "@goauthentik/elements/EmptyState"; | ||||
| import "@goauthentik/user/user-settings/sources/SourceSettingsOAuth"; | ||||
| import "@goauthentik/user/user-settings/sources/SourceSettingsPlex"; | ||||
| import "@goauthentik/user/user-settings/sources/SourceSettingsSAML"; | ||||
|  | ||||
| import { t } from "@lingui/macro"; | ||||
|  | ||||
| @ -95,6 +96,15 @@ export class UserSourceSettingsPage extends AKElement { | ||||
|                     .configureUrl=${source.configureUrl} | ||||
|                 > | ||||
|                 </ak-user-settings-source-plex>`; | ||||
|             case "ak-user-settings-source-saml": | ||||
|                 return html`<ak-user-settings-source-saml | ||||
|                     class="pf-c-data-list__item-row" | ||||
|                     objectId=${source.objectUid} | ||||
|                     title=${source.title} | ||||
|                     connectionPk=${connectionPk} | ||||
|                     .configureUrl=${source.configureUrl} | ||||
|                 > | ||||
|                 </ak-user-settings-source-saml>`; | ||||
|             default: | ||||
|                 return html`<p>${t`Error: unsupported source settings: ${source.component}`}</p>`; | ||||
|         } | ||||
|  | ||||
							
								
								
									
										70
									
								
								web/src/user/user-settings/sources/SourceSettingsSAML.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								web/src/user/user-settings/sources/SourceSettingsSAML.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | ||||
| import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||
| import { MessageLevel } from "@goauthentik/common/messages"; | ||||
| import "@goauthentik/elements/Spinner"; | ||||
| import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; | ||||
| import { BaseUserSettings } from "@goauthentik/user/user-settings/BaseUserSettings"; | ||||
|  | ||||
| import { t } from "@lingui/macro"; | ||||
|  | ||||
| import { TemplateResult, html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
|  | ||||
| import { SourcesApi } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-user-settings-source-saml") | ||||
| export class SourceSettingsSAML extends BaseUserSettings { | ||||
|     @property() | ||||
|     title!: string; | ||||
|  | ||||
|     @property({ type: Number }) | ||||
|     connectionPk = 0; | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         if (this.connectionPk === -1) { | ||||
|             return html`<ak-spinner></ak-spinner>`; | ||||
|         } | ||||
|         if (this.connectionPk > 0) { | ||||
|             return html`<button | ||||
|                 class="pf-c-button pf-m-danger" | ||||
|                 @click=${() => { | ||||
|                     return new SourcesApi(DEFAULT_CONFIG) | ||||
|                         .sourcesUserConnectionsSamlDestroy({ | ||||
|                             id: this.connectionPk, | ||||
|                         }) | ||||
|                         .then(() => { | ||||
|                             showMessage({ | ||||
|                                 level: MessageLevel.info, | ||||
|                                 message: t`Successfully disconnected source`, | ||||
|                             }); | ||||
|                         }) | ||||
|                         .catch((exc) => { | ||||
|                             showMessage({ | ||||
|                                 level: MessageLevel.error, | ||||
|                                 message: t`Failed to disconnected source: ${exc}`, | ||||
|                             }); | ||||
|                         }) | ||||
|                         .finally(() => { | ||||
|                             this.parentElement?.dispatchEvent( | ||||
|                                 new CustomEvent(EVENT_REFRESH, { | ||||
|                                     bubbles: true, | ||||
|                                     composed: true, | ||||
|                                 }), | ||||
|                             ); | ||||
|                         }); | ||||
|                 }} | ||||
|             > | ||||
|                 ${t`Disconnect`} | ||||
|             </button>`; | ||||
|         } | ||||
|         return html`<a | ||||
|             class="pf-c-button pf-m-primary" | ||||
|             href="${ifDefined(this.configureUrl)}${AndNext( | ||||
|                 `/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`, | ||||
|             )}" | ||||
|         > | ||||
|             ${t`Connect`} | ||||
|         </a>`; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L