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/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/core/models.py b/authentik/core/models.py index 1126ab248167..5caee66c7215 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.filter( + **{f"{provider_type._meta.model_name}__isnull": False}, + **kwargs, + ) + return getattr(providers.first(), provider_type._meta.model_name) + def __str__(self): return str(self.name) 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..ad1dfefda613 --- /dev/null +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -0,0 +1,64 @@ +"""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 | None: + request: Request = self._context.get("request") + if not request: + return None + if not instance.backchannel_application: + return None + return request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": instance.backchannel_application.slug, + }, + ) + ) + + class Meta: + model = SSFProvider + fields = [ + "pk", + "name", + "component", + "verbose_name", + "verbose_name_plural", + "meta_model_name", + "signing_key", + "token_obj", + "oidc_auth_providers", + "ssf_url", + "event_retention", + ] + 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/api/streams.py b/authentik/enterprise/providers/ssf/api/streams.py new file mode 100644 index 000000000000..cd44c6aabfa2 --- /dev/null +++ b/authentik/enterprise/providers/ssf/api/streams.py @@ -0,0 +1,37 @@ +"""SSF Stream API Views""" + +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.core.api.utils import ModelSerializer +from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer +from authentik.enterprise.providers.ssf.models import Stream + + +class SSFStreamSerializer(ModelSerializer): + """SSFStream Serializer""" + + provider_obj = SSFProviderSerializer(source="provider", read_only=True) + + class Meta: + model = Stream + fields = [ + "pk", + "provider", + "provider_obj", + "delivery_method", + "endpoint_url", + "events_requested", + "format", + "aud", + "iss", + ] + + +class SSFStreamViewSet(ReadOnlyModelViewSet): + """SSFStream Viewset""" + + queryset = Stream.objects.all() + serializer_class = SSFStreamSerializer + filterset_fields = ["provider", "endpoint_url", "delivery_method"] + search_fields = ["provider__name", "endpoint_url"] + ordering = ["provider", "uuid"] 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..f72bd742146a --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0001_initial.py @@ -0,0 +1,201 @@ +# Generated by Django 5.0.11 on 2025-02-05 16:20 + +import authentik.lib.utils.time +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"), + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"), + ] + + 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", + ), + ), + ( + "event_retention", + models.TextField( + default="days=30", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ( + "oidc_auth_providers", + models.ManyToManyField( + blank=True, default=None, to="authentik_providers_oauth2.oauth2provider" + ), + ), + ( + "signing_key", + models.ForeignKey( + help_text="Key used to sign the SSF Events.", + on_delete=django.db.models.deletion.CASCADE, + 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": "Shared Signals Framework Provider", + "verbose_name_plural": "Shared Signals Framework Providers", + "permissions": [("add_stream", "Add stream to SSF provider")], + }, + 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", + ), + ( + "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( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_ssf.ssfprovider", + ), + ), + ], + options={ + "verbose_name": "SSF Stream", + "verbose_name_plural": "SSF Streams", + "default_permissions": ["change", "delete", "view"], + }, + ), + 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)), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ( + "status", + models.TextField( + choices=[ + ("pending_new", "Pending New"), + ("pending_failed", "Pending Failed"), + ("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", + ), + ), + ], + options={ + "verbose_name": "SSF Stream Event", + "verbose_name_plural": "SSF Stream Events", + "ordering": ("-created",), + }, + ), + ] 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..9e34031c58f3 --- /dev/null +++ b/authentik/enterprise/providers/ssf/models.py @@ -0,0 +1,178 @@ +from datetime import datetime +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.templatetags.static import static +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from jwt import encode + +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 + + +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" + SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification" + + +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 SSFEventStatus(models.TextChoices): + """SSF Event status""" + + PENDING_NEW = "pending_new" + PENDING_FAILED = "pending_failed" + SENT = "sent" + + +class SSFProvider(BackchannelProvider): + """Shared Signals Framework provider to allow applications to + receive user events from authentik.""" + + signing_key = models.ForeignKey( + CertificateKeyPair, + verbose_name=_("Signing Key"), + on_delete=models.CASCADE, + help_text=_("Key used to sign the SSF Events."), + ) + + oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None) + + token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) + + event_retention = models.TextField( + default="days=30", + validators=[timedelta_string_validator], + ) + + @cached_property + def jwt_key(self) -> tuple[PrivateKeyTypes, str]: + """Get either the configured certificate or the client secret""" + 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}" + + @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/ssf.svg") + + @property + def component(self) -> str: + return "ak-provider-ssf-form" + + class Meta: + verbose_name = _("Shared Signals Framework Provider") + verbose_name_plural = _("Shared Signals Framework Providers") + permissions = [ + # This overrides the default "add_stream" permission of the Stream object, + # as the user requesting to add a stream must have the permission on the provider + ("add_stream", _("Add stream to SSF provider")), + ] + + +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) + format = models.TextField() + aud = ArrayField(models.TextField(), default=list) + + iss = models.TextField() + + class Meta: + verbose_name = _("SSF Stream") + verbose_name_plural = _("SSF Streams") + default_permissions = ["change", "delete", "view"] + + def __str__(self) -> str: + return "SSF Stream" + + def prepare_event_payload(self, type: EventTypes, event_data: dict, **kwargs) -> dict: + jti = uuid4() + _now = now() + return { + "uuid": jti, + "stream_id": str(self.pk), + "type": type, + "expiring": True, + "status": SSFEventStatus.PENDING_NEW, + "expires": _now + timedelta_from_string(self.provider.event_retention), + "payload": { + "jti": jti.hex, + "aud": self.aud, + "iat": int(datetime.now().timestamp()), + "iss": self.iss, + "events": {type: event_data}, + **kwargs, + }, + } + + 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 StreamEvent(CreatedUpdatedModel, ExpiringModel): + """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 expire_action(self, *args, **kwargs): + """Only allow automatic cleanup of successfully sent event""" + if self.status != SSFEventStatus.SENT: + return + return super().expire_action(*args, **kwargs) + + def __str__(self): + return f"Stream event {self.type}" + + class Meta: + verbose_name = _("SSF Stream Event") + verbose_name_plural = _("SSF Stream Events") + ordering = ("-created",) diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py new file mode 100644 index 000000000000..0b75573a1b2a --- /dev/null +++ b/authentik/enterprise/providers/ssf/signals.py @@ -0,0 +1,182 @@ +from hashlib import sha256 + +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, 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, + UserTypes, +) +from authentik.core.signals import password_changed +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.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" + + +@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, + }, + ) + assign_perm("add_stream", user, instance) + 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 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( + EventTypes.CAEP_SESSION_REVOKED, + { + "subject": { + "session": { + "format": "opaque", + "id": sha256(request.session.session_key.encode("ascii")).hexdigest(), + }, + "user": { + "format": "email", + "email": user.email, + }, + }, + "initiating_entity": "user", + }, + request=request, + ) + + +@receiver(pre_delete, sender=AuthenticatedSession) +def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): + """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, + { + "subject": { + "session": { + "format": "opaque", + "id": sha256(instance.session_key.encode("ascii")).hexdigest(), + }, + "user": { + "format": "email", + "email": instance.user.email, + }, + }, + "initiating_entity": "user", + }, + ) + + +@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, + { + "subject": { + "user": { + "format": "email", + "email": user.email, + }, + }, + "credential_type": "password", + "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, **_): + 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/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py new file mode 100644 index 000000000000..4a7cca780bd3 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -0,0 +1,112 @@ +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 +from structlog.stdlib import get_logger + +from authentik.enterprise.providers.ssf.models import ( + DeliveryMethods, + EventTypes, + SSFEventStatus, + 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 +from authentik.lib.utils.time import timedelta_from_string +from authentik.root.celery import CELERY_APP + +session = get_http_session() +LOGGER = get_logger() + + +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""" + payload = [] + if not stream_filter: + stream_filter = {} + stream_filter["events_requested__contains"] = [event_type] + 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) + payload.append((str(stream.uuid), event_data)) + return _send_ssf_event.delay(payload) + + +@CELERY_APP.task() +def _send_ssf_event(event_data: list[tuple[str, dict]]): + tasks = [] + for stream, data in event_data: + event = StreamEvent.objects.create(**data) + tasks.extend(send_single_ssf_event(stream, str(event.uuid))) + main_task = group(*tasks) + main_task() + + +def send_single_ssf_event(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: + return [ssf_push_event.si(str(event.pk))] + return [] + + +@CELERY_APP.task(bind=True, base=SystemTask) +def ssf_push_event(self: SystemTask, event_id: str): + self.save_on_success = False + event = StreamEvent.objects.filter(pk=event_id).first() + if not event: + return + self.set_uid(event_id) + if event.status == SSFEventStatus.SENT: + self.set_status(TaskStatus.SUCCESSFUL) + return + try: + response = session.post( + event.stream.endpoint_url, + data=event.stream.encode(event.payload), + headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"}, + ) + response.raise_for_status() + event.status = SSFEventStatus.SENT + event.save() + self.set_status(TaskStatus.SUCCESSFUL) + return + except RequestException as exc: + LOGGER.warning("Failed to send SSF event", exc=exc) + self.set_status(TaskStatus.ERROR) + 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 + event.save() 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_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/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) 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) 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..f849561ef464 --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -0,0 +1,154 @@ +import json +from dataclasses import asdict + +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APITestCase + +from authentik.core.models import Application +from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow +from authentik.enterprise.providers.ssf.models import ( + SSFEventStatus, + SSFProvider, + Stream, + StreamEvent, +) +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.id_token import IDToken +from authentik.providers.oauth2.models import AccessToken, OAuth2Provider + + +class TestStream(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_stream_add_token(self): + """test stream add (token auth)""" + 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) + 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) + self.assertEqual( + event.payload["events"], + {"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( + name=generate_id(), + authorization_flow=create_test_flow(), + ) + self.application.provider = provider + self.application.save() + user = create_test_admin_user() + token = AccessToken.objects.create( + provider=provider, + user=user, + token=generate_id(), + auth_time=timezone.now(), + _scope="openid user profile", + _id_token=json.dumps( + asdict( + IDToken("foo", "bar"), + ) + ), + ) + + 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 {token.token}", + ) + self.assertEqual(res.status_code, 201) + 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) + self.assertEqual( + event.payload["events"], + {"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}}, + ) + + 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}, + ), + 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/urls.py b/authentik/enterprise/providers/ssf/urls.py new file mode 100644 index 000000000000..26734913d53e --- /dev/null +++ b/authentik/enterprise/providers/ssf/urls.py @@ -0,0 +1,32 @@ +"""SSF provider URLs""" + +from django.urls import path + +from authentik.enterprise.providers.ssf.api.providers import SSFProviderViewSet +from authentik.enterprise.providers.ssf.api.streams import SSFStreamViewSet +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), + ("ssf/streams", SSFStreamViewSet), +] 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..91f90b81f226 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/auth.py @@ -0,0 +1,66 @@ +"""SSF Token auth""" + +from typing import TYPE_CHECKING, Any + +from django.db.models import Q +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.request import Request + +from authentik.core.models import Token, TokenIntents, User +from authentik.enterprise.providers.ssf.models import SSFProvider +from authentik.providers.oauth2.models import AccessToken + +if TYPE_CHECKING: + from authentik.enterprise.providers.ssf.views.base import SSFView + + +class SSFTokenAuth(BaseAuthentication): + """SSF Token auth""" + + 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 provider""" + 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.backchannel_application + self.view.provider = provider + return token + + def check_jwt(self, jwt: str) -> AccessToken | None: + """Check JWT-based authentication, this supports tokens issued either by providers + configured directly in the provider, and by providers assigned to the application + that the SSF provider is a backchannel provider of.""" + token = AccessToken.filter_not_expired(token=jwt, revoked=False).first() + if not token: + return None + ssf_provider = SSFProvider.objects.filter( + Q(oidc_auth_providers__in=[token.provider]) + | Q(backchannel_application__provider__in=[token.provider]), + ).first() + if not ssf_provider: + return None + self.view.application = ssf_provider.backchannel_application + self.view.provider = ssf_provider + return token + + def authenticate(self, request: Request) -> tuple[User, Any] | None: + auth = get_authorization_header(request).decode() + auth_type, _, key = auth.partition(" ") + if auth_type != "Bearer": + return None + token = self.check_token(key) + if token: + return (token.user, token) + jwt_token = self.check_jwt(key) + if jwt_token: + return (jwt_token.user, token) + return None 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..4cdcaa98bf39 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -0,0 +1,55 @@ +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 + +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): + """SSF configuration endpoint""" + + permission_classes = [AllowAny] + + def get_authenticators(self): + return [] + + def get(self, request: HttpRequest, application_slug: str, *args, **kwargs) -> HttpResponse: + application = get_object_or_404(Application, slug=application_slug) + provider = application.backchannel_provider_for(SSFProvider) + if not provider: + raise Http404 + data = { + "spec_version": "1_0-ID2", + "issuer": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": application.slug, + }, + ) + ), + "jwks_uri": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:jwks", + kwargs={ + "application_slug": application.slug, + }, + ) + ), + "configuration_endpoint": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:stream", + kwargs={ + "application_slug": application.slug, + }, + ) + ), + "delivery_methods_supported": [ + DeliveryMethods.RISC_PUSH, + ], + "authorization_schemes": [{"spec_urn": "urn:ietf:rfc:6749"}], + } + 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..0f6baa4d5d34 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/jwks.py @@ -0,0 +1,31 @@ +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 + + +class JWKSview(View): + """SSF JWKS endpoint, similar to the OAuth2 provider's endpoint""" + + 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 = application.backchannel_provider_for(SSFProvider) + if not provider: + raise Http404 + signing_key: CertificateKeyPair = provider.signing_key + + response_data = {} + + jwk = OAuthJWKSView.get_jwk_for_key(signing_key, "sig") + 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..96bdcac2c706 --- /dev/null +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -0,0 +1,130 @@ +from django.http import HttpRequest +from django.urls import reverse +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 +from rest_framework.serializers import ModelSerializer +from structlog.stdlib import get_logger + +from authentik.core.api.utils import PassiveSerializer +from authentik.enterprise.providers.ssf.models import ( + DeliveryMethods, + EventTypes, + SSFProvider, + Stream, +) +from authentik.enterprise.providers.ssf.tasks import send_ssf_event +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(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): + 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): + provider: SSFProvider = validated_data["provider"] + request: HttpRequest = self.context["request"] + iss = request.build_absolute_uri( + reverse( + "authentik_providers_ssf:configuration", + kwargs={ + "application_slug": provider.backchannel_application.slug, + }, + ) + ) + # Ensure that streams always get SET verification events sent to them + validated_data["events_requested"].append(EventTypes.SET_VERIFICATION) + 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"], + "iss": iss, + } + ) + + class Meta: + model = Stream + fields = [ + "delivery", + "events_requested", + "format", + "aud", + ] + + +class StreamResponseSerializer(PassiveSerializer): + stream_id = CharField(source="pk") + iss = CharField() + aud = ListField(child=CharField()) + delivery = SerializerMethodField() + format = CharField() + + events_requested = ListField(child=CharField()) + events_supported = SerializerMethodField() + events_delivered = ListField(child=CharField(), source="events_requested") + + 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 post(self, request: Request, *args, **kwargs) -> Response: + stream = StreamSerializer(data=request.data, context={"request": request}) + stream.is_valid(raise_exception=True) + if not request.user.has_perm("authentik_providers_ssf.add_stream", self.provider): + raise PermissionDenied( + "User does not have permission to create stream for this provider." + ) + instance: Stream = stream.save(provider=self.provider) + send_ssf_event( + EventTypes.SET_VERIFICATION, + { + "state": None, + }, + stream_filter={"pk": instance.uuid}, + sub_id={"format": "opaque", "id": str(instance.uuid)}, + ) + response = StreamResponseSerializer(instance=instance, context={"request": request}).data + 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/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/events/system_tasks.py b/authentik/events/system_tasks.py index ebaf81abd8e6..dbe81853f9ec 100644 --- a/authentik/events/system_tasks.py +++ b/authentik/events/system_tasks.py @@ -53,12 +53,13 @@ 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 = [ - LogEvent(exception_to_string(exception), logger=self.__name__, log_level="error") - ] + self._messages = list(messages) + self._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() 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 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 diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 0a06d8bf1647..4c1d1af32fc1 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -281,7 +281,6 @@ def get_issuer(self, request: HttpRequest) -> str | None: }, ) return request.build_absolute_uri(url) - except Provider.application.RelatedObjectDoesNotExist: return None 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..4e0ec4c68a0e 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_streamevent", + "authentik_providers_ssf.change_ssfprovider", + "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_streamevent", + "authentik_providers_ssf.delete_ssfprovider", + "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_streamevent", + "authentik_providers_ssf.view_ssfprovider", + "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_streamevent", "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_streamevent", + "authentik_providers_ssf.change_ssfprovider", + "authentik_providers_ssf.change_stream", + "authentik_providers_ssf.change_streamevent", + "authentik_providers_ssf.delete_ssfprovider", + "authentik_providers_ssf.delete_stream", + "authentik_providers_ssf.delete_streamevent", + "authentik_providers_ssf.view_ssfprovider", + "authentik_providers_ssf.view_stream", + "authentik_providers_ssf.view_streamevent", "authentik_rbac.access_admin_interface", "authentik_rbac.add_role", "authentik_rbac.assign_role_permissions", @@ -13988,6 +14054,62 @@ } } }, + "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 SSF Events." + }, + "oidc_auth_providers": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Oidc auth providers" + }, + "event_retention": { + "type": "string", + "minLength": 1, + "title": "Event retention" + } + }, + "required": [] + }, + "model_authentik_providers_ssf.ssfprovider_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_stream", + "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/go.mod b/go.mod index b19cd735e89a..461795c54b2c 100644 --- a/go.mod +++ b/go.mod @@ -31,8 +31,8 @@ require ( github.com/wwt/guac v1.3.2 goauthentik.io/api/v3 v3.2024123.1 golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab - golang.org/x/oauth2 v0.25.0 - golang.org/x/sync v0.10.0 + golang.org/x/oauth2 v0.26.0 + golang.org/x/sync v0.11.0 gopkg.in/yaml.v2 v2.4.0 layeh.com/radius v0.0.0-20210819152912-ad72663a72ab ) diff --git a/go.sum b/go.sum index 6f0a417aacfc..2880bbcf36a5 100644 --- a/go.sum +++ b/go.sum @@ -393,8 +393,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -408,8 +408,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 4acb824ca052..9e1c9181534a 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -26,17 +26,19 @@ # Thomas Liske, 2024 # Michael Gottinger, 2024 # itxworks, 2024 -# Alexander Möbius, 2024 # Christian Wichmann , 2024 +# Stefan Werner, 2024 +# Alexander Möbius, 2025 +# Jonas, 2025 # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-18 00:09+0000\n" +"POT-Creation-Date: 2024-12-20 00:08+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n" -"Last-Translator: Christian Wichmann , 2024\n" +"Last-Translator: Jonas, 2025\n" "Language-Team: German (https://app.transifex.com/authentik/teams/119923/de/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -104,9 +106,9 @@ msgid "authentik Export - {date}" msgstr "authentik Export - {date}" #: authentik/blueprints/v1/tasks.py authentik/crypto/tasks.py -#, python-format -msgid "Successfully imported %(count)d files." -msgstr "%(count)d Dateien wurden erfolgreich importiert." +#, python-brace-format +msgid "Successfully imported {count} files." +msgstr "{count} Dateien erfolgreich importiert." #: authentik/brands/models.py msgid "" @@ -136,6 +138,10 @@ msgstr "Marke" msgid "Brands" msgstr "Marken" +#: authentik/core/api/application_entitlements.py +msgid "User does not have access to application." +msgstr "Nutzer hat keinen Zugriff auf diese Applikation." + #: authentik/core/api/devices.py msgid "Extra description not available" msgstr "Eine weitergehende Beschreibung ist nicht verfügbar" @@ -269,6 +275,14 @@ msgstr "Anwendung" msgid "Applications" msgstr "Anwendungen" +#: authentik/core/models.py +msgid "Application Entitlement" +msgstr "Anwendungsberechtigung" + +#: authentik/core/models.py +msgid "Application Entitlements" +msgstr "Anwendungsberechtigungen" + #: authentik/core/models.py msgid "Use the source-specific identifier" msgstr "Verwenden Sie die quellenspezifische Kennung" @@ -959,14 +973,14 @@ msgid "Starting full provider sync" msgstr "Starte komplette Provider Synchronisation." #: authentik/lib/sync/outgoing/tasks.py -#, python-format -msgid "Syncing page %(page)d of users" -msgstr "Seite %(page)d der Benutzer synchronisieren" +#, python-brace-format +msgid "Syncing page {page} of users" +msgstr "Synchonisiere Benutzer Seite {page}" #: authentik/lib/sync/outgoing/tasks.py -#, python-format -msgid "Syncing page %(page)d of groups" -msgstr "Seite %(page)d der Gruppen synchronisieren" +#, python-brace-format +msgid "Syncing page {page} of groups" +msgstr "Synchonisiere Gruppen Seite {page}" #: authentik/lib/sync/outgoing/tasks.py #, python-brace-format @@ -1140,10 +1154,10 @@ msgid "Event Matcher Policies" msgstr "Richtlinie für den Ereignisvergleich" #: authentik/policies/expiry/models.py -#, python-format -msgid "Password expired %(days)d days ago. Please update your password." +#, python-brace-format +msgid "Password expired {days} days ago. Please update your password." msgstr "" -"Das Passwort ist vor %(days)d Tagen abgelaufen. Bitte aktualisieren Sie Ihr " +"Das Passwort ist vor {days} Tagen abgelaufen. Bitte aktualisieren Sie Ihr " "Passwort." #: authentik/policies/expiry/models.py @@ -1278,9 +1292,9 @@ msgid "Invalid password." msgstr "Ungültiges Passwort." #: authentik/policies/password/models.py -#, python-format -msgid "Password exists on %(count)d online lists." -msgstr "Passwort existiert auf %(count)d Listen." +#, python-brace-format +msgid "Password exists on {count} online lists." +msgstr "Passwort online in {count} Listen gefunden." #: authentik/policies/password/models.py msgid "Password is too weak." @@ -1407,6 +1421,11 @@ msgstr "LDAP Anbietern" msgid "Search full LDAP directory" msgstr "Durchsuche komplettes LDAP Verzeichnis" +#: authentik/providers/oauth2/api/providers.py +#, python-brace-format +msgid "Invalid Regex Pattern: {url}" +msgstr "Regex pattern ungültig: {url}" + #: authentik/providers/oauth2/id_token.py msgid "Based on the Hashed User ID" msgstr "Basierend auf der gehashten Benutzer ID" @@ -1456,6 +1475,14 @@ msgstr "" "Jeder Anbieter hat einen anderen Aussteller, der auf dem Slug der Anwendung " "basiert." +#: authentik/providers/oauth2/models.py +msgid "Strict URL comparison" +msgstr "Strikter URL-Vergleich" + +#: authentik/providers/oauth2/models.py +msgid "Regular Expression URL matching" +msgstr "Regex-URL-Vergleich" + #: authentik/providers/oauth2/models.py msgid "code (Authorization Code Flow)" msgstr "Code (Autorisierungsablauf)" @@ -1536,10 +1563,6 @@ msgstr "Client Geheimnis" msgid "Redirect URIs" msgstr "URIs weiterleiten" -#: authentik/providers/oauth2/models.py -msgid "Enter each URI on a new line." -msgstr "Geben Sie jeden URI in eine neue Zeile ein." - #: authentik/providers/oauth2/models.py msgid "Include claims in id_token" msgstr "Ansprüche in id_token berücksichtigen" @@ -2094,6 +2117,10 @@ msgstr "" "Benutzerdefinierte krb5.conf zur Benutzung. Benutzt standardmäßig die " "systemeigene Konfiguration" +#: authentik/sources/kerberos/models.py +msgid "KAdmin server type" +msgstr "KAdmin-Servertyp" + #: authentik/sources/kerberos/models.py msgid "Sync users from Kerberos into authentik" msgstr "Synchronisiere Nutzer von Kerberos nach authentik" @@ -3114,12 +3141,11 @@ msgstr "" #, python-format msgid "" "\n" -" If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" +" If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" " " msgstr "" "\n" -" Wenn Sie keine Passwortänderung beantragt haben, ignorieren Sie bitte diese E-Mail. Der obige Link ist gültig für %(expires)s.\n" -" " +"  Wenn Sie keine Passwortänderung beantragt haben, ignorieren Sie bitte diese E-Mail. Der obige Link ist gültig für %(expires)s." #: authentik/stages/email/templates/email/password_reset.txt #, python-format @@ -3138,7 +3164,7 @@ msgstr "" #, python-format msgid "" "\n" -"If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" +"If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" msgstr "" "\n" "Wenn Sie keine Passwortänderung beantragt haben, ignorieren Sie bitte diese E-Mail. Der obige Link ist gültig für %(expires)s.\n" @@ -3450,6 +3476,22 @@ msgstr "Aufforderungsstufen" msgid "Passwords don't match." msgstr "Passwörter stimmen nicht überein" +#: authentik/stages/redirect/api.py +msgid "Target URL should be present when mode is Static." +msgstr "Ziel-URL sollte beim statischen Modus vorhanden sein" + +#: authentik/stages/redirect/api.py +msgid "Target Flow should be present when mode is Flow." +msgstr "Ziel-Flow sollte beim Flow-Modus vorhanden sein" + +#: authentik/stages/redirect/models.py +msgid "Redirect Stage" +msgstr "Umleitungphase" + +#: authentik/stages/redirect/models.py +msgid "Redirect Stages" +msgstr "Umleitungphasen" + #: authentik/stages/user_delete/models.py msgid "User Delete Stage" msgstr "Benutzer löschen Stufe" diff --git a/poetry.lock b/poetry.lock index 95e6f9594bb9..8d4db152c2de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3421,13 +3421,13 @@ files = [ [[package]] name = "paramiko" -version = "3.5.0" +version = "3.5.1" description = "SSH2 protocol library" optional = false python-versions = ">=3.6" files = [ - {file = "paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9"}, - {file = "paramiko-3.5.0.tar.gz", hash = "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124"}, + {file = "paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61"}, + {file = "paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822"}, ] [package.dependencies] diff --git a/schema.yml b/schema.yml index 820952f890f3..c5c4a9609a2c 100644 --- a/schema.yml +++ b/schema.yml @@ -22953,6 +22953,279 @@ 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 Shared Signals Framework + 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 Shared Signals Framework + 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 Shared Signals Framework + 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 Shared Signals Framework + 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 Shared Signals Framework + 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 +23903,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 +24145,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 @@ -30309,6 +30584,108 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /ssf/streams/: + get: + operationId: ssf_streams_list + description: SSFStream Viewset + parameters: + - in: query + name: delivery_method + schema: + type: string + enum: + - https://schemas.openid.net/secevent/risc/delivery-method/poll + - https://schemas.openid.net/secevent/risc/delivery-method/push + - in: query + name: endpoint_url + 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 + - in: query + name: provider + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - ssf + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSSFStreamList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /ssf/streams/{uuid}/: + get: + operationId: ssf_streams_retrieve + description: SSFStream Viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SSF Stream. + required: true + tags: + - ssf + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SSFStream' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/all/: get: operationId: stages_all_list @@ -38345,6 +38722,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 @@ -40755,6 +41133,11 @@ components: - matched_domain - ui_footer_links - ui_theme + DeliveryMethodEnum: + enum: + - https://schemas.openid.net/secevent/risc/delivery-method/push + - https://schemas.openid.net/secevent/risc/delivery-method/poll + type: string DeniedActionEnum: enum: - message_continue @@ -41793,6 +42176,12 @@ components: - application - counted_events - unique_users + EventsRequestedEnum: + enum: + - https://schemas.openid.net/secevent/caep/event-type/session-revoked + - https://schemas.openid.net/secevent/caep/event-type/credential-change + - https://schemas.openid.net/secevent/ssf/event-type/verification + type: string ExpiringBaseGrantModel: type: object description: Serializer for BaseGrantModel and ExpiringBaseGrant @@ -45240,6 +45629,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 +47937,30 @@ components: required: - pagination - results + PaginatedSSFProviderList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/SSFProvider' + required: + - pagination + - results + PaginatedSSFStreamList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/SSFStream' + required: + - pagination + - results PaginatedScopeMappingList: type: object properties: @@ -50916,6 +51330,24 @@ 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 + description: Key used to sign the SSF Events. + oidc_auth_providers: + type: array + items: + type: integer + event_retention: + type: string + minLength: 1 PatchedScopeMappingRequest: type: object description: ScopeMapping Serializer @@ -52197,6 +52629,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 +55058,120 @@ 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 + description: Key used to sign the SSF Events. + token_obj: + allOf: + - $ref: '#/components/schemas/Token' + readOnly: true + oidc_auth_providers: + type: array + items: + type: integer + ssf_url: + type: string + nullable: true + readOnly: true + event_retention: + type: string + required: + - component + - meta_model_name + - name + - pk + - signing_key + - 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 + description: Key used to sign the SSF Events. + oidc_auth_providers: + type: array + items: + type: integer + event_retention: + type: string + minLength: 1 + required: + - name + - signing_key + SSFStream: + type: object + description: SSFStream Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Uuid + provider: + type: integer + provider_obj: + allOf: + - $ref: '#/components/schemas/SSFProvider' + readOnly: true + delivery_method: + $ref: '#/components/schemas/DeliveryMethodEnum' + endpoint_url: + type: string + nullable: true + events_requested: + type: array + items: + $ref: '#/components/schemas/EventsRequestedEnum' + format: + type: string + aud: + type: array + items: + type: string + iss: + type: string + required: + - delivery_method + - format + - iss + - pk + - provider + - provider_obj ScopeMapping: type: object description: ScopeMapping Serializer @@ -57071,6 +57618,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 +57631,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/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 diff --git a/web/authentik/sources/ssf.svg b/web/authentik/sources/ssf.svg new file mode 100644 index 000000000000..c4760b23ea9c --- /dev/null +++ b/web/authentik/sources/ssf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/package-lock.json b/web/package-lock.json index e7a49bcd81da..3c3a50900c7b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "@floating-ui/dom": "^1.6.11", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", - "@goauthentik/api": "^2024.12.3-1738190128", + "@goauthentik/api": "^2024.12.3-1738774356", "@lit-labs/ssr": "^3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", @@ -1775,9 +1775,9 @@ } }, "node_modules/@goauthentik/api": { - "version": "2024.12.3-1738190128", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.12.3-1738190128.tgz", - "integrity": "sha512-zoeGg4dDGv9XdmRGczYLRyU8+sEPG3lfEIKkZj2Lc3troxj/3omKgc7nb2l3NfnHIY8zkhxDjd9RMSEeYP0LRQ==" + "version": "2024.12.3-1738774356", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.12.3-1738774356.tgz", + "integrity": "sha512-TbEkX8v2DtMiMTuyYWdMvCmccuVIxXMENRZHw7t30DFxtTY4on2b18rFs7VqN/LgZMIQhN8UV4EPCINvkWgnlw==" }, "node_modules/@goauthentik/web": { "resolved": "", diff --git a/web/package.json b/web/package.json index 6e953c7afd03..7eaee89451cc 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "@floating-ui/dom": "^1.6.11", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", - "@goauthentik/api": "^2024.12.3-1738190128", + "@goauthentik/api": "^2024.12.3-1738774356", "@lit-labs/ssr": "^3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", 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..6c431a5372a1 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts @@ -175,7 +175,7 @@ export class OAuth2ProviderViewPage extends AKElement { `}
@@ -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..68d5c5eec0d3 --- /dev/null +++ b/web/src/admin/providers/ssf/SSFProviderFormPage.ts @@ -0,0 +1,126 @@ +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"; +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` + + ${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.", + )} +

+
+
+
`; + } +} + +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..4359c7d999c3 --- /dev/null +++ b/web/src/admin/providers/ssf/SSFProviderViewPage.ts @@ -0,0 +1,169 @@ +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"; +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("URL")} +
+
+
+ +
+
+
+
+
+ +
+
+
${msg("Streams")}
+ + +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-provider-ssf-view": SSFProviderViewPage; + } +} 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; + } +}