diff --git a/Makefile b/Makefile index 9c79841f7..612dc68f3 100644 --- a/Makefile +++ b/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 diff --git a/authentik/api/v3/urls.py b/authentik/api/v3/urls.py index 4c96cb5f6..d4e1e2a07 100644 --- a/authentik/api/v3/urls.py +++ b/authentik/api/v3/urls.py @@ -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) diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py index 6fb58169a..f43a065ba 100644 --- a/authentik/providers/saml/tests/test_auth_n_request.py +++ b/authentik/providers/saml/tests/test_auth_n_request.py @@ -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)""" diff --git a/authentik/sources/oauth/types/apple.py b/authentik/sources/oauth/types/apple.py index 34c55042e..d91c48627 100644 --- a/authentik/sources/oauth/types/apple.py +++ b/authentik/sources/oauth/types/apple.py @@ -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( diff --git a/authentik/sources/saml/api/__init__.py b/authentik/sources/saml/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/saml/api.py b/authentik/sources/saml/api/source.py similarity index 100% rename from authentik/sources/saml/api.py rename to authentik/sources/saml/api/source.py diff --git a/authentik/sources/saml/api/source_connection.py b/authentik/sources/saml/api/source_connection.py new file mode 100644 index 000000000..25351569d --- /dev/null +++ b/authentik/sources/saml/api/source_connection.py @@ -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"] diff --git a/authentik/sources/saml/migrations/0012_usersamlsourceconnection.py b/authentik/sources/saml/migrations/0012_usersamlsourceconnection.py new file mode 100644 index 000000000..f2b55050c --- /dev/null +++ b/authentik/sources/saml/migrations/0012_usersamlsourceconnection.py @@ -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",), + ), + ] diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index dee01a058..8dd30cc34 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -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") diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py index fe0afb688..6b5a4682b 100644 --- a/authentik/sources/saml/processors/response.py +++ b/authentik/sources/saml/processors/response.py @@ -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 diff --git a/authentik/sources/saml/tests/test_metadata.py b/authentik/sources/saml/tests/test_metadata.py index 9876aaa0d..590af7d96 100644 --- a/authentik/sources/saml/tests/test_metadata.py +++ b/authentik/sources/saml/tests/test_metadata.py @@ -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") diff --git a/authentik/sources/saml/tests/test_response.py b/authentik/sources/saml/tests/test_response.py new file mode 100644 index 000000000..5cbd11ab8 --- /dev/null +++ b/authentik/sources/saml/tests/test_response.py @@ -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 = """ + + https://accounts.google.com/o/saml2?idpid= + + + + + 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/. + + +""" + +RESPONSE_SUCCESS = """ + + https://accounts.google.com/o/saml2?idpid= + + + + + https://accounts.google.com/o/saml2?idpid= + + jens@beryju.org + + + + + + + https://accounts.google.com/o/saml2?idpid= + + + + + foo + + + bar + + + foo@bar.baz + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified + + + + +""" + + +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"}) diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py index 0db4630b6..ed13f6917 100644 --- a/authentik/sources/saml/views.py +++ b/authentik/sources/saml/views.py @@ -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)) diff --git a/blueprints/schema.json b/blueprints/schema.json index b832d4618..45119dffb 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -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", diff --git a/schema.yml b/schema.yml index 89cebbc67..c4e940ac5 100644 --- a/schema.yml +++ b/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 diff --git a/web/authentik/sources/apple.svg b/web/authentik/sources/apple.svg new file mode 100644 index 000000000..4f3693bb3 --- /dev/null +++ b/web/authentik/sources/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index 431a0cebf..d760951aa 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -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 { + + + ${t`Protocol settings`} diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index d02f6a068..0e57ce0cd 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -534,7 +534,7 @@ export class FlowExecutor extends AKElement implements StageHost { ? html`
  • ${t`Background image`}
  • diff --git a/web/src/user/user-settings/sources/SourceSettings.ts b/web/src/user/user-settings/sources/SourceSettings.ts index 93534b75f..fada32011 100644 --- a/web/src/user/user-settings/sources/SourceSettings.ts +++ b/web/src/user/user-settings/sources/SourceSettings.ts @@ -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} > `; + case "ak-user-settings-source-saml": + return html` + `; default: return html`

    ${t`Error: unsupported source settings: ${source.component}`}

    `; } diff --git a/web/src/user/user-settings/sources/SourceSettingsSAML.ts b/web/src/user/user-settings/sources/SourceSettingsSAML.ts new file mode 100644 index 000000000..a1e7efd88 --- /dev/null +++ b/web/src/user/user-settings/sources/SourceSettingsSAML.ts @@ -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``; + } + if (this.connectionPk > 0) { + return html``; + } + return html` + ${t`Connect`} + `; + } +}