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:
parent
5e620c74f9
commit
363872715d
2
Makefile
2
Makefile
|
@ -53,7 +53,7 @@ migrate:
|
||||||
python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run -v cmd/server/main.go
|
go run -v ./cmd/server/
|
||||||
|
|
||||||
i18n-extract: i18n-extract-core web-extract
|
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.oauth.api.source_connection import UserOAuthSourceConnectionViewSet
|
||||||
from authentik.sources.plex.api.source import PlexSourceViewSet
|
from authentik.sources.plex.api.source import PlexSourceViewSet
|
||||||
from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet
|
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 (
|
from authentik.stages.authenticator_duo.api import (
|
||||||
AuthenticatorDuoStageViewSet,
|
AuthenticatorDuoStageViewSet,
|
||||||
DuoAdminDeviceViewSet,
|
DuoAdminDeviceViewSet,
|
||||||
|
@ -138,6 +139,7 @@ router.register("sources/all", SourceViewSet)
|
||||||
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
|
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
|
||||||
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
|
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
|
||||||
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
|
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
|
||||||
|
router.register("sources/user_connections/saml", UserSAMLSourceConnectionViewSet)
|
||||||
router.register("sources/ldap", LDAPSourceViewSet)
|
router.register("sources/ldap", LDAPSourceViewSet)
|
||||||
router.register("sources/saml", SAMLSourceViewSet)
|
router.register("sources/saml", SAMLSourceViewSet)
|
||||||
router.register("sources/oauth", OAuthSourceViewSet)
|
router.register("sources/oauth", OAuthSourceViewSet)
|
||||||
|
|
|
@ -130,8 +130,8 @@ class TestAuthNRequest(TestCase):
|
||||||
http_request.POST = QueryDict(mutable=True)
|
http_request.POST = QueryDict(mutable=True)
|
||||||
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
|
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
|
||||||
|
|
||||||
response_parser = ResponseProcessor(self.source)
|
response_parser = ResponseProcessor(self.source, http_request)
|
||||||
response_parser.parse(http_request)
|
response_parser.parse()
|
||||||
|
|
||||||
def test_request_id_invalid(self):
|
def test_request_id_invalid(self):
|
||||||
"""Test generated AuthNRequest with invalid request ID"""
|
"""Test generated AuthNRequest with invalid request ID"""
|
||||||
|
@ -157,10 +157,10 @@ class TestAuthNRequest(TestCase):
|
||||||
http_request.POST = QueryDict(mutable=True)
|
http_request.POST = QueryDict(mutable=True)
|
||||||
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
|
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
|
||||||
|
|
||||||
response_parser = ResponseProcessor(self.source)
|
response_parser = ResponseProcessor(self.source, http_request)
|
||||||
|
|
||||||
with self.assertRaises(MismatchedRequestID):
|
with self.assertRaises(MismatchedRequestID):
|
||||||
response_parser.parse(http_request)
|
response_parser.parse()
|
||||||
|
|
||||||
def test_signed_valid_detached(self):
|
def test_signed_valid_detached(self):
|
||||||
"""Test generated AuthNRequest with valid signature (detached)"""
|
"""Test generated AuthNRequest with valid signature (detached)"""
|
||||||
|
|
|
@ -114,9 +114,6 @@ class AppleType(SourceType):
|
||||||
access_token_url = "https://appleid.apple.com/auth/token" # nosec
|
access_token_url = "https://appleid.apple.com/auth/token" # nosec
|
||||||
profile_url = ""
|
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:
|
def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge:
|
||||||
"""Pre-general all the things required for the JS SDK"""
|
"""Pre-general all the things required for the JS SDK"""
|
||||||
apple_client = AppleOAuthClient(
|
apple_client = AppleOAuthClient(
|
||||||
|
|
|
@ -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"""
|
"""saml sp models"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from authentik.core.models import Source
|
from authentik.core.models import Source, UserSourceConnection
|
||||||
from authentik.core.types import UILoginButton
|
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
|
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
|
@ -161,7 +163,7 @@ class SAMLSource(Source):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.saml.api import SAMLSourceSerializer
|
from authentik.sources.saml.api.source import SAMLSourceSerializer
|
||||||
|
|
||||||
return SAMLSourceSerializer
|
return SAMLSourceSerializer
|
||||||
|
|
||||||
|
@ -191,6 +193,19 @@ class SAMLSource(Source):
|
||||||
name=self.name,
|
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):
|
def __str__(self):
|
||||||
return f"SAML Source {self.name}"
|
return f"SAML Source {self.name}"
|
||||||
|
|
||||||
|
@ -198,3 +213,20 @@ class SAMLSource(Source):
|
||||||
|
|
||||||
verbose_name = _("SAML Source")
|
verbose_name = _("SAML Source")
|
||||||
verbose_name_plural = _("SAML Sources")
|
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 defusedxml.lxml import fromstring
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
@ -18,17 +18,9 @@ from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_SOURCES,
|
USER_ATTRIBUTE_SOURCES,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.flows.models import Flow
|
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||||
from authentik.flows.planner import (
|
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||||
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.lib.utils.time import timedelta_from_string
|
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.policies.utils import delete_none_keys
|
||||||
from authentik.sources.saml.exceptions import (
|
from authentik.sources.saml.exceptions import (
|
||||||
InvalidSignature,
|
InvalidSignature,
|
||||||
|
@ -36,9 +28,11 @@ from authentik.sources.saml.exceptions import (
|
||||||
MissingSAMLResponse,
|
MissingSAMLResponse,
|
||||||
UnsupportedNameIDFormat,
|
UnsupportedNameIDFormat,
|
||||||
)
|
)
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
NS_MAP,
|
NS_MAP,
|
||||||
|
NS_SAML_ASSERTION,
|
||||||
|
NS_SAML_PROTOCOL,
|
||||||
SAML_NAME_ID_FORMAT_EMAIL,
|
SAML_NAME_ID_FORMAT_EMAIL,
|
||||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||||
|
@ -46,9 +40,6 @@ from authentik.sources.saml.processors.constants import (
|
||||||
SAML_NAME_ID_FORMAT_X509,
|
SAML_NAME_ID_FORMAT_X509,
|
||||||
)
|
)
|
||||||
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
|
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()
|
LOGGER = get_logger()
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -67,14 +58,14 @@ class ResponseProcessor:
|
||||||
|
|
||||||
_http_request: HttpRequest
|
_http_request: HttpRequest
|
||||||
|
|
||||||
def __init__(self, source: SAMLSource):
|
def __init__(self, source: SAMLSource, request: HttpRequest):
|
||||||
self._source = source
|
self._source = source
|
||||||
|
|
||||||
def parse(self, request: HttpRequest):
|
|
||||||
"""Check if `request` contains SAML Response data, parse and validate it."""
|
|
||||||
self._http_request = request
|
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.
|
# 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:
|
if not raw_response:
|
||||||
raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
|
raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
|
||||||
# Check if response is compressed, b64 decode it
|
# Check if response is compressed, b64 decode it
|
||||||
|
@ -83,7 +74,8 @@ class ResponseProcessor:
|
||||||
|
|
||||||
if self._source.signing_kp:
|
if self._source.signing_kp:
|
||||||
self._verify_signed()
|
self._verify_signed()
|
||||||
self._verify_request_id(request)
|
self._verify_request_id()
|
||||||
|
self._verify_status()
|
||||||
|
|
||||||
def _verify_signed(self):
|
def _verify_signed(self):
|
||||||
"""Verify SAML Response's Signature"""
|
"""Verify SAML Response's Signature"""
|
||||||
|
@ -109,7 +101,7 @@ class ResponseProcessor:
|
||||||
raise InvalidSignature from exc
|
raise InvalidSignature from exc
|
||||||
LOGGER.debug("Successfully verified signautre")
|
LOGGER.debug("Successfully verified signautre")
|
||||||
|
|
||||||
def _verify_request_id(self, request: HttpRequest):
|
def _verify_request_id(self):
|
||||||
if self._source.allow_idp_initiated:
|
if self._source.allow_idp_initiated:
|
||||||
# If IdP-initiated SSO flows are enabled, we want to cache the Response ID
|
# If IdP-initiated SSO flows are enabled, we want to cache the Response ID
|
||||||
# somewhat mitigate replay attacks
|
# somewhat mitigate replay attacks
|
||||||
|
@ -119,14 +111,26 @@ class ResponseProcessor:
|
||||||
seen_ids.append(self._root.attrib["ID"])
|
seen_ids.append(self._root.attrib["ID"])
|
||||||
cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids)
|
cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids)
|
||||||
return
|
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(
|
raise MismatchedRequestID(
|
||||||
"Missing InResponseTo and IdP-initiated Logins are not allowed"
|
"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")
|
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
|
"""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
|
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
|
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)
|
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save()
|
user.save()
|
||||||
return self._flow_response(
|
UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id)
|
||||||
request,
|
return SAMLSourceFlowManager(
|
||||||
self._source.authentication_flow,
|
self._source,
|
||||||
**{
|
self._http_request,
|
||||||
PLAN_CONTEXT_PENDING_USER: user,
|
name_id,
|
||||||
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT,
|
delete_none_keys(self.get_attributes()),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_name_id(self) -> "Element":
|
def _get_name_id(self) -> "Element":
|
||||||
"""Get NameID Element"""
|
"""Get NameID Element"""
|
||||||
assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
|
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||||
if not assertion:
|
if assertion is None:
|
||||||
raise ValueError("Assertion element not found")
|
raise ValueError("Assertion element not found")
|
||||||
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
|
subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject")
|
||||||
if not subject:
|
if subject is None:
|
||||||
raise ValueError("Subject element not found")
|
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:
|
if name_id is None:
|
||||||
raise ValueError("NameID element not found")
|
raise ValueError("NameID element not found")
|
||||||
return name_id
|
return name_id
|
||||||
|
@ -195,7 +198,27 @@ class ResponseProcessor:
|
||||||
f"Assertion contains NameID with unsupported format {_format}."
|
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"""
|
"""Prepare flow plan depending on whether or not the user exists"""
|
||||||
name_id = self._get_name_id()
|
name_id = self._get_name_id()
|
||||||
# Sanity check, show a warning if NameIDPolicy doesn't match what we go
|
# 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.
|
# transient NameIDs are handled separately as they don't have to go through flows.
|
||||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
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()
|
return SAMLSourceFlowManager(
|
||||||
matching_users = User.objects.filter(**name_id_filter)
|
self._source,
|
||||||
# Ensure redirect is carried through when user was trying to
|
self._http_request,
|
||||||
# authorize application
|
name_id.text,
|
||||||
final_redirect = self._http_request.session.get(SESSION_KEY_GET, {}).get(
|
delete_none_keys(self.get_attributes()),
|
||||||
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)},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _flow_response(self, request: HttpRequest, flow: Flow, **kwargs) -> HttpResponse:
|
|
||||||
kwargs[PLAN_CONTEXT_SSO] = True
|
class SAMLSourceFlowManager(SourceFlowManager):
|
||||||
kwargs[PLAN_CONTEXT_SOURCE] = self._source
|
"""Source flow manager for SAML Sources"""
|
||||||
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs)
|
|
||||||
return redirect_with_qs(
|
connection_type = UserSAMLSourceConnection
|
||||||
"authentik_core:if-flow",
|
|
||||||
request.GET,
|
|
||||||
flow_slug=flow.slug,
|
|
||||||
)
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase
|
||||||
from lxml import etree # nosec
|
from lxml import etree # nosec
|
||||||
|
|
||||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
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.lib.xml import lxml_from_string
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLSource
|
||||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||||
|
@ -14,17 +15,17 @@ class TestMetadataProcessor(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
self.source = SAMLSource.objects.create(
|
||||||
def test_metadata_schema(self):
|
slug=generate_id(),
|
||||||
"""Test Metadata generation being valid"""
|
|
||||||
source = SAMLSource.objects.create(
|
|
||||||
slug="provider",
|
|
||||||
issuer="authentik",
|
issuer="authentik",
|
||||||
signing_kp=create_test_cert(),
|
signing_kp=create_test_cert(),
|
||||||
pre_authentication_flow=create_test_flow(),
|
pre_authentication_flow=create_test_flow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_metadata_schema(self):
|
||||||
|
"""Test Metadata generation being valid"""
|
||||||
request = self.factory.get("/")
|
request = self.factory.get("/")
|
||||||
xml = MetadataProcessor(source, request).build_entity_descriptor()
|
xml = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||||
metadata = lxml_from_string(xml)
|
metadata = lxml_from_string(xml)
|
||||||
|
|
||||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec
|
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):
|
def test_metadata_consistent(self):
|
||||||
"""Test Metadata generation being consistent (xml stays the same)"""
|
"""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("/")
|
request = self.factory.get("/")
|
||||||
xml_a = MetadataProcessor(source, request).build_entity_descriptor()
|
xml_a = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||||
xml_b = MetadataProcessor(source, request).build_entity_descriptor()
|
xml_b = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||||
self.assertEqual(xml_a, xml_b)
|
self.assertEqual(xml_a, xml_b)
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_metadata(self):
|
||||||
"""Test Metadata generation being valid"""
|
"""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("/")
|
request = self.factory.get("/")
|
||||||
xml = MetadataProcessor(source, request).build_entity_descriptor()
|
xml = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||||
metadata = ElementTree.fromstring(xml)
|
metadata = ElementTree.fromstring(xml)
|
||||||
self.assertEqual(metadata.attrib["entityID"], "authentik")
|
self.assertEqual(metadata.attrib["entityID"], "authentik")
|
||||||
|
|
||||||
def test_metadata_without_signautre(self):
|
def test_metadata_without_signautre(self):
|
||||||
"""Test Metadata generation being valid"""
|
"""Test Metadata generation being valid"""
|
||||||
source = SAMLSource.objects.create(
|
self.source.signing_kp = None
|
||||||
slug="provider",
|
self.source.save()
|
||||||
issuer="authentik",
|
|
||||||
pre_authentication_flow=create_test_flow(),
|
|
||||||
)
|
|
||||||
request = self.factory.get("/")
|
request = self.factory.get("/")
|
||||||
xml = MetadataProcessor(source, request).build_entity_descriptor()
|
xml = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||||
metadata = ElementTree.fromstring(xml)
|
metadata = ElementTree.fromstring(xml)
|
||||||
self.assertEqual(metadata.attrib["entityID"], "authentik")
|
self.assertEqual(metadata.attrib["entityID"], "authentik")
|
||||||
|
|
|
@ -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)
|
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||||
if not source.enabled:
|
if not source.enabled:
|
||||||
raise Http404
|
raise Http404
|
||||||
processor = ResponseProcessor(source)
|
processor = ResponseProcessor(source, request)
|
||||||
try:
|
try:
|
||||||
processor.parse(request)
|
processor.parse()
|
||||||
except MissingSAMLResponse as exc:
|
except MissingSAMLResponse as exc:
|
||||||
return bad_request_message(request, str(exc))
|
return bad_request_message(request, str(exc))
|
||||||
except VerificationError as exc:
|
except VerificationError as exc:
|
||||||
return bad_request_message(request, str(exc))
|
return bad_request_message(request, str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return processor.prepare_flow(request)
|
return processor.prepare_flow_manager().get_flow()
|
||||||
except (UnsupportedNameIDFormat, ValueError) as exc:
|
except (UnsupportedNameIDFormat, ValueError) as exc:
|
||||||
return bad_request_message(request, str(exc))
|
return bad_request_message(request, str(exc))
|
||||||
|
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
"authentik_sources_plex.plexsource",
|
"authentik_sources_plex.plexsource",
|
||||||
"authentik_sources_plex.plexsourceconnection",
|
"authentik_sources_plex.plexsourceconnection",
|
||||||
"authentik_sources_saml.samlsource",
|
"authentik_sources_saml.samlsource",
|
||||||
|
"authentik_sources_saml.usersamlsourceconnection",
|
||||||
"authentik_stages_authenticator_duo.authenticatorduostage",
|
"authentik_stages_authenticator_duo.authenticatorduostage",
|
||||||
"authentik_stages_authenticator_duo.duodevice",
|
"authentik_stages_authenticator_duo.duodevice",
|
||||||
"authentik_stages_authenticator_sms.authenticatorsmsstage",
|
"authentik_stages_authenticator_sms.authenticatorsmsstage",
|
||||||
|
|
341
schema.yml
341
schema.yml
|
@ -18164,6 +18164,270 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/GenericError'
|
$ref: '#/components/schemas/GenericError'
|
||||||
description: ''
|
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/:
|
/stages/all/:
|
||||||
get:
|
get:
|
||||||
operationId: stages_all_list
|
operationId: stages_all_list
|
||||||
|
@ -32190,6 +32454,41 @@ components:
|
||||||
required:
|
required:
|
||||||
- pagination
|
- pagination
|
||||||
- results
|
- 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:
|
PaginatedUserSourceConnectionList:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -34432,6 +34731,15 @@ components:
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
PatchedUserSAMLSourceConnectionRequest:
|
||||||
|
type: object
|
||||||
|
description: SAML Source Serializer
|
||||||
|
properties:
|
||||||
|
user:
|
||||||
|
type: integer
|
||||||
|
identifier:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
PatchedUserWriteStageRequest:
|
PatchedUserWriteStageRequest:
|
||||||
type: object
|
type: object
|
||||||
description: UserWriteStage Serializer
|
description: UserWriteStage Serializer
|
||||||
|
@ -37376,6 +37684,39 @@ components:
|
||||||
- groups
|
- groups
|
||||||
- name
|
- name
|
||||||
- username
|
- 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:
|
UserSelf:
|
||||||
type: object
|
type: object
|
||||||
description: User Serializer for information a user can retrieve about themselves
|
description: User Serializer for information a user can retrieve about themselves
|
||||||
|
|
|
@ -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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { first } from "@goauthentik/common/utils";
|
import { first } from "@goauthentik/common/utils";
|
||||||
import "@goauthentik/elements/forms/FormGroup";
|
import "@goauthentik/elements/forms/FormGroup";
|
||||||
|
@ -22,6 +23,7 @@ import {
|
||||||
SAMLSource,
|
SAMLSource,
|
||||||
SignatureAlgorithmEnum,
|
SignatureAlgorithmEnum,
|
||||||
SourcesApi,
|
SourcesApi,
|
||||||
|
UserMatchingModeEnum,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-source-saml-form")
|
@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>
|
<label class="pf-c-check__label"> ${t`Enabled`} </label>
|
||||||
</div>
|
</div>
|
||||||
</ak-form-element-horizontal>
|
</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}>
|
<ak-form-group .expanded=${true}>
|
||||||
<span slot="header"> ${t`Protocol settings`} </span>
|
<span slot="header"> ${t`Protocol settings`} </span>
|
||||||
|
|
|
@ -534,7 +534,7 @@ export class FlowExecutor extends AKElement implements StageHost {
|
||||||
? html`
|
? html`
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://unsplash.com/@impatrickt"
|
href="https://unsplash.com/@brendan_k_steeves"
|
||||||
>${t`Background image`}</a
|
>${t`Background image`}</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
import "@goauthentik/user/user-settings/sources/SourceSettingsOAuth";
|
import "@goauthentik/user/user-settings/sources/SourceSettingsOAuth";
|
||||||
import "@goauthentik/user/user-settings/sources/SourceSettingsPlex";
|
import "@goauthentik/user/user-settings/sources/SourceSettingsPlex";
|
||||||
|
import "@goauthentik/user/user-settings/sources/SourceSettingsSAML";
|
||||||
|
|
||||||
import { t } from "@lingui/macro";
|
import { t } from "@lingui/macro";
|
||||||
|
|
||||||
|
@ -95,6 +96,15 @@ export class UserSourceSettingsPage extends AKElement {
|
||||||
.configureUrl=${source.configureUrl}
|
.configureUrl=${source.configureUrl}
|
||||||
>
|
>
|
||||||
</ak-user-settings-source-plex>`;
|
</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:
|
default:
|
||||||
return html`<p>${t`Error: unsupported source settings: ${source.component}`}</p>`;
|
return html`<p>${t`Error: unsupported source settings: ${source.component}`}</p>`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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