From 10f612ade152afdcfbc948238fcdbaf70bec657e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 16 Aug 2024 14:07:32 +0200 Subject: [PATCH 01/44] init Signed-off-by: Jens Langhammer --- Makefile | 13 +- .../enterprise/providers/ssf/__init__.py | 0 .../enterprise/providers/ssf/api/__init__.py | 0 .../enterprise/providers/ssf/api/providers.py | 59 +++ authentik/enterprise/providers/ssf/apps.py | 13 + .../providers/ssf/migrations/0001_initial.py | 140 +++++++ .../providers/ssf/migrations/__init__.py | 0 authentik/enterprise/providers/ssf/models.py | 86 +++++ authentik/enterprise/providers/ssf/signals.py | 54 +++ authentik/enterprise/providers/ssf/tasks.py | 26 ++ authentik/enterprise/providers/ssf/urls.py | 30 ++ .../providers/ssf/views/__init__.py | 0 .../enterprise/providers/ssf/views/auth.py | 54 +++ .../enterprise/providers/ssf/views/base.py | 23 ++ .../providers/ssf/views/configuration.py | 57 +++ .../enterprise/providers/ssf/views/jwks.py | 29 ++ .../enterprise/providers/ssf/views/stream.py | 34 ++ authentik/enterprise/settings.py | 1 + authentik/providers/oauth2/views/jwks.py | 7 +- authentik/sources/scim/views/v2/base.py | 2 +- blueprints/schema.json | 109 ++++++ schema.yml | 363 ++++++++++++++++++ web/src/admin/providers/ProviderListPage.ts | 1 + web/src/admin/providers/ProviderViewPage.ts | 5 + .../oauth2/OAuth2ProviderViewPage.ts | 1 - .../providers/ssf/SSFProviderFormPage.ts | 68 ++++ .../providers/ssf/SSFProviderViewPage.ts | 163 ++++++++ 27 files changed, 1328 insertions(+), 10 deletions(-) create mode 100644 authentik/enterprise/providers/ssf/__init__.py create mode 100644 authentik/enterprise/providers/ssf/api/__init__.py create mode 100644 authentik/enterprise/providers/ssf/api/providers.py create mode 100644 authentik/enterprise/providers/ssf/apps.py create mode 100644 authentik/enterprise/providers/ssf/migrations/0001_initial.py create mode 100644 authentik/enterprise/providers/ssf/migrations/__init__.py create mode 100644 authentik/enterprise/providers/ssf/models.py create mode 100644 authentik/enterprise/providers/ssf/signals.py create mode 100644 authentik/enterprise/providers/ssf/tasks.py create mode 100644 authentik/enterprise/providers/ssf/urls.py create mode 100644 authentik/enterprise/providers/ssf/views/__init__.py create mode 100644 authentik/enterprise/providers/ssf/views/auth.py create mode 100644 authentik/enterprise/providers/ssf/views/base.py create mode 100644 authentik/enterprise/providers/ssf/views/configuration.py create mode 100644 authentik/enterprise/providers/ssf/views/jwks.py create mode 100644 authentik/enterprise/providers/ssf/views/stream.py create mode 100644 web/src/admin/providers/ssf/SSFProviderFormPage.ts create mode 100644 web/src/admin/providers/ssf/SSFProviderViewPage.ts diff --git a/Makefile b/Makefile index 07d769f784df..29dccf8a8990 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ UID = $(shell id -u) GID = $(shell id -g) NPM_VERSION = $(shell python -m scripts.npm_version) PY_SOURCES = authentik tests scripts lifecycle .github +GO_SOURCES = cmd internal +WEB_SOURCES = web/src web/packages DOCKER_IMAGE ?= "authentik:test" GEN_API_TS = "gen-ts-api" @@ -19,11 +21,12 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ -I .github/codespell-words.txt \ -S 'web/src/locales/**' \ - -S 'website/docs/developer-docs/api/reference/**' \ - authentik \ - internal \ - cmd \ - web/src \ + -S 'website/developer-docs/api/reference/**' \ + -S '**/node_modules/**' \ + -S '**/dist/**' \ + $(PY_SOURCES) \ + $(GO_SOURCES) \ + $(WEB_SOURCES) \ website/src \ website/blog \ website/docs \ diff --git a/authentik/enterprise/providers/ssf/__init__.py b/authentik/enterprise/providers/ssf/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/authentik/enterprise/providers/ssf/api/__init__.py b/authentik/enterprise/providers/ssf/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py new file mode 100644 index 000000000000..e74ed07639d0 --- /dev/null +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -0,0 +1,59 @@ +"""SSF Provider API Views""" + +from django.urls import reverse +from rest_framework.fields import SerializerMethodField +from rest_framework.request import Request +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.providers import ProviderSerializer +from authentik.core.api.tokens import TokenSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.enterprise.api import EnterpriseRequiredMixin +from authentik.enterprise.providers.ssf.models import SSFProvider + + +class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): + """SSFProvider Serializer""" + + ssf_url = SerializerMethodField() + token_obj = TokenSerializer(source="token", required=False, read_only=True) + + def get_ssf_url(self, instance: SSFProvider) -> str: + request: Request = self._context["request"] + return request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": instance.application.slug, + "provider": instance.pk, + }, + ) + ) + + class Meta: + model = SSFProvider + fields = [ + "pk", + "name", + "component", + "verbose_name", + "verbose_name_plural", + "meta_model_name", + "signing_key", + "token_obj", + "ssf_url", + ] + extra_kwargs = {} + + +class SSFProviderViewSet(UsedByMixin, ModelViewSet): + """SSFProvider Viewset""" + + queryset = SSFProvider.objects.all() + serializer_class = SSFProviderSerializer + filterset_fields = { + "application": ["isnull"], + "name": ["iexact"], + } + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/enterprise/providers/ssf/apps.py b/authentik/enterprise/providers/ssf/apps.py new file mode 100644 index 000000000000..d3022cbb2411 --- /dev/null +++ b/authentik/enterprise/providers/ssf/apps.py @@ -0,0 +1,13 @@ +"""SSF app config""" + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseProviderSSF(EnterpriseConfig): + """authentik enterprise ssf app config""" + + name = "authentik.enterprise.providers.ssf" + label = "authentik_providers_ssf" + verbose_name = "authentik Enterprise.Providers.SSF" + default = True + mountpoint = "" diff --git a/authentik/enterprise/providers/ssf/migrations/0001_initial.py b/authentik/enterprise/providers/ssf/migrations/0001_initial.py new file mode 100644 index 000000000000..8b2d09d5a105 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -0,0 +1,140 @@ +# Generated by Django 5.0.9 on 2024-09-30 17:22 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"), + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SSFProvider", + fields=[ + ( + "provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.provider", + ), + ), + ( + "signing_key", + models.ForeignKey( + help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.certificatekeypair", + verbose_name="Signing Key", + ), + ), + ( + "token", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="authentik_core.token", + ), + ), + ], + options={ + "verbose_name": "SSF Provider", + "verbose_name_plural": "SSF Providers", + }, + bases=("authentik_core.provider",), + ), + migrations.CreateModel( + name="Stream", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ( + "delivery_method", + models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/risc/delivery-method/push", + "Risc Push", + ), + ( + "https://schemas.openid.net/secevent/risc/delivery-method/poll", + "Risc Poll", + ), + ] + ), + ), + ("endpoint_url", models.TextField(null=True)), + ( + "events_requested", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + "Caep Session Revoked", + ) + ] + ), + default=list, + size=None, + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_ssf.ssfprovider", + ), + ), + ( + "user_subjects", + models.ManyToManyField( + related_name="UserStreamSubject", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.CreateModel( + name="UserStreamSubject", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stream", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_ssf.stream", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + ] diff --git a/authentik/enterprise/providers/ssf/migrations/__init__.py b/authentik/enterprise/providers/ssf/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py new file mode 100644 index 000000000000..8070ffd99c14 --- /dev/null +++ b/authentik/enterprise/providers/ssf/models.py @@ -0,0 +1,86 @@ +from uuid import uuid4 + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ + +from authentik.core.models import BackchannelProvider, Token, User +from authentik.crypto.models import CertificateKeyPair + + +class EventTypes(models.TextChoices): + """SSF Event types supported by authentik""" + + CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + + +class DeliveryMethods(models.TextChoices): + """SSF Delivery methods""" + + RISC_PUSH = "https://schemas.openid.net/secevent/risc/delivery-method/push" + RISC_POLL = "https://schemas.openid.net/secevent/risc/delivery-method/poll" + + +class SSFProvider(BackchannelProvider): + """Shared Signals Framework""" + + signing_key = models.ForeignKey( + CertificateKeyPair, + verbose_name=_("Signing Key"), + on_delete=models.SET_NULL, + null=True, + help_text=_( + "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + ), + ) + + token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) + + @property + def service_account_identifier(self) -> str: + return f"ak-providers-ssf-{self.pk}" + + @property + def serializer(self): + from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer + + return SSFProviderSerializer + + @property + def icon_url(self) -> str | None: + return static("authentik/sources/scim.png") + + @property + def component(self) -> str: + return "ak-provider-ssf-form" + + class Meta: + verbose_name = _("SSF Provider") + verbose_name_plural = _("SSF Providers") + + +class Stream(models.Model): + """SSF Stream""" + + uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False) + provider = models.ForeignKey(SSFProvider, on_delete=models.CASCADE) + + delivery_method = models.TextField(choices=DeliveryMethods.choices) + endpoint_url = models.TextField(null=True) + + events_requested = ArrayField(models.TextField(choices=EventTypes.choices), default=list) + + user_subjects = models.ManyToManyField(User, "UserStreamSubject") + + def __str__(self) -> str: + return "SSF Stream" + + +class UserStreamSubject(models.Model): + + stream = models.ForeignKey(Stream, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"Stream subject {self.stream_id} to {self.user_id}" diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py new file mode 100644 index 000000000000..3e9238cfa5c5 --- /dev/null +++ b/authentik/enterprise/providers/ssf/signals.py @@ -0,0 +1,54 @@ +from django.contrib.auth.signals import user_logged_out +from django.db.models import Model +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.http.request import HttpRequest + +from authentik.core.models import ( + USER_PATH_SYSTEM_PREFIX, + Token, + TokenIntents, + User, + UserTypes, +) +from authentik.enterprise.providers.ssf.models import EventTypes, SSFProvider +from authentik.enterprise.providers.ssf.tasks import send_ssf_event +from authentik.events.middleware import audit_ignore +from authentik.events.utils import get_user + +USER_PATH_PROVIDERS_SSF = USER_PATH_SYSTEM_PREFIX + "/providers/ssf" + + +@receiver(post_save, sender=SSFProvider) +def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: bool, **_): + """Create service account before provider is saved""" + identifier = instance.service_account_identifier + user, _ = User.objects.update_or_create( + username=identifier, + defaults={ + "name": f"SSF Provider {instance.name} Service-Account", + "type": UserTypes.INTERNAL_SERVICE_ACCOUNT, + "path": USER_PATH_PROVIDERS_SSF, + }, + ) + token, token_created = Token.objects.update_or_create( + identifier=identifier, + defaults={ + "user": user, + "intent": TokenIntents.INTENT_API, + "expiring": False, + "managed": f"goauthentik.io/providers/ssf/{instance.pk}", + }, + ) + if created or token_created: + with audit_ignore(): + instance.token = token + instance.save() + + +@receiver(user_logged_out) +def user_logged_out_session(sender, request: HttpRequest, user: User, **_): + send_ssf_event.delay( + EventTypes.CAEP_SESSION_REVOKED, + subject=get_user(user), + ) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py new file mode 100644 index 000000000000..2b4e55af08b2 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -0,0 +1,26 @@ +from celery import group +from requests.exceptions import RequestException + +from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes, Stream +from authentik.lib.utils.http import get_http_session +from authentik.root.celery import CELERY_APP + +session = get_http_session() + + +@CELERY_APP.task(bind=True) +def send_ssf_event(event_type: EventTypes, subject): + tasks = [] + for stream in Stream.objects.filter( + delivery_method=DeliveryMethods.RISC_PUSH, + events_requested__in=[event_type], + ): + tasks.append(ssf_push_request.si(stream.endpoint_url, {})) + main_task = group(*tasks) + main_task() + + +@CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) +def ssf_push_request(endpoint_url: str, data: dict): + response = session.post(endpoint_url, data) + response.raise_for_status() diff --git a/authentik/enterprise/providers/ssf/urls.py b/authentik/enterprise/providers/ssf/urls.py new file mode 100644 index 000000000000..7c2d0b0afccc --- /dev/null +++ b/authentik/enterprise/providers/ssf/urls.py @@ -0,0 +1,30 @@ +"""SSF provider URLs""" + +from django.urls import path + +from authentik.enterprise.providers.ssf.api.providers import SSFProviderViewSet +from authentik.enterprise.providers.ssf.views.configuration import ConfigurationView +from authentik.enterprise.providers.ssf.views.jwks import JWKSview +from authentik.enterprise.providers.ssf.views.stream import StreamView + +urlpatterns = [ + path( + "application/ssf//ssf-jwks/", + JWKSview.as_view(), + name="jwks", + ), + path( + ".well-known/ssf-configuration//", + ConfigurationView.as_view(), + name="configuration", + ), + path( + "application/ssf///stream/", + StreamView.as_view(), + name="stream", + ), +] + +api_urlpatterns = [ + ("providers/ssf", SSFProviderViewSet), +] diff --git a/authentik/enterprise/providers/ssf/views/__init__.py b/authentik/enterprise/providers/ssf/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/authentik/enterprise/providers/ssf/views/auth.py b/authentik/enterprise/providers/ssf/views/auth.py new file mode 100644 index 000000000000..342b63bcecbd --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/auth.py @@ -0,0 +1,54 @@ +"""SSF Token auth""" + +from base64 import b64decode +from typing import Any + +from django.conf import settings +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.request import Request +from rest_framework.views import APIView + +from authentik.core.models import Token, TokenIntents, User +from authentik.enterprise.providers.ssf.models import SSFProvider + + +class SSFTokenAuth(BaseAuthentication): + """SCIM Token auth""" + + def __init__(self, view: APIView) -> None: + super().__init__() + self.view = view + + def legacy(self, key: str, source_slug: str) -> Token | None: # pragma: no cover + """Legacy HTTP-Basic auth for testing""" + if not settings.TEST and not settings.DEBUG: + return None + _username, _, password = b64decode(key.encode()).decode().partition(":") + token = self.check_token(password, source_slug) + if token: + return (token.user, token) + return None + + def check_token(self, key: str, source_slug: str) -> Token | None: + """Check that a token exists, is not expired, and is assigned to the correct source""" + token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first() + if not token: + return None + provider: SSFProvider = token.ssfprovider_set.first() + if not provider: + return None + self.view.application = provider.application + self.view.provider = provider + return token + + def authenticate(self, request: Request) -> tuple[User, Any] | None: + kwargs = request._request.resolver_match.kwargs + source_slug = kwargs.get("source_slug", None) + auth = get_authorization_header(request).decode() + auth_type, _, key = auth.partition(" ") + if auth_type != "Bearer": + return self.legacy(key, source_slug) + token = self.check_token(key, source_slug) + if not token: + return None + return (token.user, token) diff --git a/authentik/enterprise/providers/ssf/views/base.py b/authentik/enterprise/providers/ssf/views/base.py new file mode 100644 index 000000000000..927f9fa2a502 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/base.py @@ -0,0 +1,23 @@ +from django.http import HttpRequest +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from structlog.stdlib import BoundLogger, get_logger + +from authentik.core.models import Application +from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.enterprise.providers.ssf.views.auth import SSFTokenAuth + + +class SSFView(APIView): + application: Application + provider: SSFProvider + logger: BoundLogger + + permission_classes = [IsAuthenticated] + + def setup(self, request: HttpRequest, *args, **kwargs) -> None: + self.logger = get_logger().bind() + super().setup(request, *args, **kwargs) + + def get_authenticators(self): + return [SSFTokenAuth(self)] diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py new file mode 100644 index 000000000000..20ee550d2eeb --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -0,0 +1,57 @@ +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from rest_framework.permissions import AllowAny + +from authentik.core.models import Application +from authentik.enterprise.providers.ssf.models import DeliveryMethods, SSFProvider +from authentik.enterprise.providers.ssf.views.base import SSFView + + +class ConfigurationView(SSFView): + permission_classes = [AllowAny] + + def get_authenticators(self): + return [] + + def get( + self, request: HttpRequest, application_slug: str, provider: int, *args, **kwargs + ) -> HttpResponse: + application = get_object_or_404(Application, slug=application_slug) + provider = get_object_or_404(SSFProvider, pk=provider) + data = { + "issuer": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": application.slug, + "provider": provider.pk, + }, + ) + ), + "jwks_uri": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:jwks", + kwargs={ + "provider": provider.pk, + }, + ) + ), + "configuration_endpoint": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:stream", + kwargs={ + "application_slug": application.slug, + "provider": provider.pk, + }, + ) + ), + "add_subject_endpoint": "https://transmitter.most-secure.com/add-subject", + "remove_subject_endpoint": "https://transmitter.most-secure.com/remove-subject", + "verification_endpoint": "https://transmitter.most-secure.com/verification", + "status_endpoint": "https://transmitter.most-secure.com/status", + "delivery_methods_supported": [ + DeliveryMethods.RISC_PUSH, + ], + } + return JsonResponse(data) diff --git a/authentik/enterprise/providers/ssf/views/jwks.py b/authentik/enterprise/providers/ssf/views/jwks.py new file mode 100644 index 000000000000..87b7de1682ae --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/jwks.py @@ -0,0 +1,29 @@ +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views import View + +from authentik.core.models import Application +from authentik.crypto.models import CertificateKeyPair +from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.providers.oauth2.views.jwks import JWKSView as OAuthJWKSView + + +class JWKSview(View): + + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """Show JWK Key data for Provider""" + application = get_object_or_404(Application, slug=application_slug) + provider: SSFProvider = get_object_or_404(SSFProvider, pk=application.provider_id) + signing_key: CertificateKeyPair = provider.signing_key + + response_data = {} + + if signing_key: + jwk = OAuthJWKSView.get_jwk_for_key(signing_key) + if jwk: + response_data["keys"] = [jwk] + + response = JsonResponse(response_data) + response["Access-Control-Allow-Origin"] = "*" + + return response diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py new file mode 100644 index 000000000000..49cb44db8d63 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -0,0 +1,34 @@ +from django.http import HttpResponse +from rest_framework.fields import CharField, ChoiceField, ListField +from rest_framework.request import Request +from structlog.stdlib import get_logger + +from authentik.core.api.utils import PassiveSerializer +from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes +from authentik.enterprise.providers.ssf.views.base import SSFView + +LOGGER = get_logger() + + +class StreamDeliverySerializer(PassiveSerializer): + method = ChoiceField(choices=[(x.value, x.value) for x in DeliveryMethods]) + endpoint_url = CharField(allow_null=True) + + +class StreamSerializer(PassiveSerializer): + delivery = StreamDeliverySerializer() + events_requested = ListField( + child=ChoiceField(choices=[(x.value, x.value) for x in EventTypes]) + ) + + +class StreamView(SSFView): + # def setup(self, request: HttpRequest, *args, **kwargs) -> None: + # self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"]) + # self.provider = get_object_or_404(SSFProvider, slug=self.kwargs["provider"]) + # # TODO: Auth + # return super().setup(request, *args, **kwargs) + + def post(self, request: Request, *args, **kwargs) -> HttpResponse: + payload = StreamSerializer(request.data) + payload.is_valid(raise_exception=True) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 318493ef6c5b..a032fbd01675 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -17,6 +17,7 @@ "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.rac", + "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.source", ] diff --git a/authentik/providers/oauth2/views/jwks.py b/authentik/providers/oauth2/views/jwks.py index 6e0fc96ec1b3..91a13b8df851 100644 --- a/authentik/providers/oauth2/views/jwks.py +++ b/authentik/providers/oauth2/views/jwks.py @@ -64,7 +64,8 @@ def to_base64url_uint(val: int, min_length: int = 0) -> bytes: class JWKSView(View): """Show RSA Key data for Provider""" - def get_jwk_for_key(self, key: CertificateKeyPair, use: str) -> dict | None: + @staticmethod + def get_jwk_for_key(key: CertificateKeyPair, use: str) -> dict | None: """Convert a certificate-key pair into JWK""" private_key = key.private_key key_data = None @@ -123,12 +124,12 @@ def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: response_data = {} if signing_key := provider.signing_key: - jwk = self.get_jwk_for_key(signing_key, "sig") + jwk = JWKSView.get_jwk_for_key(signing_key, "sig") if jwk: response_data.setdefault("keys", []) response_data["keys"].append(jwk) if encryption_key := provider.encryption_key: - jwk = self.get_jwk_for_key(encryption_key, "enc") + jwk = JWKSView.get_jwk_for_key(encryption_key, "enc") if jwk: response_data.setdefault("keys", []) response_data["keys"].append(jwk) diff --git a/authentik/sources/scim/views/v2/base.py b/authentik/sources/scim/views/v2/base.py index b541f6ca7c74..bcb4a8eed5af 100644 --- a/authentik/sources/scim/views/v2/base.py +++ b/authentik/sources/scim/views/v2/base.py @@ -114,7 +114,7 @@ def paginate_query(self, query: QuerySet) -> Page: class SCIMObjectView(SCIMView): - """Base SCIM View for object management""" + """Base SCIM View for object management""" mapper: SourceMapper manager: PropertyMappingManager diff --git a/blueprints/schema.json b/blueprints/schema.json index 2fb28e7d24ed..9d3fbccaaf03 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3601,6 +3601,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_ssf.ssfprovider" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_providers_ssf.ssfprovider_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_providers_ssf.ssfprovider" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_ssf.ssfprovider" + } + } + }, { "type": "object", "required": [ @@ -4583,6 +4623,7 @@ "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.rac", + "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.source", "authentik.events" @@ -4686,6 +4727,7 @@ "authentik_providers_rac.racprovider", "authentik_providers_rac.endpoint", "authentik_providers_rac.racpropertymapping", + "authentik_providers_ssf.ssfprovider", "authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage", "authentik_stages_source.sourcestage", "authentik_events.event", @@ -6687,6 +6729,18 @@ "authentik_providers_scim.view_scimprovider", "authentik_providers_scim.view_scimprovidergroup", "authentik_providers_scim.view_scimprovideruser", + "authentik_providers_ssf.add_ssfprovider", + "authentik_providers_ssf.add_stream", + "authentik_providers_ssf.add_userstreamsubject", + "authentik_providers_ssf.change_ssfprovider", + "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_userstreamsubject", + "authentik_providers_ssf.delete_ssfprovider", + "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_userstreamsubject", + "authentik_providers_ssf.view_ssfprovider", + "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_userstreamsubject", "authentik_rbac.access_admin_interface", "authentik_rbac.add_role", "authentik_rbac.assign_role_permissions", @@ -12936,6 +12990,18 @@ "authentik_providers_scim.view_scimprovider", "authentik_providers_scim.view_scimprovidergroup", "authentik_providers_scim.view_scimprovideruser", + "authentik_providers_ssf.add_ssfprovider", + "authentik_providers_ssf.add_stream", + "authentik_providers_ssf.add_userstreamsubject", + "authentik_providers_ssf.change_ssfprovider", + "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_userstreamsubject", + "authentik_providers_ssf.delete_ssfprovider", + "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_userstreamsubject", + "authentik_providers_ssf.view_ssfprovider", + "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_userstreamsubject", "authentik_rbac.access_admin_interface", "authentik_rbac.add_role", "authentik_rbac.assign_role_permissions", @@ -13988,6 +14054,49 @@ } } }, + "model_authentik_providers_ssf.ssfprovider": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "signing_key": { + "type": "string", + "format": "uuid", + "title": "Signing Key", + "description": "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + } + }, + "required": [] + }, + "model_authentik_providers_ssf.ssfprovider_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_ssfprovider", + "change_ssfprovider", + "delete_ssfprovider", + "view_ssfprovider" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": { "type": "object", "properties": { diff --git a/schema.yml b/schema.yml index 820952f890f3..a320f5f0119e 100644 --- a/schema.yml +++ b/schema.yml @@ -22953,6 +22953,274 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/ssf/: + get: + operationId: providers_ssf_list + description: SSFProvider Viewset + parameters: + - in: query + name: application__isnull + schema: + type: boolean + - in: query + name: name__iexact + schema: + type: string + - 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 + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSSFProviderList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: providers_ssf_create + description: SSFProvider Viewset + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProviderRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/ssf/{id}/: + get: + operationId: providers_ssf_retrieve + description: SSFProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SSF Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: providers_ssf_update + description: SSFProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SSF Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProviderRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: providers_ssf_partial_update + description: SSFProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SSF Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedSSFProviderRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SSFProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: providers_ssf_destroy + description: SSFProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SSF Provider. + required: true + tags: + - providers + 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: '' + /providers/ssf/{id}/used_by/: + get: + operationId: providers_ssf_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 SSF Provider. + required: true + tags: + - providers + 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: '' /rac/connection_tokens/: get: operationId: rac_connection_tokens_list @@ -23630,6 +23898,7 @@ paths: - authentik_providers_saml.samlprovider - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider + - authentik_providers_ssf.ssfprovider - authentik_rbac.role - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_kerberos.kerberossource @@ -23871,6 +24140,7 @@ paths: - authentik_providers_saml.samlprovider - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider + - authentik_providers_ssf.ssfprovider - authentik_rbac.role - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_kerberos.kerberossource @@ -38345,6 +38615,7 @@ components: - authentik.enterprise.providers.google_workspace - authentik.enterprise.providers.microsoft_entra - authentik.enterprise.providers.rac + - authentik.enterprise.providers.ssf - authentik.enterprise.stages.authenticator_endpoint_gdtc - authentik.enterprise.stages.source - authentik.events @@ -45240,6 +45511,7 @@ components: - authentik_providers_rac.racprovider - authentik_providers_rac.endpoint - authentik_providers_rac.racpropertymapping + - authentik_providers_ssf.ssfprovider - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage - authentik_stages_source.sourcestage - authentik_events.event @@ -47547,6 +47819,18 @@ components: required: - pagination - results + PaginatedSSFProviderList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/SSFProvider' + required: + - pagination + - results PaginatedScopeMappingList: type: object properties: @@ -50916,6 +51200,19 @@ components: minLength: 1 description: The human-readable name of this device. maxLength: 64 + PatchedSSFProviderRequest: + type: object + description: SSFProvider Serializer + properties: + name: + type: string + minLength: 1 + signing_key: + type: string + format: uuid + nullable: true + description: Key used to sign the tokens. Only required when JWT Algorithm + is set to RS256. PatchedScopeMappingRequest: type: object description: ScopeMapping Serializer @@ -52197,6 +52494,7 @@ components: - authentik_providers_radius.radiusprovider - authentik_providers_saml.samlprovider - authentik_providers_scim.scimprovider + - authentik_providers_ssf.ssfprovider type: string ProviderRequest: type: object @@ -54625,6 +54923,69 @@ components: maxLength: 64 required: - name + SSFProvider: + type: object + description: SSFProvider Serializer + properties: + pk: + type: integer + readOnly: true + title: ID + name: + type: string + component: + type: string + description: Get object component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + signing_key: + type: string + format: uuid + nullable: true + description: Key used to sign the tokens. Only required when JWT Algorithm + is set to RS256. + token_obj: + allOf: + - $ref: '#/components/schemas/Token' + readOnly: true + ssf_url: + type: string + readOnly: true + required: + - component + - meta_model_name + - name + - pk + - ssf_url + - token_obj + - verbose_name + - verbose_name_plural + SSFProviderRequest: + type: object + description: SSFProvider Serializer + properties: + name: + type: string + minLength: 1 + signing_key: + type: string + format: uuid + nullable: true + description: Key used to sign the tokens. Only required when JWT Algorithm + is set to RS256. + required: + - name ScopeMapping: type: object description: ScopeMapping Serializer @@ -57071,6 +57432,7 @@ components: - $ref: '#/components/schemas/RadiusProviderRequest' - $ref: '#/components/schemas/SAMLProviderRequest' - $ref: '#/components/schemas/SCIMProviderRequest' + - $ref: '#/components/schemas/SSFProviderRequest' discriminator: propertyName: provider_model mapping: @@ -57083,6 +57445,7 @@ components: authentik_providers_radius.radiusprovider: '#/components/schemas/RadiusProviderRequest' authentik_providers_saml.samlprovider: '#/components/schemas/SAMLProviderRequest' authentik_providers_scim.scimprovider: '#/components/schemas/SCIMProviderRequest' + authentik_providers_ssf.ssfprovider: '#/components/schemas/SSFProviderRequest' securitySchemes: authentik: type: http diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index e6922b5ccbe2..8e778b6b6506 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -9,6 +9,7 @@ import "@goauthentik/admin/providers/rac/RACProviderForm"; import "@goauthentik/admin/providers/radius/RadiusProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/scim/SCIMProviderForm"; +import "@goauthentik/admin/providers/ssf/SSFProviderFormPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; diff --git a/web/src/admin/providers/ProviderViewPage.ts b/web/src/admin/providers/ProviderViewPage.ts index 027648a77232..d1b42bcf7963 100644 --- a/web/src/admin/providers/ProviderViewPage.ts +++ b/web/src/admin/providers/ProviderViewPage.ts @@ -7,6 +7,7 @@ import "@goauthentik/admin/providers/rac/RACProviderViewPage"; import "@goauthentik/admin/providers/radius/RadiusProviderViewPage"; import "@goauthentik/admin/providers/saml/SAMLProviderViewPage"; import "@goauthentik/admin/providers/scim/SCIMProviderViewPage"; +import "@goauthentik/admin/providers/ssf/SSFProviderViewPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/EmptyState"; @@ -80,6 +81,10 @@ export class ProviderViewPage extends AKElement { return html``; + case "ak-provider-ssf-form": + return html``; default: return html`

Invalid provider type ${this.provider?.component}

`; } diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts index f2a866536a81..23d21a9cc15f 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts @@ -369,7 +369,6 @@ export class OAuth2ProviderViewPage extends AKElement { ]} .md=${MDProviderOAuth2} meta="providers/oauth2/index.md" - ; > diff --git a/web/src/admin/providers/ssf/SSFProviderFormPage.ts b/web/src/admin/providers/ssf/SSFProviderFormPage.ts new file mode 100644 index 000000000000..e6dbc70e4a2e --- /dev/null +++ b/web/src/admin/providers/ssf/SSFProviderFormPage.ts @@ -0,0 +1,68 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { ProvidersApi, SSFProvider } from "@goauthentik/api"; + +/** + * Form page for SSF Authentication Method + * + * @element ak-provider-ssf-form + * + */ + +@customElement("ak-provider-ssf-form") +export class SSFProviderFormPage extends BaseProviderForm { + async loadInstance(pk: number): Promise { + const provider = await new ProvidersApi(DEFAULT_CONFIG).providersSsfRetrieve({ + id: pk, + }); + return provider; + } + + async send(data: SSFProvider): Promise { + if (this.instance) { + return new ProvidersApi(DEFAULT_CONFIG).providersSsfUpdate({ + id: this.instance.pk, + sSFProviderRequest: data, + }); + } else { + return new ProvidersApi(DEFAULT_CONFIG).providersSsfCreate({ + sSFProviderRequest: data, + }); + } + } + + renderForm(): TemplateResult { + const provider = this.instance; + + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-ssf-form": SSFProviderFormPage; + } +} diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts new file mode 100644 index 000000000000..21f8eb0b85b4 --- /dev/null +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -0,0 +1,163 @@ +import "@goauthentik/admin/providers/RelatedApplicationButton"; +import "@goauthentik/admin/providers/ssf/SSFProviderFormPage"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/events/ObjectChangelog"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/EmptyState"; +import "@goauthentik/elements/Markdown"; +import "@goauthentik/elements/Tabs"; +import "@goauthentik/elements/buttons/ModalButton"; +import "@goauthentik/elements/buttons/SpinnerButton"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFDivider from "@patternfly/patternfly/components/Divider/divider.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { + ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, + SSFProvider, +} from "@goauthentik/api"; + +@customElement("ak-provider-ssf-view") +export class SSFProviderViewPage extends AKElement { + @property({ type: Number }) + set providerID(value: number) { + new ProvidersApi(DEFAULT_CONFIG) + .providersSsfRetrieve({ + id: value, + }) + .then((prov) => { + this.provider = prov; + }); + } + + @property({ attribute: false }) + provider?: SSFProvider; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFButton, + PFPage, + PFGrid, + PFContent, + PFCard, + PFDescriptionList, + PFForm, + PFFormControl, + PFBanner, + PFDivider, + ]; + } + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this.provider?.pk) return; + this.providerID = this.provider?.pk; + }); + } + + render(): TemplateResult { + if (!this.provider) { + return html``; + } + return html` +
+ ${this.renderTabOverview()} +
+
+
+
+ + +
+
+
+ +
`; + } + + renderTabOverview(): TemplateResult { + if (!this.provider) { + return html``; + } + return html`
+
+
+
+
+
+ ${msg("Name")} +
+
+
${this.provider.name}
+
+
+
+
+ ${msg("Assigned to application")} +
+
+
+ + +
+
+
+
+
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-ssf-view": SSFProviderViewPage; + } +} From fef1580bf738f9b9e72338be40c36ab5f0e84bfa Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 17 Oct 2024 12:58:06 +0200 Subject: [PATCH 02/44] fix some other stuff Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/views/configuration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py index 20ee550d2eeb..354fb6a00873 100644 --- a/authentik/enterprise/providers/ssf/views/configuration.py +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -20,6 +20,7 @@ def get( application = get_object_or_404(Application, slug=application_slug) provider = get_object_or_404(SSFProvider, pk=provider) data = { + "spec_version": "1_0-ID2", "issuer": self.request.build_absolute_uri( reverse( "authentik_providers_ssf:configuration", @@ -53,5 +54,6 @@ def get( "delivery_methods_supported": [ DeliveryMethods.RISC_PUSH, ], + "authorization_schemes": [{"spec_urn": "urn:ietf:rfc:6749"}], } return JsonResponse(data) From e2a2b6734b2822213d299da976726f48c44e5033 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 7 Nov 2024 17:29:23 +0100 Subject: [PATCH 03/44] more progress Signed-off-by: Jens Langhammer --- .../enterprise/providers/ssf/api/providers.py | 5 +- .../0002_ssfprovider_oidc_auth_providers.py | 21 ++++ .../ssf/migrations/0003_stream_format.py | 19 ++++ ...tream_aud_alter_stream_events_requested.py | 41 ++++++++ authentik/enterprise/providers/ssf/models.py | 9 +- .../providers/ssf/tests/__init__.py | 0 .../providers/ssf/tests/test_stream.py | 48 ++++++++++ .../enterprise/providers/ssf/views/auth.py | 41 ++++---- .../enterprise/providers/ssf/views/stream.py | 96 ++++++++++++++++--- blueprints/schema.json | 7 ++ schema.yml | 13 +++ .../providers/ssf/SSFProviderViewPage.ts | 8 ++ 12 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py create mode 100644 authentik/enterprise/providers/ssf/migrations/0003_stream_format.py create mode 100644 authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py create mode 100644 authentik/enterprise/providers/ssf/tests/__init__.py create mode 100644 authentik/enterprise/providers/ssf/tests/test_stream.py diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py index e74ed07639d0..3cf69b163cf8 100644 --- a/authentik/enterprise/providers/ssf/api/providers.py +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -18,8 +18,10 @@ class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): ssf_url = SerializerMethodField() token_obj = TokenSerializer(source="token", required=False, read_only=True) - def get_ssf_url(self, instance: SSFProvider) -> str: + def get_ssf_url(self, instance: SSFProvider) -> str | None: request: Request = self._context["request"] + if not instance.application: + return None return request.build_absolute_uri( reverse( "authentik_providers_ssf:configuration", @@ -41,6 +43,7 @@ class Meta: "meta_model_name", "signing_key", "token_obj", + "oidc_auth_providers", "ssf_url", ] extra_kwargs = {} diff --git a/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py b/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py new file mode 100644 index 000000000000..a5b665e91524 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.9 on 2024-11-07 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"), + ("authentik_providers_ssf", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="ssfprovider", + name="oidc_auth_providers", + field=models.ManyToManyField( + blank=True, default=None, to="authentik_providers_oauth2.oauth2provider" + ), + ), + ] diff --git a/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py b/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py new file mode 100644 index 000000000000..130a975efd1f --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.9 on 2024-11-07 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_ssf", "0002_ssfprovider_oidc_auth_providers"), + ] + + operations = [ + migrations.AddField( + model_name="stream", + name="format", + field=models.TextField(default=""), + preserve_default=False, + ), + ] diff --git a/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py b/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py new file mode 100644 index 000000000000..3734fa9ad5d3 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.9 on 2024-11-07 15:10 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_ssf", "0003_stream_format"), + ] + + operations = [ + migrations.AddField( + model_name="stream", + name="aud", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=list, size=None + ), + ), + migrations.AlterField( + model_name="stream", + name="events_requested", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + "Caep Session Revoked", + ), + ( + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "Caep Credential Change", + ), + ] + ), + default=list, + size=None, + ), + ), + ] diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index 8070ffd99c14..fda48850324a 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -2,17 +2,21 @@ from django.contrib.postgres.fields import ArrayField 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 authentik.core.models import BackchannelProvider, Token, User from authentik.crypto.models import CertificateKeyPair +from authentik.providers.oauth2.models import OAuth2Provider class EventTypes(models.TextChoices): """SSF Event types supported by authentik""" CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change" class DeliveryMethods(models.TextChoices): @@ -35,6 +39,8 @@ class SSFProvider(BackchannelProvider): ), ) + oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None) + token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) @property @@ -70,6 +76,8 @@ class Stream(models.Model): endpoint_url = models.TextField(null=True) events_requested = ArrayField(models.TextField(choices=EventTypes.choices), default=list) + format = models.TextField() + aud = ArrayField(models.TextField(), default=list) user_subjects = models.ManyToManyField(User, "UserStreamSubject") @@ -78,7 +86,6 @@ def __str__(self) -> str: class UserStreamSubject(models.Model): - stream = models.ForeignKey(Stream, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/authentik/enterprise/providers/ssf/tests/__init__.py b/authentik/enterprise/providers/ssf/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py new file mode 100644 index 000000000000..3c183fc9d6e9 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -0,0 +1,48 @@ +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_cert +from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.lib.generators import generate_id + + +class TestStream(APITestCase): + def setUp(self): + self.provider = SSFProvider.objects.create( + name=generate_id(), + signing_key=create_test_cert(), + ) + self.application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + provider=self.provider + ) + + def test_stream_add(self): + """test stream add""" + res = self.client.post( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug, "provider": self.provider.pk}, + ), + data={ + "iss": "https://screw-fotos-bracelets-longitude.trycloudflare.com/.well-known/ssf-configuration/abm-ssf/5", + "aud": [ + "https://federation.apple.com/feeds/business/caep/2034455812/871ada94-90f6-4cdc-9996-a9dd8d62ef14" + ], + "delivery": { + "method": "https://schemas.openid.net/secevent/risc/delivery-method/push", + "endpoint_url": "https://federation.apple.com/feeds/business/caep/2034455812/871ada94-90f6-4cdc-9996-a9dd8d62ef14", + }, + "events_requested": [ + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + ], + "format": "iss_sub", + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + print(res) + print(res.content) + self.assertEqual(res.status_code, 200) diff --git a/authentik/enterprise/providers/ssf/views/auth.py b/authentik/enterprise/providers/ssf/views/auth.py index 342b63bcecbd..97b735e42e82 100644 --- a/authentik/enterprise/providers/ssf/views/auth.py +++ b/authentik/enterprise/providers/ssf/views/auth.py @@ -1,15 +1,14 @@ """SSF Token auth""" -from base64 import b64decode from typing import Any -from django.conf import settings from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request from rest_framework.views import APIView from authentik.core.models import Token, TokenIntents, User from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.providers.oauth2.models import AccessToken class SSFTokenAuth(BaseAuthentication): @@ -19,17 +18,7 @@ def __init__(self, view: APIView) -> None: super().__init__() self.view = view - def legacy(self, key: str, source_slug: str) -> Token | None: # pragma: no cover - """Legacy HTTP-Basic auth for testing""" - if not settings.TEST and not settings.DEBUG: - return None - _username, _, password = b64decode(key.encode()).decode().partition(":") - token = self.check_token(password, source_slug) - if token: - return (token.user, token) - return None - - def check_token(self, key: str, source_slug: str) -> Token | None: + def check_token(self, key: str) -> Token | None: """Check that a token exists, is not expired, and is assigned to the correct source""" token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first() if not token: @@ -41,14 +30,30 @@ def check_token(self, key: str, source_slug: str) -> Token | None: self.view.provider = provider return token + def check_jwt(self, jwt: str, provider_pk: int) -> AccessToken | None: + token = AccessToken.filter_not_expired(token=jwt, revoked=False).first() + if not token: + return None + ssf_provider = SSFProvider.objects.filter( + pk=provider_pk, oidc_auth_providers__in=[token.provider] + ).first() + if not ssf_provider: + return None + self.view.application = ssf_provider.application + self.view.provider = ssf_provider + return token + def authenticate(self, request: Request) -> tuple[User, Any] | None: kwargs = request._request.resolver_match.kwargs - source_slug = kwargs.get("source_slug", None) + provider = kwargs.get("provider", None) auth = get_authorization_header(request).decode() auth_type, _, key = auth.partition(" ") if auth_type != "Bearer": - return self.legacy(key, source_slug) - token = self.check_token(key, source_slug) - if not token: return None - return (token.user, token) + token = self.check_token(key) + if token: + return (token.user, token) + jwt_token = self.check_jwt(key, provider) + if jwt_token: + return (jwt_token.user, token) + return None diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 49cb44db8d63..f0266c89f138 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -1,10 +1,13 @@ -from django.http import HttpResponse -from rest_framework.fields import CharField, ChoiceField, ListField +from django.urls import reverse +from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer from structlog.stdlib import get_logger +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin from authentik.core.api.utils import PassiveSerializer -from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes +from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes, Stream from authentik.enterprise.providers.ssf.views.base import SSFView LOGGER = get_logger() @@ -15,20 +18,87 @@ class StreamDeliverySerializer(PassiveSerializer): endpoint_url = CharField(allow_null=True) -class StreamSerializer(PassiveSerializer): +class StreamSerializer(ModelSerializer): + delivery = StreamDeliverySerializer() events_requested = ListField( child=ChoiceField(choices=[(x.value, x.value) for x in EventTypes]) ) + format = CharField() + aud = ListField(child=CharField()) + + def create(self, validated_data): + return super().create( + { + "delivery_method": validated_data["delivery"]["method"], + "endpoint_url": validated_data["delivery"].get("endpoint_url"), + "format": validated_data["format"], + "provider": validated_data["provider"], + "events_requested": validated_data["events_requested"], + "aud": validated_data["aud"], + } + ) + + class Meta: + model = Stream + fields = [ + "delivery", + "events_requested", + "format", + "aud", + ] + + +class StreamResponseSerializer(PassiveSerializer): + + stream_id = CharField(source="pk") + iss = SerializerMethodField() + aud = ListField(child=CharField()) + delivery = SerializerMethodField() + + events_requested = ListField(child=CharField()) + events_supported = SerializerMethodField() + events_delivered = ListField(child=CharField(), source="events_requested") + + def get_iss(self, instance: Stream) -> str: + request: Request = self._context["request"] + if not instance.provider.application: + return None + return request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": instance.provider.application.slug, + "provider": instance.provider.pk, + }, + ) + ) + + def get_delivery(self, instance: Stream) -> StreamDeliverySerializer: + return { + "method": instance.delivery_method, + "endpoint_url": instance.endpoint_url, + } + + def get_events_supported(self, instance: Stream) -> list[str]: + return [x.value for x in EventTypes] class StreamView(SSFView): - # def setup(self, request: HttpRequest, *args, **kwargs) -> None: - # self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"]) - # self.provider = get_object_or_404(SSFProvider, slug=self.kwargs["provider"]) - # # TODO: Auth - # return super().setup(request, *args, **kwargs) - - def post(self, request: Request, *args, **kwargs) -> HttpResponse: - payload = StreamSerializer(request.data) - payload.is_valid(raise_exception=True) + def post(self, request: Request, *args, **kwargs) -> Response: + stream = StreamSerializer(data=request.data) + stream.is_valid(raise_exception=True) + instance =stream.save(provider=self.provider) + response = StreamResponseSerializer(instance=instance, context={ + "request": request + }).data + print(response) + return Response(response, status=201) + + def delete(self, request: Request, *args, **kwargs) -> Response: + streams = Stream.objects.filter(provider=self.provider) + # Technically this parameter is required by the spec... + if "stream_id" in request.query_params: + streams = streams.filter(stream_id=request.query_params["stream_id"]) + streams.delete() + return Response(status=204) diff --git a/blueprints/schema.json b/blueprints/schema.json index 9d3fbccaaf03..ab140d92cd8f 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -14067,6 +14067,13 @@ "format": "uuid", "title": "Signing Key", "description": "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + }, + "oidc_auth_providers": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Oidc auth providers" } }, "required": [] diff --git a/schema.yml b/schema.yml index a320f5f0119e..47bbdb8cf199 100644 --- a/schema.yml +++ b/schema.yml @@ -51213,6 +51213,10 @@ components: nullable: true description: Key used to sign the tokens. Only required when JWT Algorithm is set to RS256. + oidc_auth_providers: + type: array + items: + type: integer PatchedScopeMappingRequest: type: object description: ScopeMapping Serializer @@ -54959,8 +54963,13 @@ components: allOf: - $ref: '#/components/schemas/Token' readOnly: true + oidc_auth_providers: + type: array + items: + type: integer ssf_url: type: string + nullable: true readOnly: true required: - component @@ -54984,6 +54993,10 @@ components: nullable: true description: Key used to sign the tokens. Only required when JWT Algorithm is set to RS256. + oidc_auth_providers: + type: array + items: + type: integer required: - name ScopeMapping: diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts index 21f8eb0b85b4..cdba7ba2926b 100644 --- a/web/src/admin/providers/ssf/SSFProviderViewPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -125,6 +125,14 @@ export class SSFProviderViewPage extends AKElement {
${this.provider.name}
+
+
+ ${msg("URL")} +
+
+
${this.provider.ssfUrl}
+
+
Date: Wed, 11 Dec 2024 00:40:50 +0100 Subject: [PATCH 04/44] fix missing format Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/views/stream.py | 1 + 1 file changed, 1 insertion(+) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index f0266c89f138..2f5cd27fa2e1 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -55,6 +55,7 @@ class StreamResponseSerializer(PassiveSerializer): iss = SerializerMethodField() aud = ListField(child=CharField()) delivery = SerializerMethodField() + format = CharField() events_requested = ListField(child=CharField()) events_supported = SerializerMethodField() From 0c12d6967d14e82f5b7447831c0599c6184f2ee4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 11 Dec 2024 01:25:03 +0100 Subject: [PATCH 05/44] make it work, send verification event Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/models.py | 31 ++++++++++++-- authentik/enterprise/providers/ssf/signals.py | 40 +++++++++++++++++-- authentik/enterprise/providers/ssf/tasks.py | 13 ++++-- .../providers/ssf/tests/test_stream.py | 4 +- .../providers/ssf/views/configuration.py | 4 -- .../enterprise/providers/ssf/views/jwks.py | 8 ++-- .../enterprise/providers/ssf/views/stream.py | 8 +--- .../traefik_single/config-static.yaml | 2 +- 8 files changed, 82 insertions(+), 28 deletions(-) diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index fda48850324a..97f1cda078ea 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -1,15 +1,18 @@ +from functools import cached_property from uuid import uuid4 +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from django.contrib.postgres.fields import ArrayField 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 jwt import encode from authentik.core.models import BackchannelProvider, Token, User from authentik.crypto.models import CertificateKeyPair -from authentik.providers.oauth2.models import OAuth2Provider +from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider class EventTypes(models.TextChoices): @@ -17,6 +20,7 @@ class EventTypes(models.TextChoices): CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change" + SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification" class DeliveryMethods(models.TextChoices): @@ -43,6 +47,20 @@ class SSFProvider(BackchannelProvider): token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) + @cached_property + def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: + """Get either the configured certificate or the client secret""" + if not self.signing_key: + # No Certificate at all, assume HS256 + return self.client_secret, JWTAlgorithms.HS256 + key: CertificateKeyPair = self.signing_key + private_key = key.private_key + if isinstance(private_key, RSAPrivateKey): + return private_key, JWTAlgorithms.RS256 + if isinstance(private_key, EllipticCurvePrivateKey): + return private_key, JWTAlgorithms.ES256 + raise ValueError(f"Invalid private key type: {type(private_key)}") + @property def service_account_identifier(self) -> str: return f"ak-providers-ssf-{self.pk}" @@ -84,6 +102,13 @@ class Stream(models.Model): def __str__(self) -> str: return "SSF Stream" + def encode(self, data: dict) -> str: + headers = {} + if self.provider.signing_key: + headers["kid"] = self.provider.signing_key.kid + key, alg = self.provider.jwt_key + return encode(data, key, algorithm=alg, headers=headers) + class UserStreamSubject(models.Model): stream = models.ForeignKey(Stream, on_delete=models.CASCADE) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 3e9238cfa5c5..419cb60843e0 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -1,3 +1,6 @@ +from datetime import datetime +from uuid import uuid4 + from django.contrib.auth.signals import user_logged_out from django.db.models import Model from django.db.models.signals import post_save @@ -11,8 +14,13 @@ User, UserTypes, ) -from authentik.enterprise.providers.ssf.models import EventTypes, SSFProvider -from authentik.enterprise.providers.ssf.tasks import send_ssf_event +from authentik.enterprise.providers.ssf.models import ( + DeliveryMethods, + EventTypes, + SSFProvider, + Stream, +) +from authentik.enterprise.providers.ssf.tasks import send_ssf_event, ssf_push_request from authentik.events.middleware import audit_ignore from authentik.events.utils import get_user @@ -46,9 +54,35 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: instance.save() +@receiver(post_save, sender=Stream) +def ssf_stream_post_create(sender: type[Model], instance: Stream, created: bool, **_): + """Send a verification event when a push stream is created""" + if not created: + return + if instance.delivery_method != DeliveryMethods.RISC_PUSH: + return + ssf_push_request.delay( + str(instance.uuid), + instance.endpoint_url, + { + "jti": uuid4().hex, + # TODO: Figure out how to get iss + "iss": "https://ak.beryju.dev/.well-known/ssf-configuration/abm-ssf/8", + "aud": instance.aud[0], + "iat": int(datetime.now().timestamp()), + "sub_id": {"format": "opaque", "id": str(instance.uuid)}, + "events": { + "https://schemas.openid.net/secevent/ssf/event-type/verification": { + "state": None, + } + }, + }, + ) + + @receiver(user_logged_out) def user_logged_out_session(sender, request: HttpRequest, user: User, **_): send_ssf_event.delay( - EventTypes.CAEP_SESSION_REVOKED, + EventTypes.SET_VERIFICATION, subject=get_user(user), ) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 2b4e55af08b2..4f31ca5a012d 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -15,12 +15,19 @@ def send_ssf_event(event_type: EventTypes, subject): delivery_method=DeliveryMethods.RISC_PUSH, events_requested__in=[event_type], ): - tasks.append(ssf_push_request.si(stream.endpoint_url, {})) + tasks.append(ssf_push_request.si(str(stream.uuid), stream.endpoint_url, {})) main_task = group(*tasks) main_task() @CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) -def ssf_push_request(endpoint_url: str, data: dict): - response = session.post(endpoint_url, data) +def ssf_push_request(self, stream_id: str, endpoint_url: str, data: dict): + stream = Stream.objects.filter(pk=stream_id).first() + if not stream: + return + response = session.post( + endpoint_url, + data=stream.encode(data), + headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"}, + ) response.raise_for_status() diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py index 3c183fc9d6e9..d2b8956a4eea 100644 --- a/authentik/enterprise/providers/ssf/tests/test_stream.py +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -14,9 +14,7 @@ def setUp(self): signing_key=create_test_cert(), ) self.application = Application.objects.create( - name=generate_id(), - slug=generate_id(), - provider=self.provider + name=generate_id(), slug=generate_id(), provider=self.provider ) def test_stream_add(self): diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py index 354fb6a00873..5a91fe00e8ce 100644 --- a/authentik/enterprise/providers/ssf/views/configuration.py +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -47,10 +47,6 @@ def get( }, ) ), - "add_subject_endpoint": "https://transmitter.most-secure.com/add-subject", - "remove_subject_endpoint": "https://transmitter.most-secure.com/remove-subject", - "verification_endpoint": "https://transmitter.most-secure.com/verification", - "status_endpoint": "https://transmitter.most-secure.com/status", "delivery_methods_supported": [ DeliveryMethods.RISC_PUSH, ], diff --git a/authentik/enterprise/providers/ssf/views/jwks.py b/authentik/enterprise/providers/ssf/views/jwks.py index 87b7de1682ae..696447d7bafb 100644 --- a/authentik/enterprise/providers/ssf/views/jwks.py +++ b/authentik/enterprise/providers/ssf/views/jwks.py @@ -2,7 +2,6 @@ from django.shortcuts import get_object_or_404 from django.views import View -from authentik.core.models import Application from authentik.crypto.models import CertificateKeyPair from authentik.enterprise.providers.ssf.models import SSFProvider from authentik.providers.oauth2.views.jwks import JWKSView as OAuthJWKSView @@ -10,16 +9,15 @@ class JWKSview(View): - def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: + def get(self, request: HttpRequest, provider: int) -> HttpResponse: """Show JWK Key data for Provider""" - application = get_object_or_404(Application, slug=application_slug) - provider: SSFProvider = get_object_or_404(SSFProvider, pk=application.provider_id) + provider: SSFProvider = get_object_or_404(SSFProvider, pk=provider) signing_key: CertificateKeyPair = provider.signing_key response_data = {} if signing_key: - jwk = OAuthJWKSView.get_jwk_for_key(signing_key) + jwk = OAuthJWKSView.get_jwk_for_key(signing_key, "sig") if jwk: response_data["keys"] = [jwk] diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 2f5cd27fa2e1..c5aef348af1a 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -4,7 +4,6 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer from structlog.stdlib import get_logger -from rest_framework.mixins import CreateModelMixin, DestroyModelMixin from authentik.core.api.utils import PassiveSerializer from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes, Stream @@ -89,11 +88,8 @@ class StreamView(SSFView): def post(self, request: Request, *args, **kwargs) -> Response: stream = StreamSerializer(data=request.data) stream.is_valid(raise_exception=True) - instance =stream.save(provider=self.provider) - response = StreamResponseSerializer(instance=instance, context={ - "request": request - }).data - print(response) + instance = stream.save(provider=self.provider) + response = StreamResponseSerializer(instance=instance, context={"request": request}).data return Response(response, status=201) def delete(self, request: Request, *args, **kwargs) -> Response: diff --git a/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml b/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml index e08cc99754b2..6a9480f65244 100644 --- a/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml +++ b/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml @@ -12,7 +12,7 @@ entryPoints: web: address: ":80" -# Re-use the same config file to define everything +# Reuse the same config file to define everything providers: file: filename: /etc/traefik/traefik.yml From 36412fd2e08a552ed4a604a9da6f4428e62b944b Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 11 Dec 2024 15:01:05 +0100 Subject: [PATCH 06/44] progress Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/models.py | 4 ++++ authentik/enterprise/providers/ssf/signals.py | 12 ++++------ authentik/enterprise/providers/ssf/tasks.py | 22 ++++++++++++------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index 97f1cda078ea..501f158d7ce3 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -116,3 +116,7 @@ class UserStreamSubject(models.Model): def __str__(self) -> str: return f"Stream subject {self.stream_id} to {self.user_id}" + +class StreamEvent(models.Model): + + uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 419cb60843e0..5132f7984cc7 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -15,12 +15,11 @@ UserTypes, ) from authentik.enterprise.providers.ssf.models import ( - DeliveryMethods, EventTypes, SSFProvider, Stream, ) -from authentik.enterprise.providers.ssf.tasks import send_ssf_event, ssf_push_request +from authentik.enterprise.providers.ssf.tasks import send_single_ssf_event, send_ssf_event from authentik.events.middleware import audit_ignore from authentik.events.utils import get_user @@ -56,19 +55,16 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: @receiver(post_save, sender=Stream) def ssf_stream_post_create(sender: type[Model], instance: Stream, created: bool, **_): - """Send a verification event when a push stream is created""" + """Send a verification event when a stream is created""" if not created: return - if instance.delivery_method != DeliveryMethods.RISC_PUSH: - return - ssf_push_request.delay( + send_single_ssf_event.delay( str(instance.uuid), - instance.endpoint_url, { "jti": uuid4().hex, # TODO: Figure out how to get iss "iss": "https://ak.beryju.dev/.well-known/ssf-configuration/abm-ssf/8", - "aud": instance.aud[0], + "aud": instance.aud, "iat": int(datetime.now().timestamp()), "sub_id": {"format": "opaque", "id": str(instance.uuid)}, "events": { diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 4f31ca5a012d..07cdb4c06255 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -9,24 +9,30 @@ @CELERY_APP.task(bind=True) -def send_ssf_event(event_type: EventTypes, subject): +def send_ssf_event(event_type: EventTypes, data: dict): tasks = [] - for stream in Stream.objects.filter( - delivery_method=DeliveryMethods.RISC_PUSH, - events_requested__in=[event_type], - ): - tasks.append(ssf_push_request.si(str(stream.uuid), stream.endpoint_url, {})) + for stream in Stream.objects.filter(events_requested__in=[event_type]): + tasks.append(send_single_ssf_event.si(str(stream.uuid), data)) main_task = group(*tasks) main_task() @CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) -def ssf_push_request(self, stream_id: str, endpoint_url: str, data: dict): +def send_single_ssf_event(self, stream_id: str, data: dict): + stream = Stream.objects.filter(pk=stream_id).first() + if not stream: + return + if stream.delivery_method == DeliveryMethods.RISC_PUSH: + ssf_push_request.delay(stream_id, data) + + +@CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) +def ssf_push_request(self, stream_id: str, data: dict): stream = Stream.objects.filter(pk=stream_id).first() if not stream: return response = session.post( - endpoint_url, + stream.endpoint_url, data=stream.encode(data), headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"}, ) From e219dd7496fc549aa4f69d0de2f5e031254a5072 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 11 Dec 2024 17:32:17 +0100 Subject: [PATCH 07/44] more progress Signed-off-by: Jens Langhammer --- authentik/blueprints/v1/importer.py | 2 + ...ter_stream_events_requested_streamevent.py | 79 +++++++++++++++++++ authentik/enterprise/providers/ssf/models.py | 53 +++++++++++++ authentik/enterprise/providers/ssf/signals.py | 47 +++++------ authentik/enterprise/providers/ssf/tasks.py | 55 ++++++++++--- .../enterprise/providers/ssf/views/stream.py | 13 ++- .../providers/ssf/SSFProviderViewPage.ts | 4 +- 7 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 67a829dd8808..a86e62c4a8d8 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -51,6 +51,7 @@ MicrosoftEntraProviderUser, ) from authentik.enterprise.providers.rac.models import ConnectionToken +from authentik.enterprise.providers.ssf.models import StreamEvent from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( EndpointDevice, EndpointDeviceConnection, @@ -131,6 +132,7 @@ def excluded_models() -> list[type[Model]]: EndpointDevice, EndpointDeviceConnection, DeviceToken, + StreamEvent, ) diff --git a/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py b/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py new file mode 100644 index 000000000000..83e25c286788 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py @@ -0,0 +1,79 @@ +# Generated by Django 5.0.10 on 2024-12-11 18:33 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_ssf", "0004_stream_aud_alter_stream_events_requested"), + ] + + operations = [ + migrations.AlterField( + model_name="stream", + name="events_requested", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + "Caep Session Revoked", + ), + ( + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "Caep Credential Change", + ), + ( + "https://schemas.openid.net/secevent/ssf/event-type/verification", + "Set Verification", + ), + ] + ), + default=list, + size=None, + ), + ), + migrations.CreateModel( + name="StreamEvent", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("status", models.TextField(choices=[("pending", "Pending"), ("sent", "Sent")])), + ( + "type", + models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + "Caep Session Revoked", + ), + ( + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "Caep Credential Change", + ), + ( + "https://schemas.openid.net/secevent/ssf/event-type/verification", + "Set Verification", + ), + ] + ), + ), + ("payload", models.JSONField(default=dict)), + ( + "stream", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_ssf.stream", + ), + ), + ], + ), + ] diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index 501f158d7ce3..dc7c7f16a710 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import cached_property from uuid import uuid4 @@ -6,7 +7,9 @@ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from django.contrib.postgres.fields import ArrayField 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 jwt import encode @@ -30,6 +33,13 @@ class DeliveryMethods(models.TextChoices): RISC_POLL = "https://schemas.openid.net/secevent/risc/delivery-method/poll" +class SSFEventStatus(models.TextChoices): + """SSF Event status""" + + PENDING = "pending" + SENT = "sent" + + class SSFProvider(BackchannelProvider): """Shared Signals Framework""" @@ -102,6 +112,32 @@ class Stream(models.Model): def __str__(self) -> str: return "SSF Stream" + def prepare_event_payload( + self, type: EventTypes, request: HttpRequest, event_data: dict, **kwargs + ) -> dict: + jti = uuid4() + return { + "uuid": jti, + "stream": self, + "type": type, + "payload": { + "jti": jti.hex, + "aud": self.aud, + "iat": int(datetime.now().timestamp()), + "iss": request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": self.provider.application.slug, + "provider": self.provider.pk, + }, + ) + ), + "events": {type: event_data}, + **kwargs, + }, + } + def encode(self, data: dict) -> str: headers = {} if self.provider.signing_key: @@ -117,6 +153,23 @@ class UserStreamSubject(models.Model): def __str__(self) -> str: return f"Stream subject {self.stream_id} to {self.user_id}" + class StreamEvent(models.Model): + """Single stream event to be sent""" uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False) + + stream = models.ForeignKey(Stream, on_delete=models.CASCADE) + status = models.TextField(choices=SSFEventStatus.choices) + + type = models.TextField(choices=EventTypes.choices) + payload = models.JSONField(default=dict) + + def __str__(self): + return f"Stream event {self.type}" + + def queue(self): + """Queue event to be sent""" + from authentik.enterprise.providers.ssf.tasks import send_single_ssf_event + + return send_single_ssf_event.delay(str(self.stream.uuid), str(self.uuid)) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 5132f7984cc7..a2602c3fa02a 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -1,5 +1,4 @@ -from datetime import datetime -from uuid import uuid4 +from hashlib import sha256 from django.contrib.auth.signals import user_logged_out from django.db.models import Model @@ -17,11 +16,9 @@ from authentik.enterprise.providers.ssf.models import ( EventTypes, SSFProvider, - Stream, ) -from authentik.enterprise.providers.ssf.tasks import send_single_ssf_event, send_ssf_event +from authentik.enterprise.providers.ssf.tasks import send_ssf_event from authentik.events.middleware import audit_ignore -from authentik.events.utils import get_user USER_PATH_PROVIDERS_SSF = USER_PATH_SYSTEM_PREFIX + "/providers/ssf" @@ -53,32 +50,24 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: instance.save() -@receiver(post_save, sender=Stream) -def ssf_stream_post_create(sender: type[Model], instance: Stream, created: bool, **_): - """Send a verification event when a stream is created""" - if not created: +@receiver(user_logged_out) +def user_logged_out_session(sender, request: HttpRequest, user: User, **_): + if not request.session or not request.session.session_key: return - send_single_ssf_event.delay( - str(instance.uuid), + send_ssf_event( + EventTypes.CAEP_SESSION_REVOKED, + request, { - "jti": uuid4().hex, - # TODO: Figure out how to get iss - "iss": "https://ak.beryju.dev/.well-known/ssf-configuration/abm-ssf/8", - "aud": instance.aud, - "iat": int(datetime.now().timestamp()), - "sub_id": {"format": "opaque", "id": str(instance.uuid)}, - "events": { - "https://schemas.openid.net/secevent/ssf/event-type/verification": { - "state": None, - } + "subject": { + "session": { + "format": "opaque", + "id": sha256(request.session.session_key.encode("ascii")).hexdigest(), + }, + "user": { + "format": "email", + "email": user.email, + }, }, + "initiating_entity": "user", }, ) - - -@receiver(user_logged_out) -def user_logged_out_session(sender, request: HttpRequest, user: User, **_): - send_ssf_event.delay( - EventTypes.SET_VERIFICATION, - subject=get_user(user), - ) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 07cdb4c06255..53c8a92bf2a7 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -1,39 +1,68 @@ from celery import group +from django.http import HttpRequest from requests.exceptions import RequestException -from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes, Stream +from authentik.enterprise.providers.ssf.models import ( + DeliveryMethods, + EventTypes, + SSFEventStatus, + Stream, + StreamEvent, +) from authentik.lib.utils.http import get_http_session from authentik.root.celery import CELERY_APP session = get_http_session() +def send_ssf_event( + event_type: EventTypes, + request: HttpRequest, + data: dict, + stream_filter: dict | None = None, + **extra_data, +): + """Wrapper to send an SSF event to multiple streams""" + payload = [] + if not stream_filter: + stream_filter = {} + stream_filter["events_requested__in"] = [event_type] + for stream in Stream.objects.filter(**stream_filter): + event_data = stream.prepare_event_payload(event_type, request, data, **extra_data) + payload.append((str(stream.uuid), event_data)) + return _send_ssf_event.delay(payload) + + @CELERY_APP.task(bind=True) -def send_ssf_event(event_type: EventTypes, data: dict): +def _send_ssf_event(event_data: list[tuple[str, dict]]): tasks = [] - for stream in Stream.objects.filter(events_requested__in=[event_type]): - tasks.append(send_single_ssf_event.si(str(stream.uuid), data)) + for stream, data in event_data: + event = StreamEvent.objects.create(**data) + tasks.append(send_single_ssf_event.si(str(stream.uuid), str(event.id))) main_task = group(*tasks) main_task() @CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) -def send_single_ssf_event(self, stream_id: str, data: dict): +def send_single_ssf_event(self, stream_id: str, evt_id: str): stream = Stream.objects.filter(pk=stream_id).first() if not stream: return + event = StreamEvent.objects.filter(pk=evt_id).first() + if not event: + return + if event.status == SSFEventStatus.SENT: + return if stream.delivery_method == DeliveryMethods.RISC_PUSH: - ssf_push_request.delay(stream_id, data) + ssf_push_request(event) + event.status = SSFEventStatus.SENT + event.save() -@CELERY_APP.task(bind=True, autoretry=True, autoretry_for=(RequestException,), retry_backoff=True) -def ssf_push_request(self, stream_id: str, data: dict): - stream = Stream.objects.filter(pk=stream_id).first() - if not stream: - return +def ssf_push_request(event: StreamEvent): response = session.post( - stream.endpoint_url, - data=stream.encode(data), + event.stream.endpoint_url, + data=event.stream.encode(event.payload), headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"}, ) response.raise_for_status() diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index c5aef348af1a..c54613be879f 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -7,6 +7,7 @@ from authentik.core.api.utils import PassiveSerializer from authentik.enterprise.providers.ssf.models import DeliveryMethods, EventTypes, Stream +from authentik.enterprise.providers.ssf.tasks import send_ssf_event from authentik.enterprise.providers.ssf.views.base import SSFView LOGGER = get_logger() @@ -18,7 +19,6 @@ class StreamDeliverySerializer(PassiveSerializer): class StreamSerializer(ModelSerializer): - delivery = StreamDeliverySerializer() events_requested = ListField( child=ChoiceField(choices=[(x.value, x.value) for x in EventTypes]) @@ -49,7 +49,6 @@ class Meta: class StreamResponseSerializer(PassiveSerializer): - stream_id = CharField(source="pk") iss = SerializerMethodField() aud = ListField(child=CharField()) @@ -88,7 +87,15 @@ class StreamView(SSFView): def post(self, request: Request, *args, **kwargs) -> Response: stream = StreamSerializer(data=request.data) stream.is_valid(raise_exception=True) - instance = stream.save(provider=self.provider) + instance: Stream = stream.save(provider=self.provider) + send_ssf_event( + EventTypes.SET_VERIFICATION, + request, + { + "state": None, + }, + sub_id={"format": "opaque", "id": str(instance.uuid)}, + ) response = StreamResponseSerializer(instance=instance, context={"request": request}).data return Response(response, status=201) diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts index cdba7ba2926b..901b9439f539 100644 --- a/web/src/admin/providers/ssf/SSFProviderViewPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -130,7 +130,9 @@ export class SSFProviderViewPage extends AKElement { ${msg("URL")}
-
${this.provider.ssfUrl}
+
+ ${this.provider.ssfUrl} +
From 70a972de94dae08fc43f174958360fd9547276eb Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 31 Dec 2024 14:27:22 +0100 Subject: [PATCH 08/44] fix Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/signals.py | 2 +- authentik/enterprise/providers/ssf/tasks.py | 2 +- authentik/enterprise/providers/ssf/tests/test_stream.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index a2602c3fa02a..4657ddd39c95 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -52,7 +52,7 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: @receiver(user_logged_out) def user_logged_out_session(sender, request: HttpRequest, user: User, **_): - if not request.session or not request.session.session_key: + if not request.session or not request.session.session_key or not user: return send_ssf_event( EventTypes.CAEP_SESSION_REVOKED, diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 53c8a92bf2a7..b6f2fc5596ca 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -33,7 +33,7 @@ def send_ssf_event( return _send_ssf_event.delay(payload) -@CELERY_APP.task(bind=True) +@CELERY_APP.task() def _send_ssf_event(event_data: list[tuple[str, dict]]): tasks = [] for stream, data in event_data: diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py index d2b8956a4eea..b605f49fe9c6 100644 --- a/authentik/enterprise/providers/ssf/tests/test_stream.py +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -43,4 +43,4 @@ def test_stream_add(self): ) print(res) print(res.content) - self.assertEqual(res.status_code, 200) + self.assertEqual(res.status_code, 201) From 096792405a0da71c7e125854b55fa71c7fcb4418 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 31 Dec 2024 14:56:11 +0100 Subject: [PATCH 09/44] save iss Signed-off-by: Jens Langhammer --- .../providers/ssf/migrations/0001_initial.py | 68 +++++++++++++++- .../0002_ssfprovider_oidc_auth_providers.py | 21 ----- .../ssf/migrations/0003_stream_format.py | 19 ----- ...tream_aud_alter_stream_events_requested.py | 41 ---------- ...ter_stream_events_requested_streamevent.py | 79 ------------------- authentik/enterprise/providers/ssf/models.py | 18 +---- authentik/enterprise/providers/ssf/signals.py | 21 ++++- authentik/enterprise/providers/ssf/tasks.py | 4 +- .../enterprise/providers/ssf/views/stream.py | 28 +++---- 9 files changed, 101 insertions(+), 198 deletions(-) delete mode 100644 authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py delete mode 100644 authentik/enterprise/providers/ssf/migrations/0003_stream_format.py delete mode 100644 authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py delete mode 100644 authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py diff --git a/authentik/enterprise/providers/ssf/migrations/0001_initial.py b/authentik/enterprise/providers/ssf/migrations/0001_initial.py index 8b2d09d5a105..7c1066724a4e 100644 --- a/authentik/enterprise/providers/ssf/migrations/0001_initial.py +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.9 on 2024-09-30 17:22 +# Generated by Django 5.0.10 on 2024-12-31 13:55 import django.contrib.postgres.fields import django.db.models.deletion @@ -12,8 +12,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"), + ("authentik_core", "0041_applicationentitlement"), ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ("authentik_providers_oauth2", "0026_alter_accesstoken_session_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -32,6 +33,12 @@ class Migration(migrations.Migration): to="authentik_core.provider", ), ), + ( + "oidc_auth_providers", + models.ManyToManyField( + blank=True, default=None, to="authentik_providers_oauth2.oauth2provider" + ), + ), ( "signing_key", models.ForeignKey( @@ -91,13 +98,29 @@ class Migration(migrations.Migration): ( "https://schemas.openid.net/secevent/caep/event-type/session-revoked", "Caep Session Revoked", - ) + ), + ( + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "Caep Credential Change", + ), + ( + "https://schemas.openid.net/secevent/ssf/event-type/verification", + "Set Verification", + ), ] ), default=list, size=None, ), ), + ("format", models.TextField()), + ( + "aud", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=list, size=None + ), + ), + ("iss", models.TextField()), ( "provider", models.ForeignKey( @@ -113,6 +136,45 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="StreamEvent", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("status", models.TextField(choices=[("pending", "Pending"), ("sent", "Sent")])), + ( + "type", + models.TextField( + choices=[ + ( + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + "Caep Session Revoked", + ), + ( + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "Caep Credential Change", + ), + ( + "https://schemas.openid.net/secevent/ssf/event-type/verification", + "Set Verification", + ), + ] + ), + ), + ("payload", models.JSONField(default=dict)), + ( + "stream", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_ssf.stream", + ), + ), + ], + ), migrations.CreateModel( name="UserStreamSubject", fields=[ diff --git a/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py b/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py deleted file mode 100644 index a5b665e91524..000000000000 --- a/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_oidc_auth_providers.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.0.9 on 2024-11-07 13:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"), - ("authentik_providers_ssf", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="ssfprovider", - name="oidc_auth_providers", - field=models.ManyToManyField( - blank=True, default=None, to="authentik_providers_oauth2.oauth2provider" - ), - ), - ] diff --git a/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py b/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py deleted file mode 100644 index 130a975efd1f..000000000000 --- a/authentik/enterprise/providers/ssf/migrations/0003_stream_format.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.9 on 2024-11-07 14:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_providers_ssf", "0002_ssfprovider_oidc_auth_providers"), - ] - - operations = [ - migrations.AddField( - model_name="stream", - name="format", - field=models.TextField(default=""), - preserve_default=False, - ), - ] diff --git a/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py b/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py deleted file mode 100644 index 3734fa9ad5d3..000000000000 --- a/authentik/enterprise/providers/ssf/migrations/0004_stream_aud_alter_stream_events_requested.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.0.9 on 2024-11-07 15:10 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_providers_ssf", "0003_stream_format"), - ] - - operations = [ - migrations.AddField( - model_name="stream", - name="aud", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), default=list, size=None - ), - ), - migrations.AlterField( - model_name="stream", - name="events_requested", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.TextField( - choices=[ - ( - "https://schemas.openid.net/secevent/caep/event-type/session-revoked", - "Caep Session Revoked", - ), - ( - "https://schemas.openid.net/secevent/caep/event-type/credential-change", - "Caep Credential Change", - ), - ] - ), - default=list, - size=None, - ), - ), - ] diff --git a/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py b/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py deleted file mode 100644 index 83e25c286788..000000000000 --- a/authentik/enterprise/providers/ssf/migrations/0005_alter_stream_events_requested_streamevent.py +++ /dev/null @@ -1,79 +0,0 @@ -# Generated by Django 5.0.10 on 2024-12-11 18:33 - -import django.contrib.postgres.fields -import django.db.models.deletion -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_providers_ssf", "0004_stream_aud_alter_stream_events_requested"), - ] - - operations = [ - migrations.AlterField( - model_name="stream", - name="events_requested", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.TextField( - choices=[ - ( - "https://schemas.openid.net/secevent/caep/event-type/session-revoked", - "Caep Session Revoked", - ), - ( - "https://schemas.openid.net/secevent/caep/event-type/credential-change", - "Caep Credential Change", - ), - ( - "https://schemas.openid.net/secevent/ssf/event-type/verification", - "Set Verification", - ), - ] - ), - default=list, - size=None, - ), - ), - migrations.CreateModel( - name="StreamEvent", - fields=[ - ( - "uuid", - models.UUIDField( - default=uuid.uuid4, editable=False, primary_key=True, serialize=False - ), - ), - ("status", models.TextField(choices=[("pending", "Pending"), ("sent", "Sent")])), - ( - "type", - models.TextField( - choices=[ - ( - "https://schemas.openid.net/secevent/caep/event-type/session-revoked", - "Caep Session Revoked", - ), - ( - "https://schemas.openid.net/secevent/caep/event-type/credential-change", - "Caep Credential Change", - ), - ( - "https://schemas.openid.net/secevent/ssf/event-type/verification", - "Set Verification", - ), - ] - ), - ), - ("payload", models.JSONField(default=dict)), - ( - "stream", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="authentik_providers_ssf.stream", - ), - ), - ], - ), - ] diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index dc7c7f16a710..281691cc4cdc 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -7,9 +7,7 @@ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from django.contrib.postgres.fields import ArrayField 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 jwt import encode @@ -109,12 +107,12 @@ class Stream(models.Model): user_subjects = models.ManyToManyField(User, "UserStreamSubject") + iss = models.TextField() + def __str__(self) -> str: return "SSF Stream" - def prepare_event_payload( - self, type: EventTypes, request: HttpRequest, event_data: dict, **kwargs - ) -> dict: + def prepare_event_payload(self, type: EventTypes, event_data: dict, **kwargs) -> dict: jti = uuid4() return { "uuid": jti, @@ -124,15 +122,7 @@ def prepare_event_payload( "jti": jti.hex, "aud": self.aud, "iat": int(datetime.now().timestamp()), - "iss": request.build_absolute_uri( - reverse( - "authentik_providers_ssf:configuration", - kwargs={ - "application_slug": self.provider.application.slug, - "provider": self.provider.pk, - }, - ) - ), + "iss": self.iss, "events": {type: event_data}, **kwargs, }, diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 4657ddd39c95..b4a746503d32 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -13,6 +13,7 @@ User, UserTypes, ) +from authentik.core.signals import password_changed from authentik.enterprise.providers.ssf.models import ( EventTypes, SSFProvider, @@ -51,12 +52,11 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: @receiver(user_logged_out) -def user_logged_out_session(sender, request: HttpRequest, user: User, **_): +def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_): if not request.session or not request.session.session_key or not user: return send_ssf_event( EventTypes.CAEP_SESSION_REVOKED, - request, { "subject": { "session": { @@ -71,3 +71,20 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_): "initiating_entity": "user", }, ) + + +@receiver(password_changed) +def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_): + send_ssf_event( + EventTypes.CAEP_CREDENTIAL_CHANGE, + { + "subject": { + "user": { + "format": "email", + "email": user.email, + }, + }, + "credential_type": "password", + "change_type": "revoke" if password is None else "update", + }, + ) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index b6f2fc5596ca..e38d5d12e75b 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -1,5 +1,4 @@ from celery import group -from django.http import HttpRequest from requests.exceptions import RequestException from authentik.enterprise.providers.ssf.models import ( @@ -17,7 +16,6 @@ def send_ssf_event( event_type: EventTypes, - request: HttpRequest, data: dict, stream_filter: dict | None = None, **extra_data, @@ -28,7 +26,7 @@ def send_ssf_event( stream_filter = {} stream_filter["events_requested__in"] = [event_type] for stream in Stream.objects.filter(**stream_filter): - event_data = stream.prepare_event_payload(event_type, request, data, **extra_data) + event_data = stream.prepare_event_payload(event_type, data, **extra_data) payload.append((str(stream.uuid), event_data)) return _send_ssf_event.delay(payload) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index c54613be879f..274c4cf94966 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -27,6 +27,15 @@ class StreamSerializer(ModelSerializer): aud = ListField(child=CharField()) def create(self, validated_data): + iss = self._context["request"].build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": self.provider.application.slug, + "provider": self.provider.pk, + }, + ) + ) return super().create( { "delivery_method": validated_data["delivery"]["method"], @@ -35,6 +44,7 @@ def create(self, validated_data): "provider": validated_data["provider"], "events_requested": validated_data["events_requested"], "aud": validated_data["aud"], + "iss": iss, } ) @@ -50,7 +60,7 @@ class Meta: class StreamResponseSerializer(PassiveSerializer): stream_id = CharField(source="pk") - iss = SerializerMethodField() + iss = CharField() aud = ListField(child=CharField()) delivery = SerializerMethodField() format = CharField() @@ -59,20 +69,6 @@ class StreamResponseSerializer(PassiveSerializer): events_supported = SerializerMethodField() events_delivered = ListField(child=CharField(), source="events_requested") - def get_iss(self, instance: Stream) -> str: - request: Request = self._context["request"] - if not instance.provider.application: - return None - return request.build_absolute_uri( - reverse( - "authentik_providers_ssf:configuration", - kwargs={ - "application_slug": instance.provider.application.slug, - "provider": instance.provider.pk, - }, - ) - ) - def get_delivery(self, instance: Stream) -> StreamDeliverySerializer: return { "method": instance.delivery_method, @@ -90,10 +86,10 @@ def post(self, request: Request, *args, **kwargs) -> Response: instance: Stream = stream.save(provider=self.provider) send_ssf_event( EventTypes.SET_VERIFICATION, - request, { "state": None, }, + stream_filter={"pk": instance.uuid}, sub_id={"format": "opaque", "id": str(instance.uuid)}, ) response = StreamResponseSerializer(instance=instance, context={"request": request}).data From cb6b4987890caf989d6f114f3122962dddf63295 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 31 Dec 2024 16:48:02 +0100 Subject: [PATCH 10/44] add signals for MFA devices Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/signals.py | 64 ++++++++++++++++++- blueprints/schema.json | 8 +++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index b4a746503d32..84763130f9c5 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -2,7 +2,7 @@ from django.contrib.auth.signals import user_logged_out from django.db.models import Model -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.http.request import HttpRequest @@ -20,6 +20,14 @@ ) from authentik.enterprise.providers.ssf.tasks import send_ssf_event from authentik.events.middleware import audit_ignore +from authentik.stages.authenticator.models import Device +from authentik.stages.authenticator_duo.models import DuoDevice +from authentik.stages.authenticator_static.models import StaticDevice +from authentik.stages.authenticator_totp.models import TOTPDevice +from authentik.stages.authenticator_webauthn.models import ( + UNKNOWN_DEVICE_TYPE_AAGUID, + WebAuthnDevice, +) USER_PATH_PROVIDERS_SSF = USER_PATH_SYSTEM_PREFIX + "/providers/ssf" @@ -88,3 +96,57 @@ def ssf_password_changed_cred_change(sender, user: User, password: str | None, * "change_type": "revoke" if password is None else "update", }, ) + + +device_type_map = { + StaticDevice: "pin", + TOTPDevice: "pin", + WebAuthnDevice: "fido-u2f", + DuoDevice: "app", +} + + +@receiver(post_save) +def ssf_device_post_save(sender: type[Model], instance: Device, created: bool, **_): + if not isinstance(instance, Device): + return + if not instance.confirmed: + return + device_type = device_type_map.get(instance.__class__) + data = { + "subject": { + "user": { + "format": "email", + "email": instance.user.email, + }, + }, + "credential_type": device_type, + "change_type": "create" if created else "update", + "friendly_name": instance.name, + } + if isinstance(instance, WebAuthnDevice) and instance.aaguid != UNKNOWN_DEVICE_TYPE_AAGUID: + data["fido2_aaguid"] = instance.aaguid + send_ssf_event(EventTypes.CAEP_CREDENTIAL_CHANGE, data) + + +@receiver(post_delete) +def ssf_device_post_delete(sender: type[Model], instance: Device, created: bool, **_): + if not isinstance(instance, Device): + return + if not instance.confirmed: + return + device_type = device_type_map.get(instance.__class__) + data = { + "subject": { + "user": { + "format": "email", + "email": instance.user.email, + }, + }, + "credential_type": device_type, + "change_type": "delete", + "friendly_name": instance.name, + } + if isinstance(instance, WebAuthnDevice) and instance.aaguid != UNKNOWN_DEVICE_TYPE_AAGUID: + data["fido2_aaguid"] = instance.aaguid + send_ssf_event(EventTypes.CAEP_CREDENTIAL_CHANGE, data) diff --git a/blueprints/schema.json b/blueprints/schema.json index ab140d92cd8f..26d7c32e3d3a 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6731,15 +6731,19 @@ "authentik_providers_scim.view_scimprovideruser", "authentik_providers_ssf.add_ssfprovider", "authentik_providers_ssf.add_stream", + "authentik_providers_ssf.add_streamevent", "authentik_providers_ssf.add_userstreamsubject", "authentik_providers_ssf.change_ssfprovider", "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_streamevent", "authentik_providers_ssf.change_userstreamsubject", "authentik_providers_ssf.delete_ssfprovider", "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_streamevent", "authentik_providers_ssf.delete_userstreamsubject", "authentik_providers_ssf.view_ssfprovider", "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_streamevent", "authentik_providers_ssf.view_userstreamsubject", "authentik_rbac.access_admin_interface", "authentik_rbac.add_role", @@ -12992,15 +12996,19 @@ "authentik_providers_scim.view_scimprovideruser", "authentik_providers_ssf.add_ssfprovider", "authentik_providers_ssf.add_stream", + "authentik_providers_ssf.add_streamevent", "authentik_providers_ssf.add_userstreamsubject", "authentik_providers_ssf.change_ssfprovider", "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_streamevent", "authentik_providers_ssf.change_userstreamsubject", "authentik_providers_ssf.delete_ssfprovider", "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_streamevent", "authentik_providers_ssf.delete_userstreamsubject", "authentik_providers_ssf.view_ssfprovider", "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_streamevent", "authentik_providers_ssf.view_userstreamsubject", "authentik_rbac.access_admin_interface", "authentik_rbac.add_role", From 5d82c5189d0beb1e1b00af1dc8bc82ad6835d66a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 31 Dec 2024 17:18:55 +0100 Subject: [PATCH 11/44] fix tests Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/models.py | 6 ------ authentik/enterprise/providers/ssf/signals.py | 2 +- .../providers/ssf/tests/test_stream.py | 17 ++++++++++++++--- .../enterprise/providers/ssf/views/stream.py | 8 ++++---- authentik/lib/config.py | 4 +++- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index 281691cc4cdc..179c895d9bb2 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -157,9 +157,3 @@ class StreamEvent(models.Model): def __str__(self): return f"Stream event {self.type}" - - def queue(self): - """Queue event to be sent""" - from authentik.enterprise.providers.ssf.tasks import send_single_ssf_event - - return send_single_ssf_event.delay(str(self.stream.uuid), str(self.uuid)) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 84763130f9c5..907deea20683 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -130,7 +130,7 @@ def ssf_device_post_save(sender: type[Model], instance: Device, created: bool, * @receiver(post_delete) -def ssf_device_post_delete(sender: type[Model], instance: Device, created: bool, **_): +def ssf_device_post_delete(sender: type[Model], instance: Device, **_): if not isinstance(instance, Device): return if not instance.confirmed: diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py index b605f49fe9c6..1c4e5aad5b77 100644 --- a/authentik/enterprise/providers/ssf/tests/test_stream.py +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -3,7 +3,7 @@ from authentik.core.models import Application from authentik.core.tests.utils import create_test_cert -from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.enterprise.providers.ssf.models import SSFProvider, Stream from authentik.lib.generators import generate_id @@ -41,6 +41,17 @@ def test_stream_add(self): }, HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", ) - print(res) - print(res.content) self.assertEqual(res.status_code, 201) + + def test_stream_delete(self): + """delete stream""" + stream = Stream.objects.create(provider=self.provider) + res = self.client.delete( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug, "provider": self.provider.pk}, + ), + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 204) + self.assertFalse(Stream.objects.filter(pk=stream.pk).exists()) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 274c4cf94966..960a5474de2a 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -27,12 +27,12 @@ class StreamSerializer(ModelSerializer): aud = ListField(child=CharField()) def create(self, validated_data): - iss = self._context["request"].build_absolute_uri( + iss = self.context["request"].build_absolute_uri( reverse( "authentik_providers_ssf:configuration", kwargs={ - "application_slug": self.provider.application.slug, - "provider": self.provider.pk, + "application_slug": validated_data["provider"].application.slug, + "provider": validated_data["provider"].pk, }, ) ) @@ -81,7 +81,7 @@ def get_events_supported(self, instance: Stream) -> list[str]: class StreamView(SSFView): def post(self, request: Request, *args, **kwargs) -> Response: - stream = StreamSerializer(data=request.data) + stream = StreamSerializer(data=request.data, context={"request": request}) stream.is_valid(raise_exception=True) instance: Stream = stream.save(provider=self.provider) send_ssf_event( diff --git a/authentik/lib/config.py b/authentik/lib/config.py index a609633bd5bb..88e9f0975253 100644 --- a/authentik/lib/config.py +++ b/authentik/lib/config.py @@ -289,7 +289,9 @@ def get_optional_int(self, path: str, default=None) -> int | None: return int(value) except (ValueError, TypeError) as exc: if value is None or (isinstance(value, str) and value.lower() == "null"): - return None + return default + if value is UNSET: + return default self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) return default From 3dc40cfbdfafde59abb41f37e3bd5118e1ae0349 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 21 Jan 2025 02:38:43 +0100 Subject: [PATCH 12/44] refactor more Signed-off-by: Jens Langhammer --- authentik/core/models.py | 8 ++++++++ .../enterprise/providers/ssf/api/providers.py | 5 ++--- .../providers/ssf/migrations/0001_initial.py | 6 +++--- authentik/enterprise/providers/ssf/urls.py | 6 +++--- authentik/enterprise/providers/ssf/views/auth.py | 14 ++++++++++---- .../providers/ssf/views/configuration.py | 10 +++++----- authentik/enterprise/providers/ssf/views/jwks.py | 10 +++++++--- authentik/enterprise/providers/ssf/views/stream.py | 1 - web/src/admin/providers/ssf/SSFProviderViewPage.ts | 13 ------------- 9 files changed, 38 insertions(+), 35 deletions(-) diff --git a/authentik/core/models.py b/authentik/core/models.py index 1126ab248167..3d09ca2f83a8 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -599,6 +599,14 @@ def get_provider(self) -> Provider | None: return None return candidates[-1] + def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None: + """Get Backchannel provider for a specific type""" + providers = self.backchannel_providers.objects.filter( + **{f"{provider_type._meta.model_name}__isnull": False}, + **kwargs, + ) + return providers.first() + def __str__(self): return str(self.name) diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py index 3cf69b163cf8..96eab2ed31e8 100644 --- a/authentik/enterprise/providers/ssf/api/providers.py +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -20,14 +20,13 @@ class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): def get_ssf_url(self, instance: SSFProvider) -> str | None: request: Request = self._context["request"] - if not instance.application: + if not instance.backchannel_application: return None return request.build_absolute_uri( reverse( "authentik_providers_ssf:configuration", kwargs={ - "application_slug": instance.application.slug, - "provider": instance.pk, + "application_slug": instance.backchannel_application.slug, }, ) ) diff --git a/authentik/enterprise/providers/ssf/migrations/0001_initial.py b/authentik/enterprise/providers/ssf/migrations/0001_initial.py index 7c1066724a4e..1be1ac1879d8 100644 --- a/authentik/enterprise/providers/ssf/migrations/0001_initial.py +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.10 on 2024-12-31 13:55 +# Generated by Django 5.0.11 on 2025-01-21 01:46 import django.contrib.postgres.fields import django.db.models.deletion @@ -12,9 +12,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("authentik_core", "0041_applicationentitlement"), + ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"), ("authentik_crypto", "0004_alter_certificatekeypair_name"), - ("authentik_providers_oauth2", "0026_alter_accesstoken_session_and_more"), + ("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/authentik/enterprise/providers/ssf/urls.py b/authentik/enterprise/providers/ssf/urls.py index 7c2d0b0afccc..1b1e4993f11d 100644 --- a/authentik/enterprise/providers/ssf/urls.py +++ b/authentik/enterprise/providers/ssf/urls.py @@ -9,17 +9,17 @@ urlpatterns = [ path( - "application/ssf//ssf-jwks/", + "application/ssf//ssf-jwks/", JWKSview.as_view(), name="jwks", ), path( - ".well-known/ssf-configuration//", + ".well-known/ssf-configuration/", ConfigurationView.as_view(), name="configuration", ), path( - "application/ssf///stream/", + "application/ssf//stream/", StreamView.as_view(), name="stream", ), diff --git a/authentik/enterprise/providers/ssf/views/auth.py b/authentik/enterprise/providers/ssf/views/auth.py index 97b735e42e82..4390bd2af0fd 100644 --- a/authentik/enterprise/providers/ssf/views/auth.py +++ b/authentik/enterprise/providers/ssf/views/auth.py @@ -1,6 +1,6 @@ """SSF Token auth""" -from typing import Any +from typing import Any, TYPE_CHECKING from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request @@ -11,15 +11,21 @@ from authentik.providers.oauth2.models import AccessToken +if TYPE_CHECKING: + from authentik.enterprise.providers.ssf.views.base import SSFView + + class SSFTokenAuth(BaseAuthentication): """SCIM Token auth""" - def __init__(self, view: APIView) -> None: + view: "SSFView" + + def __init__(self, view: "SSFView") -> None: super().__init__() self.view = view def check_token(self, key: str) -> Token | None: - """Check that a token exists, is not expired, and is assigned to the correct source""" + """Check that a token exists, is not expired, and is assigned to the correct provider""" token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first() if not token: return None @@ -39,7 +45,7 @@ def check_jwt(self, jwt: str, provider_pk: int) -> AccessToken | None: ).first() if not ssf_provider: return None - self.view.application = ssf_provider.application + self.view.application = ssf_provider.backchannel_application self.view.provider = ssf_provider return token diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py index 5a91fe00e8ce..6f13e9ac0fff 100644 --- a/authentik/enterprise/providers/ssf/views/configuration.py +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -1,4 +1,4 @@ -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.urls import reverse from rest_framework.permissions import AllowAny @@ -14,11 +14,11 @@ class ConfigurationView(SSFView): def get_authenticators(self): return [] - def get( - self, request: HttpRequest, application_slug: str, provider: int, *args, **kwargs - ) -> HttpResponse: + def get(self, request: HttpRequest, application_slug: str, *args, **kwargs) -> HttpResponse: application = get_object_or_404(Application, slug=application_slug) - provider = get_object_or_404(SSFProvider, pk=provider) + provider = application.backchannel_provider_for(SSFProvider) + if not provider: + raise Http404 data = { "spec_version": "1_0-ID2", "issuer": self.request.build_absolute_uri( diff --git a/authentik/enterprise/providers/ssf/views/jwks.py b/authentik/enterprise/providers/ssf/views/jwks.py index 696447d7bafb..f4e995000b1f 100644 --- a/authentik/enterprise/providers/ssf/views/jwks.py +++ b/authentik/enterprise/providers/ssf/views/jwks.py @@ -1,7 +1,8 @@ -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.views import View +from authentik.core.models import Application from authentik.crypto.models import CertificateKeyPair from authentik.enterprise.providers.ssf.models import SSFProvider from authentik.providers.oauth2.views.jwks import JWKSView as OAuthJWKSView @@ -9,9 +10,12 @@ class JWKSview(View): - def get(self, request: HttpRequest, provider: int) -> HttpResponse: + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: """Show JWK Key data for Provider""" - provider: SSFProvider = get_object_or_404(SSFProvider, pk=provider) + application = get_object_or_404(Application, slug=application_slug) + provider = application.backchannel_provider_for(SSFProvider) + if not provider: + raise Http404 signing_key: CertificateKeyPair = provider.signing_key response_data = {} diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 960a5474de2a..906459d015ac 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -32,7 +32,6 @@ def create(self, validated_data): "authentik_providers_ssf:configuration", kwargs={ "application_slug": validated_data["provider"].application.slug, - "provider": validated_data["provider"].pk, }, ) ) diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts index 901b9439f539..4a196055ddca 100644 --- a/web/src/admin/providers/ssf/SSFProviderViewPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -135,19 +135,6 @@ export class SSFProviderViewPage extends AKElement {
-
-
- ${msg("Assigned to application")} -
-
-
- - -
-
-
`}
diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts index 57ce48cec402..aac85aa14d5f 100644 --- a/web/src/admin/providers/ssf/SSFProviderViewPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/providers/RelatedApplicationButton"; import "@goauthentik/admin/providers/ssf/SSFProviderFormPage"; +import "@goauthentik/admin/providers/ssf/StreamTable"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -112,9 +113,7 @@ export class SSFProviderViewPage extends AKElement { return html`
-
+
@@ -131,7 +130,12 @@ export class SSFProviderViewPage extends AKElement {
- ${this.provider.ssfUrl} +
@@ -149,6 +153,10 @@ export class SSFProviderViewPage extends AKElement {
+
+ + +
`; } } diff --git a/web/src/admin/providers/ssf/StreamTable.ts b/web/src/admin/providers/ssf/StreamTable.ts new file mode 100644 index 000000000000..989a2cd95a3b --- /dev/null +++ b/web/src/admin/providers/ssf/StreamTable.ts @@ -0,0 +1,50 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/forms/ProxyForm"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import { Table, TableColumn } from "@goauthentik/elements/table/Table"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { SSFStream, SsfApi } from "@goauthentik/api"; + +@customElement("ak-provider-ssf-stream-list") +export class SSFProviderStreamList extends Table { + searchEnabled(): boolean { + return true; + } + checkbox = true; + clearOnRefresh = true; + + @property({ type: Number }) + providerId?: number; + + @property() + order = "name"; + + async apiEndpoint(): Promise> { + return new SsfApi(DEFAULT_CONFIG).ssfStreamsList({ + provider: this.providerId, + ...(await this.defaultEndpointConfig()), + }); + } + + columns(): TableColumn[] { + return [new TableColumn(msg("Audience"), "aud")]; + } + + row(item: SSFStream): TemplateResult[] { + return [html`${item.aud}`]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-ssf-stream-list": SSFProviderStreamList; + } +} From 29a4e63c7652729ef54939fd98b172024851c56b Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 4 Feb 2025 08:46:27 +0100 Subject: [PATCH 22/44] add jwks tests and fixes Signed-off-by: Jens Langhammer --- authentik/core/models.py | 4 +- .../providers/ssf/tests/test_jwks.py | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 authentik/enterprise/providers/ssf/tests/test_jwks.py diff --git a/authentik/core/models.py b/authentik/core/models.py index 3d09ca2f83a8..5caee66c7215 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -601,11 +601,11 @@ def get_provider(self) -> Provider | None: def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None: """Get Backchannel provider for a specific type""" - providers = self.backchannel_providers.objects.filter( + providers = self.backchannel_providers.filter( **{f"{provider_type._meta.model_name}__isnull": False}, **kwargs, ) - return providers.first() + return getattr(providers.first(), provider_type._meta.model_name) def __str__(self): return str(self.name) diff --git a/authentik/enterprise/providers/ssf/tests/test_jwks.py b/authentik/enterprise/providers/ssf/tests/test_jwks.py new file mode 100644 index 000000000000..c0674535c45f --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_jwks.py @@ -0,0 +1,51 @@ +"""JWKS tests""" + +import base64 +import json + +from cryptography.hazmat.backends import default_backend +from cryptography.x509 import load_der_x509_certificate +from django.test import TestCase +from django.urls.base import reverse +from jwt import PyJWKSet + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_cert +from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.lib.generators import generate_id + + +class TestJWKS(TestCase): + """Test JWKS view""" + + def test_rs256(self): + """Test JWKS request with RS256""" + provider = SSFProvider.objects.create( + name=generate_id(), + signing_key=create_test_cert(), + ) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + app.backchannel_providers.add(provider) + response = self.client.get( + reverse("authentik_providers_ssf:jwks", kwargs={"application_slug": app.slug}) + ) + body = json.loads(response.content.decode()) + self.assertEqual(len(body["keys"]), 1) + PyJWKSet.from_dict(body) + key = body["keys"][0] + load_der_x509_certificate(base64.b64decode(key["x5c"][0]), default_backend()).public_key() + + def test_es256(self): + """Test JWKS request with ES256""" + provider = SSFProvider.objects.create( + name=generate_id(), + signing_key=create_test_cert(), + ) + app = Application.objects.create(name=generate_id(), slug=generate_id()) + app.backchannel_providers.add(provider) + response = self.client.get( + reverse("authentik_providers_ssf:jwks", kwargs={"application_slug": app.slug}) + ) + body = json.loads(response.content.decode()) + self.assertEqual(len(body["keys"]), 1) + PyJWKSet.from_dict(body) From 41e6b8a3525052d061a0fde5447fce2a54646840 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 4 Feb 2025 14:25:57 +0100 Subject: [PATCH 23/44] update web ui Signed-off-by: Jens Langhammer --- .../enterprise/providers/ssf/api/providers.py | 1 + authentik/enterprise/providers/ssf/tasks.py | 4 ++ blueprints/schema.json | 5 ++ schema.yml | 8 +++ .../providers/ssf/SSFProviderFormPage.ts | 70 +++++++++++++++++-- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py index 96eab2ed31e8..39595c6182c7 100644 --- a/authentik/enterprise/providers/ssf/api/providers.py +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -44,6 +44,7 @@ class Meta: "token_obj", "oidc_auth_providers", "ssf_url", + "event_retention", ] extra_kwargs = {} diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index f9e42f81f9d0..1a2e2e8af1c7 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -1,4 +1,5 @@ from celery import group +from django.utils.timezone import now from requests.exceptions import RequestException from structlog.stdlib import get_logger @@ -12,6 +13,7 @@ from authentik.events.models import TaskStatus from authentik.events.system_tasks import SystemTask from authentik.lib.utils.http import get_http_session +from authentik.lib.utils.time import timedelta_from_string from authentik.root.celery import CELERY_APP session = get_http_session() @@ -83,5 +85,7 @@ def ssf_push_event(self: SystemTask, event_id: str): except RequestException as exc: LOGGER.warning("Failed to send SSF event", exc=exc) self.set_error(exc) + # Re-up the expiry of the stream event + event.expires = now() + timedelta_from_string(self.provider.event_retention) event.status = SSFEventStatus.PENDING_FAILED event.save() diff --git a/blueprints/schema.json b/blueprints/schema.json index 976ae1b16d50..8994ffe74bd9 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -14074,6 +14074,11 @@ "type": "integer" }, "title": "Oidc auth providers" + }, + "event_retention": { + "type": "string", + "minLength": 1, + "title": "Event retention" } }, "required": [] diff --git a/schema.yml b/schema.yml index f942cecf2610..2e6a9f65901d 100644 --- a/schema.yml +++ b/schema.yml @@ -51347,6 +51347,9 @@ components: type: array items: type: integer + event_retention: + type: string + minLength: 1 PatchedScopeMappingRequest: type: object description: ScopeMapping Serializer @@ -55101,6 +55104,8 @@ components: type: string nullable: true readOnly: true + event_retention: + type: string required: - component - meta_model_name @@ -55127,6 +55132,9 @@ components: type: array items: type: integer + event_retention: + type: string + minLength: 1 required: - name SSFStream: diff --git a/web/src/admin/providers/ssf/SSFProviderFormPage.ts b/web/src/admin/providers/ssf/SSFProviderFormPage.ts index e6dbc70e4a2e..68d5c5eec0d3 100644 --- a/web/src/admin/providers/ssf/SSFProviderFormPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderFormPage.ts @@ -1,7 +1,12 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { + oauth2ProvidersProvider, + oauth2ProvidersSelector, +} from "@goauthentik/admin/providers/oauth2/OAuth2ProvidersProvider"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; @@ -52,12 +57,65 @@ export class SSFProviderFormPage extends BaseProviderForm { renderForm(): TemplateResult { const provider = this.instance; - return html` `; + return html` + + ${msg("Protocol settings")} +
+ + + +

${msg("Key used to sign the events.")}

+
+ + +

+ ${msg( + "Determines how long events are stored for. If an event could not be sent correctly, its expiration is also increased by this duration.", + )} +

+ +
+
+
+ + + ${msg("Authentication settings")} +
+ + +

+ ${msg( + "JWTs signed by the selected providers can be used to authenticate to this provider.", + )} +

+
+
+
`; } } From b6d09c613cf9042b12e131a4c0c02d17608aa78c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 4 Feb 2025 21:54:44 +0100 Subject: [PATCH 24/44] fix Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 1a2e2e8af1c7..4955543ec203 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -86,6 +86,6 @@ def ssf_push_event(self: SystemTask, event_id: str): LOGGER.warning("Failed to send SSF event", exc=exc) self.set_error(exc) # Re-up the expiry of the stream event - event.expires = now() + timedelta_from_string(self.provider.event_retention) + event.expires = now() + timedelta_from_string(event.stream.provider.event_retention) event.status = SSFEventStatus.PENDING_FAILED event.save() From 3a5d291d9f250f10dc0b534626828ed5d9a8e737 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 4 Feb 2025 22:39:29 +0100 Subject: [PATCH 25/44] fix configuration endpoint Signed-off-by: Jens Langhammer --- .../providers/ssf/tests/test_config.py | 46 +++++++++++++++++++ .../providers/ssf/views/configuration.py | 4 +- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 authentik/enterprise/providers/ssf/tests/test_config.py diff --git a/authentik/enterprise/providers/ssf/tests/test_config.py b/authentik/enterprise/providers/ssf/tests/test_config.py new file mode 100644 index 000000000000..4487a00fa363 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_config.py @@ -0,0 +1,46 @@ +import json + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_cert +from authentik.enterprise.providers.ssf.models import ( + SSFProvider, +) +from authentik.lib.generators import generate_id + + +class TestConfiguration(APITestCase): + def setUp(self): + self.application = Application.objects.create(name=generate_id(), slug=generate_id()) + self.provider = SSFProvider.objects.create( + name=generate_id(), + signing_key=create_test_cert(), + backchannel_application=self.application, + ) + + def test_config_fetch(self): + """test SSF configuration (unauthenticated)""" + res = self.client.get( + reverse( + "authentik_providers_ssf:configuration", + kwargs={"application_slug": self.application.slug}, + ), + ) + self.assertEqual(res.status_code, 200) + content = json.loads(res.content) + self.assertEqual(content["spec_version"], "1_0-ID2") + + def test_config_fetch_authenticated(self): + """test SSF configuration (authenticated)""" + res = self.client.get( + reverse( + "authentik_providers_ssf:configuration", + kwargs={"application_slug": self.application.slug}, + ), + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 200) + content = json.loads(res.content) + self.assertEqual(content["spec_version"], "1_0-ID2") diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py index b36d9738fb88..4cdcaa98bf39 100644 --- a/authentik/enterprise/providers/ssf/views/configuration.py +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -28,7 +28,6 @@ def get(self, request: HttpRequest, application_slug: str, *args, **kwargs) -> H "authentik_providers_ssf:configuration", kwargs={ "application_slug": application.slug, - "provider": provider.pk, }, ) ), @@ -36,7 +35,7 @@ def get(self, request: HttpRequest, application_slug: str, *args, **kwargs) -> H reverse( "authentik_providers_ssf:jwks", kwargs={ - "provider": provider.pk, + "application_slug": application.slug, }, ) ), @@ -45,7 +44,6 @@ def get(self, request: HttpRequest, application_slug: str, *args, **kwargs) -> H "authentik_providers_ssf:stream", kwargs={ "application_slug": application.slug, - "provider": provider.pk, }, ) ), From 5cf9f325d9751617effac401b30ea176889b53fd Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 08:04:32 +0100 Subject: [PATCH 26/44] replace port number correctly Signed-off-by: Jens Langhammer --- authentik/providers/oauth2/models.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 0a06d8bf1647..5bcb735ac8fa 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -271,19 +271,23 @@ def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: def get_issuer(self, request: HttpRequest) -> str | None: """Get issuer, based on request""" + full_url = "" if self.issuer_mode == IssuerMode.GLOBAL: - return request.build_absolute_uri(reverse("authentik_core:root-redirect")) - try: - url = reverse( - "authentik_providers_oauth2:provider-root", - kwargs={ - "application_slug": self.application.slug, - }, - ) - return request.build_absolute_uri(url) - - except Provider.application.RelatedObjectDoesNotExist: - return None + full_url = request.build_absolute_uri(reverse("authentik_core:root-redirect")) + else: + try: + url = reverse( + "authentik_providers_oauth2:provider-root", + kwargs={ + "application_slug": self.application.slug, + }, + ) + full_url = request.build_absolute_uri(url) + except Provider.application.RelatedObjectDoesNotExist: + return None + if request.is_secure(): + return full_url.replace(":443", "") + return full_url @property def redirect_uris(self) -> list[RedirectURI]: From 0fd44cfdad25655c670c387927df4d4306c09366 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 09:04:07 +0100 Subject: [PATCH 27/44] better log what went wrong Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/tasks.py | 21 +++++++++++++++++++-- authentik/events/system_tasks.py | 6 +++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 4955543ec203..67c9a1ce031a 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -1,5 +1,6 @@ from celery import group from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ from requests.exceptions import RequestException from structlog.stdlib import get_logger @@ -10,6 +11,7 @@ Stream, StreamEvent, ) +from authentik.events.logs import LogEvent from authentik.events.models import TaskStatus from authentik.events.system_tasks import SystemTask from authentik.lib.utils.http import get_http_session @@ -67,7 +69,7 @@ def ssf_push_event(self: SystemTask, event_id: str): event = StreamEvent.objects.filter(pk=event_id).first() if not event: return - self.set_uid(event) + self.set_uid(event_id) if event.status == SSFEventStatus.SENT: self.set_status(TaskStatus.SUCCESSFUL) return @@ -84,7 +86,22 @@ def ssf_push_event(self: SystemTask, event_id: str): return except RequestException as exc: LOGGER.warning("Failed to send SSF event", exc=exc) - self.set_error(exc) + self.set_status() + attrs = {} + if exc.response: + attrs["response"] = { + "content": exc.response.text, + "status": exc.response.status_code, + } + self.set_error( + exc, + LogEvent( + _("Failed to send request"), + log_level="warning", + logger=self.__name__, + attributes=attrs, + ), + ) # Re-up the expiry of the stream event event.expires = now() + timedelta_from_string(event.stream.provider.event_retention) event.status = SSFEventStatus.PENDING_FAILED diff --git a/authentik/events/system_tasks.py b/authentik/events/system_tasks.py index ebaf81abd8e6..0edb218ec096 100644 --- a/authentik/events/system_tasks.py +++ b/authentik/events/system_tasks.py @@ -53,12 +53,12 @@ def set_status(self, status: TaskStatus, *messages: LogEvent): if not isinstance(msg, LogEvent): self._messages[idx] = LogEvent(msg, logger=self.__name__, log_level="info") - def set_error(self, exception: Exception): + def set_error(self, exception: Exception, *messages: LogEvent): """Set result to error and save exception""" self._status = TaskStatus.ERROR - self._messages = [ + self._messages = list(messages).extend([ LogEvent(exception_to_string(exception), logger=self.__name__, log_level="error") - ] + ]) def before_start(self, task_id, args, kwargs): self._start_precise = perf_counter() From befcfb9b8187b47acee673bdd4aba31d8f00be7a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 09:08:20 +0100 Subject: [PATCH 28/44] linter has opinions Signed-off-by: Jens Langhammer --- authentik/events/system_tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/authentik/events/system_tasks.py b/authentik/events/system_tasks.py index 0edb218ec096..b33c3f8ac8e3 100644 --- a/authentik/events/system_tasks.py +++ b/authentik/events/system_tasks.py @@ -56,9 +56,9 @@ def set_status(self, status: TaskStatus, *messages: LogEvent): def set_error(self, exception: Exception, *messages: LogEvent): """Set result to error and save exception""" self._status = TaskStatus.ERROR - self._messages = list(messages).extend([ - LogEvent(exception_to_string(exception), logger=self.__name__, log_level="error") - ]) + self._messages = list(messages).extend( + [LogEvent(exception_to_string(exception), logger=self.__name__, log_level="error")] + ) def before_start(self, task_id, args, kwargs): self._start_precise = perf_counter() From ada960791ffd63f985a42b96f12ccabcc6f38954 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 09:29:03 +0100 Subject: [PATCH 29/44] fix messages Signed-off-by: Jens Langhammer --- authentik/events/system_tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authentik/events/system_tasks.py b/authentik/events/system_tasks.py index b33c3f8ac8e3..dbe81853f9ec 100644 --- a/authentik/events/system_tasks.py +++ b/authentik/events/system_tasks.py @@ -56,7 +56,8 @@ def set_status(self, status: TaskStatus, *messages: LogEvent): def set_error(self, exception: Exception, *messages: LogEvent): """Set result to error and save exception""" self._status = TaskStatus.ERROR - self._messages = list(messages).extend( + self._messages = list(messages) + self._messages.extend( [LogEvent(exception_to_string(exception), logger=self.__name__, log_level="error")] ) From 62efc5a57bd22586759d14d6b9eb90c6d81bb446 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 10:00:56 +0100 Subject: [PATCH 30/44] fix set status Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 67c9a1ce031a..9068b85f8596 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -86,7 +86,7 @@ def ssf_push_event(self: SystemTask, event_id: str): return except RequestException as exc: LOGGER.warning("Failed to send SSF event", exc=exc) - self.set_status() + self.set_status(TaskStatus.ERROR) attrs = {} if exc.response: attrs["response"] = { From 0f33ca52bf49dc8c4e3ad3af4524d5ef597541b6 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 13:15:37 +0100 Subject: [PATCH 31/44] more debug logging Signed-off-by: Jens Langhammer --- authentik/lib/utils/http.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/authentik/lib/utils/http.py b/authentik/lib/utils/http.py index a29b589fc0c1..81b6e371a8a6 100644 --- a/authentik/lib/utils/http.py +++ b/authentik/lib/utils/http.py @@ -42,6 +42,8 @@ def send(self, req: PreparedRequest, *args, **kwargs): def get_http_session() -> Session: """Get a requests session with common headers""" - session = DebugSession() if CONFIG.get_bool("debug") else Session() + session = Session() + if CONFIG.get_bool("debug") or CONFIG.get("log_level") == "trace": + session = DebugSession() session.headers["User-Agent"] = authentik_user_agent() return session From c9d612e298e8a2f760483b427b3ee9c926d93d3d Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 13:47:19 +0100 Subject: [PATCH 32/44] fix issuer here too Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/views/stream.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 23d35d2659e7..18c6861b5c67 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -1,3 +1,4 @@ +from django.http import HttpRequest from django.urls import reverse from rest_framework.exceptions import PermissionDenied from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField @@ -34,7 +35,8 @@ class StreamSerializer(ModelSerializer): def create(self, validated_data): provider: SSFProvider = validated_data["provider"] - iss = self.context["request"].build_absolute_uri( + request: HttpRequest = self.context["request"] + iss = request.build_absolute_uri( reverse( "authentik_providers_ssf:configuration", kwargs={ @@ -42,6 +44,8 @@ def create(self, validated_data): }, ) ) + if request.is_secure(): + iss = iss.replace(":443", "") # Ensure that streams always get SET verification events sent to them validated_data["events_requested"].append(EventTypes.SET_VERIFICATION) return super().create( From 4d9c147519e8016da3fd0fc5c7bc469f6c64302a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 14:04:45 +0100 Subject: [PATCH 33/44] remove port :443...removal apparently apple's HTTP logic is wrong and includes the port in the Host header even if the default port is used (80 or 443), which then fails as the URL doesn't exactly match what the admin configured...so instead of trying to add magic about this we'll add it in the docs Signed-off-by: Jens Langhammer --- .../enterprise/providers/ssf/views/stream.py | 2 -- authentik/providers/oauth2/models.py | 27 ++++++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 18c6861b5c67..32d11cf256f5 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -44,8 +44,6 @@ def create(self, validated_data): }, ) ) - if request.is_secure(): - iss = iss.replace(":443", "") # Ensure that streams always get SET verification events sent to them validated_data["events_requested"].append(EventTypes.SET_VERIFICATION) return super().create( diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 5bcb735ac8fa..4c1d1af32fc1 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -271,23 +271,18 @@ def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: def get_issuer(self, request: HttpRequest) -> str | None: """Get issuer, based on request""" - full_url = "" if self.issuer_mode == IssuerMode.GLOBAL: - full_url = request.build_absolute_uri(reverse("authentik_core:root-redirect")) - else: - try: - url = reverse( - "authentik_providers_oauth2:provider-root", - kwargs={ - "application_slug": self.application.slug, - }, - ) - full_url = request.build_absolute_uri(url) - except Provider.application.RelatedObjectDoesNotExist: - return None - if request.is_secure(): - return full_url.replace(":443", "") - return full_url + return request.build_absolute_uri(reverse("authentik_core:root-redirect")) + try: + url = reverse( + "authentik_providers_oauth2:provider-root", + kwargs={ + "application_slug": self.application.slug, + }, + ) + return request.build_absolute_uri(url) + except Provider.application.RelatedObjectDoesNotExist: + return None @property def redirect_uris(self) -> list[RedirectURI]: From 71c06166d862a4a7dc3458eef688f4706cc65eed Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 15:43:00 +0100 Subject: [PATCH 34/44] fix error when no request in context Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/api/providers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py index 39595c6182c7..ad1dfefda613 100644 --- a/authentik/enterprise/providers/ssf/api/providers.py +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -19,7 +19,9 @@ class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): token_obj = TokenSerializer(source="token", required=False, read_only=True) def get_ssf_url(self, instance: SSFProvider) -> str | None: - request: Request = self._context["request"] + request: Request = self._context.get("request") + if not request: + return None if not instance.backchannel_application: return None return request.build_absolute_uri( From 2daa035062d8fe7c2f4d5d0aea7475e75be0fed9 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 15:54:05 +0100 Subject: [PATCH 35/44] add signal for admin session revoke Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/signals.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 3f4946bc04bb..885531f2b5d5 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -2,13 +2,14 @@ from django.contrib.auth.signals import user_logged_out from django.db.models import Model -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import receiver from django.http.request import HttpRequest from guardian.shortcuts import assign_perm from authentik.core.models import ( USER_PATH_SYSTEM_PREFIX, + AuthenticatedSession, Token, TokenIntents, User, @@ -63,6 +64,7 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: @receiver(user_logged_out) def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_): + """Session revoked trigger (user logged out)""" if not request.session or not request.session.session_key or not user: return send_ssf_event( @@ -80,11 +82,34 @@ def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User }, "initiating_entity": "user", }, + request=request, + ) + + +@receiver(pre_delete, sender=AuthenticatedSession) +def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): + """Session revoked trigger (admin has deleted a users' session)""" + send_ssf_event( + EventTypes.CAEP_SESSION_REVOKED, + { + "subject": { + "session": { + "format": "opaque", + "id": sha256(instance.session_key.encode("ascii")).hexdigest(), + }, + "user": { + "format": "email", + "email": instance.user.email, + }, + }, + "initiating_entity": "admin", + }, ) @receiver(password_changed) def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_): + """Credential change trigger (password changed)""" send_ssf_event( EventTypes.CAEP_CREDENTIAL_CHANGE, { From eec07aca6014be5eda1bdc4f01f7d03525bb95db Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 15:54:13 +0100 Subject: [PATCH 36/44] set txn based on request id Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 9068b85f8596..96e9c3cc65a8 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -1,4 +1,5 @@ from celery import group +from django.http import HttpRequest from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from requests.exceptions import RequestException @@ -26,6 +27,7 @@ def send_ssf_event( event_type: EventTypes, data: dict, stream_filter: dict | None = None, + request: HttpRequest | None = None, **extra_data, ): """Wrapper to send an SSF event to multiple streams""" @@ -33,6 +35,8 @@ def send_ssf_event( if not stream_filter: stream_filter = {} stream_filter["events_requested__contains"] = [event_type] + if request and request.request_id: + data.setdefault("txn", request.request_id) for stream in Stream.objects.filter(**stream_filter): event_data = stream.prepare_event_payload(event_type, data, **extra_data) payload.append((str(stream.uuid), event_data)) From 12a086e808a9e6cb8de61b8925311f1d5b10ac51 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:00:52 +0100 Subject: [PATCH 37/44] validate method and endpoint url Signed-off-by: Jens Langhammer --- .../providers/ssf/tests/test_stream.py | 27 +++++++++++++++++++ .../enterprise/providers/ssf/views/stream.py | 16 +++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py index fde28e9c60f6..f849561ef464 100644 --- a/authentik/enterprise/providers/ssf/tests/test_stream.py +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -60,6 +60,33 @@ def test_stream_add_token(self): {"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}}, ) + def test_stream_add_poll(self): + """test stream add - poll method""" + res = self.client.post( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug}, + ), + data={ + "iss": "https://authentik.company/.well-known/ssf-configuration/foo/5", + "aud": ["https://app.authentik.company"], + "delivery": { + "method": "https://schemas.openid.net/secevent/risc/delivery-method/poll", + }, + "events_requested": [ + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + ], + "format": "iss_sub", + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 400) + self.assertJSONEqual( + res.content, + {"delivery": {"method": ["Polling for SSF events is not currently supported."]}}, + ) + def test_stream_add_oidc(self): """test stream add (oidc auth)""" provider = OAuth2Provider.objects.create( diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 32d11cf256f5..96bdcac2c706 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -1,6 +1,6 @@ from django.http import HttpRequest from django.urls import reverse -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField from rest_framework.request import Request from rest_framework.response import Response @@ -22,7 +22,19 @@ class StreamDeliverySerializer(PassiveSerializer): method = ChoiceField(choices=[(x.value, x.value) for x in DeliveryMethods]) - endpoint_url = CharField(allow_null=True) + endpoint_url = CharField(required=False) + + def validate_method(self, method: DeliveryMethods): + """Currently only push is supported""" + if method == DeliveryMethods.RISC_POLL: + raise ValidationError("Polling for SSF events is not currently supported.") + return method + + def validate(self, attrs: dict) -> dict: + if attrs["method"] == DeliveryMethods.RISC_PUSH: + if not attrs.get("endpoint_url"): + raise ValidationError("Endpoint URL is required when using push.") + return attrs class StreamSerializer(ModelSerializer): From dcff00307d5ac58e8d2da8a53bda78be3e2ddf2e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:02:56 +0100 Subject: [PATCH 38/44] fix request ID detection Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index 96e9c3cc65a8..4a7cca780bd3 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -35,7 +35,7 @@ def send_ssf_event( if not stream_filter: stream_filter = {} stream_filter["events_requested__contains"] = [event_type] - if request and request.request_id: + if request and hasattr(request, "request_id"): data.setdefault("txn", request.request_id) for stream in Stream.objects.filter(**stream_filter): event_data = stream.prepare_event_payload(event_type, data, **extra_data) From 89a63e03fa5a83b57d0595faf35dfae03185a7d8 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:19:16 +0100 Subject: [PATCH 39/44] add timestamp Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index bb3ad7e933f1..4059685f5758 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -14,6 +14,7 @@ from authentik.core.models import BackchannelProvider, ExpiringModel, Token from authentik.crypto.models import CertificateKeyPair +from authentik.lib.models import CreatedUpdatedModel from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider @@ -157,7 +158,7 @@ def encode(self, data: dict) -> str: return encode(data, key, algorithm=alg, headers=headers) -class StreamEvent(ExpiringModel): +class StreamEvent(CreatedUpdatedModel, ExpiringModel): """Single stream event to be sent""" uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False) @@ -180,3 +181,4 @@ def __str__(self): class Meta: verbose_name = _("SSF Stream Event") verbose_name_plural = _("SSF Stream Events") + ordering = ("-created",) From edf220d247f58d52d282aa028161e6efa4c301f9 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:19:22 +0100 Subject: [PATCH 40/44] temp migration Signed-off-by: Jens Langhammer --- ...nt_options_streamevent_created_and_more.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py diff --git a/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py b/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py new file mode 100644 index 000000000000..65b872956d93 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.11 on 2025-02-05 15:18 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_ssf", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="streamevent", + options={ + "ordering": ("-created",), + "verbose_name": "SSF Stream Event", + "verbose_name_plural": "SSF Stream Events", + }, + ), + migrations.AddField( + model_name="streamevent", + name="created", + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name="streamevent", + name="last_updated", + field=models.DateTimeField(auto_now=True), + ), + ] From 786745f3eff32cc309ab008a8f01c3ebf8cab955 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:19:29 +0100 Subject: [PATCH 41/44] fix signal Signed-off-by: Jens Langhammer --- authentik/enterprise/providers/ssf/signals.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 885531f2b5d5..0b75573a1b2a 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -88,7 +88,10 @@ def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User @receiver(pre_delete, sender=AuthenticatedSession) def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): - """Session revoked trigger (admin has deleted a users' session)""" + """Session revoked trigger (users' session has been deleted) + + As this signal is also triggered with a regular logout, we can't be sure + if the session has been deleted by an admin or by the user themselves.""" send_ssf_event( EventTypes.CAEP_SESSION_REVOKED, { @@ -102,7 +105,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi "email": instance.user.email, }, }, - "initiating_entity": "admin", + "initiating_entity": "user", }, ) From 89267473c3113493b5645ea1ee7005a928240698 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 16:22:58 +0100 Subject: [PATCH 42/44] add signal tests Signed-off-by: Jens Langhammer --- .../providers/ssf/tests/test_signals.py | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 authentik/enterprise/providers/ssf/tests/test_signals.py diff --git a/authentik/enterprise/providers/ssf/tests/test_signals.py b/authentik/enterprise/providers/ssf/tests/test_signals.py new file mode 100644 index 000000000000..c848125cce51 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_signals.py @@ -0,0 +1,145 @@ +from uuid import uuid4 + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import ( + create_test_cert, + create_test_user, +) +from authentik.enterprise.providers.ssf.models import ( + SSFEventStatus, + SSFProvider, + Stream, + StreamEvent, +) +from authentik.lib.generators import generate_id +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice + + +class TestSignals(APITestCase): + """Test individual SSF Signals""" + + def setUp(self): + self.application = Application.objects.create(name=generate_id(), slug=generate_id()) + self.provider = SSFProvider.objects.create( + name=generate_id(), + signing_key=create_test_cert(), + backchannel_application=self.application, + ) + res = self.client.post( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug}, + ), + data={ + "iss": "https://authentik.company/.well-known/ssf-configuration/foo/5", + "aud": ["https://app.authentik.company"], + "delivery": { + "method": "https://schemas.openid.net/secevent/risc/delivery-method/push", + "endpoint_url": "https://app.authentik.company", + }, + "events_requested": [ + "https://schemas.openid.net/secevent/caep/event-type/credential-change", + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + ], + "format": "iss_sub", + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 201, res.content) + + def test_signal_logout(self): + """Test user logout""" + user = create_test_user() + self.client.force_login(user) + self.client.logout() + + stream = Stream.objects.filter(provider=self.provider).first() + self.assertIsNotNone(stream) + event = StreamEvent.objects.filter(stream=stream).first() + self.assertIsNotNone(event) + self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) + event_payload = event.payload["events"][ + "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + ] + self.assertEqual(event_payload["initiating_entity"], "user") + self.assertEqual(event_payload["subject"]["session"]["format"], "opaque") + self.assertEqual(event_payload["subject"]["user"]["format"], "email") + self.assertEqual(event_payload["subject"]["user"]["email"], user.email) + + def test_signal_password_change(self): + """Test user password change""" + user = create_test_user() + self.client.force_login(user) + user.set_password(generate_id()) + user.save() + + stream = Stream.objects.filter(provider=self.provider).first() + self.assertIsNotNone(stream) + event = StreamEvent.objects.filter(stream=stream).first() + self.assertIsNotNone(event) + self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) + event_payload = event.payload["events"][ + "https://schemas.openid.net/secevent/caep/event-type/credential-change" + ] + self.assertEqual(event_payload["change_type"], "update") + self.assertEqual(event_payload["credential_type"], "password") + self.assertEqual(event_payload["subject"]["user"]["format"], "email") + self.assertEqual(event_payload["subject"]["user"]["email"], user.email) + + def test_signal_authenticator_added(self): + """Test authenticator creation signal""" + user = create_test_user() + self.client.force_login(user) + dev = WebAuthnDevice.objects.create( + user=user, + name=generate_id(), + credential_id=generate_id(), + public_key=generate_id(), + aaguid=str(uuid4()), + ) + + stream = Stream.objects.filter(provider=self.provider).first() + self.assertIsNotNone(stream) + event = StreamEvent.objects.filter(stream=stream).exclude().first() + self.assertIsNotNone(event) + self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) + event_payload = event.payload["events"][ + "https://schemas.openid.net/secevent/caep/event-type/credential-change" + ] + self.assertEqual(event_payload["change_type"], "create") + self.assertEqual(event_payload["fido2_aaguid"], dev.aaguid) + self.assertEqual(event_payload["friendly_name"], dev.name) + self.assertEqual(event_payload["credential_type"], "fido-u2f") + self.assertEqual(event_payload["subject"]["user"]["format"], "email") + self.assertEqual(event_payload["subject"]["user"]["email"], user.email) + + def test_signal_authenticator_deleted(self): + """Test authenticator deletion signal""" + user = create_test_user() + self.client.force_login(user) + dev = WebAuthnDevice.objects.create( + user=user, + name=generate_id(), + credential_id=generate_id(), + public_key=generate_id(), + aaguid=str(uuid4()), + ) + dev.delete() + + stream = Stream.objects.filter(provider=self.provider).first() + self.assertIsNotNone(stream) + event = StreamEvent.objects.filter(stream=stream).exclude().first() + self.assertIsNotNone(event) + self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED) + event_payload = event.payload["events"][ + "https://schemas.openid.net/secevent/caep/event-type/credential-change" + ] + self.assertEqual(event_payload["change_type"], "delete") + self.assertEqual(event_payload["fido2_aaguid"], dev.aaguid) + self.assertEqual(event_payload["friendly_name"], dev.name) + self.assertEqual(event_payload["credential_type"], "fido-u2f") + self.assertEqual(event_payload["subject"]["user"]["format"], "email") + self.assertEqual(event_payload["subject"]["user"]["email"], user.email) From 6a46ad983a2551c3bc1beb68b8e53f416186f91f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 17:02:35 +0100 Subject: [PATCH 43/44] the final commit Signed-off-by: Jens Langhammer --- .../providers/ssf/migrations/0001_initial.py | 5 ++- ...nt_options_streamevent_created_and_more.py | 33 ------------------- .../providers/ssf/SSFProviderViewPage.ts | 1 + 3 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py diff --git a/authentik/enterprise/providers/ssf/migrations/0001_initial.py b/authentik/enterprise/providers/ssf/migrations/0001_initial.py index 91a9094a3552..22235e10956c 100644 --- a/authentik/enterprise/providers/ssf/migrations/0001_initial.py +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.11 on 2025-02-02 19:41 +# Generated by Django 5.0.11 on 2025-02-05 16:01 import authentik.lib.utils.time import django.contrib.postgres.fields @@ -145,6 +145,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name="StreamEvent", fields=[ + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), ("expires", models.DateTimeField(default=None, null=True)), ("expiring", models.BooleanField(default=True)), ( @@ -194,6 +196,7 @@ class Migration(migrations.Migration): options={ "verbose_name": "SSF Stream Event", "verbose_name_plural": "SSF Stream Events", + "ordering": ("-created",), }, ), ] diff --git a/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py b/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py deleted file mode 100644 index 65b872956d93..000000000000 --- a/authentik/enterprise/providers/ssf/migrations/0002_alter_streamevent_options_streamevent_created_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.0.11 on 2025-02-05 15:18 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("authentik_providers_ssf", "0001_initial"), - ] - - operations = [ - migrations.AlterModelOptions( - name="streamevent", - options={ - "ordering": ("-created",), - "verbose_name": "SSF Stream Event", - "verbose_name_plural": "SSF Stream Events", - }, - ), - migrations.AddField( - model_name="streamevent", - name="created", - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name="streamevent", - name="last_updated", - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/web/src/admin/providers/ssf/SSFProviderViewPage.ts b/web/src/admin/providers/ssf/SSFProviderViewPage.ts index aac85aa14d5f..4359c7d999c3 100644 --- a/web/src/admin/providers/ssf/SSFProviderViewPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -154,6 +154,7 @@ export class SSFProviderViewPage extends AKElement {
+
${msg("Streams")}
From f8adfcc6db91f1a5884487b18654e9eb9d71202a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Feb 2025 17:21:55 +0100 Subject: [PATCH 44/44] ok actually the last commit Signed-off-by: Jens Langhammer --- .../providers/ssf/migrations/0001_initial.py | 7 +++---- authentik/enterprise/providers/ssf/models.py | 12 +++--------- authentik/enterprise/providers/ssf/views/jwks.py | 7 +++---- blueprints/schema.json | 2 +- schema.yml | 14 +++++--------- 5 files changed, 15 insertions(+), 27 deletions(-) diff --git a/authentik/enterprise/providers/ssf/migrations/0001_initial.py b/authentik/enterprise/providers/ssf/migrations/0001_initial.py index 22235e10956c..f72bd742146a 100644 --- a/authentik/enterprise/providers/ssf/migrations/0001_initial.py +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.11 on 2025-02-05 16:01 +# Generated by Django 5.0.11 on 2025-02-05 16:20 import authentik.lib.utils.time import django.contrib.postgres.fields @@ -48,9 +48,8 @@ class Migration(migrations.Migration): ( "signing_key", models.ForeignKey( - help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, + help_text="Key used to sign the SSF Events.", + on_delete=django.db.models.deletion.CASCADE, to="authentik_crypto.certificatekeypair", verbose_name="Signing Key", ), diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index 4059685f5758..9e34031c58f3 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -49,11 +49,8 @@ class SSFProvider(BackchannelProvider): signing_key = models.ForeignKey( CertificateKeyPair, verbose_name=_("Signing Key"), - on_delete=models.SET_NULL, - null=True, - help_text=_( - "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." - ), + on_delete=models.CASCADE, + help_text=_("Key used to sign the SSF Events."), ) oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None) @@ -66,11 +63,8 @@ class SSFProvider(BackchannelProvider): ) @cached_property - def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: + def jwt_key(self) -> tuple[PrivateKeyTypes, str]: """Get either the configured certificate or the client secret""" - if not self.signing_key: - # No Certificate at all, assume HS256 - return self.client_secret, JWTAlgorithms.HS256 key: CertificateKeyPair = self.signing_key private_key = key.private_key if isinstance(private_key, RSAPrivateKey): diff --git a/authentik/enterprise/providers/ssf/views/jwks.py b/authentik/enterprise/providers/ssf/views/jwks.py index 400ffe71cfab..0f6baa4d5d34 100644 --- a/authentik/enterprise/providers/ssf/views/jwks.py +++ b/authentik/enterprise/providers/ssf/views/jwks.py @@ -21,10 +21,9 @@ def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: response_data = {} - if signing_key: - jwk = OAuthJWKSView.get_jwk_for_key(signing_key, "sig") - if jwk: - response_data["keys"] = [jwk] + jwk = OAuthJWKSView.get_jwk_for_key(signing_key, "sig") + if jwk: + response_data["keys"] = [jwk] response = JsonResponse(response_data) response["Access-Control-Allow-Origin"] = "*" diff --git a/blueprints/schema.json b/blueprints/schema.json index 8994ffe74bd9..4e0ec4c68a0e 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -14066,7 +14066,7 @@ "type": "string", "format": "uuid", "title": "Signing Key", - "description": "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + "description": "Key used to sign the SSF Events." }, "oidc_auth_providers": { "type": "array", diff --git a/schema.yml b/schema.yml index 2e6a9f65901d..c5c4a9609a2c 100644 --- a/schema.yml +++ b/schema.yml @@ -51340,9 +51340,7 @@ components: signing_key: type: string format: uuid - nullable: true - description: Key used to sign the tokens. Only required when JWT Algorithm - is set to RS256. + description: Key used to sign the SSF Events. oidc_auth_providers: type: array items: @@ -55089,9 +55087,7 @@ components: signing_key: type: string format: uuid - nullable: true - description: Key used to sign the tokens. Only required when JWT Algorithm - is set to RS256. + description: Key used to sign the SSF Events. token_obj: allOf: - $ref: '#/components/schemas/Token' @@ -55111,6 +55107,7 @@ components: - meta_model_name - name - pk + - signing_key - ssf_url - token_obj - verbose_name @@ -55125,9 +55122,7 @@ components: signing_key: type: string format: uuid - nullable: true - description: Key used to sign the tokens. Only required when JWT Algorithm - is set to RS256. + description: Key used to sign the SSF Events. oidc_auth_providers: type: array items: @@ -55137,6 +55132,7 @@ components: minLength: 1 required: - name + - signing_key SSFStream: type: object description: SSFStream Serializer