diff --git a/authentik/core/models.py b/authentik/core/models.py index 04ac201395..77c759fbba 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -796,11 +796,11 @@ class Application(SerializerModel, PolicyBindingModel): 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( + provider: BackchannelProvider | None = self.backchannel_providers.filter( **{f"{provider_type._meta.model_name}__isnull": False}, **kwargs, - ) - return getattr(providers.first(), provider_type._meta.model_name) + ).first() + return getattr(provider, provider_type._meta.model_name) if provider else None def __str__(self): return str(self.name) diff --git a/authentik/enterprise/providers/ssf/api/providers.py b/authentik/enterprise/providers/ssf/api/providers.py index 07fd68108e..74f7162790 100644 --- a/authentik/enterprise/providers/ssf/api/providers.py +++ b/authentik/enterprise/providers/ssf/api/providers.py @@ -52,6 +52,7 @@ class SSFProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): "oidc_auth_providers_obj", "ssf_url", "event_retention", + "push_verify_certificates", ] extra_kwargs = {} diff --git a/authentik/enterprise/providers/ssf/api/streams.py b/authentik/enterprise/providers/ssf/api/streams.py index cd44c6aabf..b98ef948e5 100644 --- a/authentik/enterprise/providers/ssf/api/streams.py +++ b/authentik/enterprise/providers/ssf/api/streams.py @@ -1,6 +1,7 @@ """SSF Stream API Views""" -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework import mixins +from rest_framework.viewsets import GenericViewSet from authentik.core.api.utils import ModelSerializer from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer @@ -16,6 +17,7 @@ class SSFStreamSerializer(ModelSerializer): model = Stream fields = [ "pk", + "status", "provider", "provider_obj", "delivery_method", @@ -27,7 +29,12 @@ class SSFStreamSerializer(ModelSerializer): ] -class SSFStreamViewSet(ReadOnlyModelViewSet): +class SSFStreamViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet, +): """SSFStream Viewset""" queryset = Stream.objects.all() diff --git a/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_push_verify_certificates_and_more.py b/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_push_verify_certificates_and_more.py new file mode 100644 index 0000000000..78171d68e5 --- /dev/null +++ b/authentik/enterprise/providers/ssf/migrations/0002_ssfprovider_push_verify_certificates_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.12 on 2026-04-04 16:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_ssf", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="ssfprovider", + name="push_verify_certificates", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="stream", + name="authorization_header", + field=models.TextField(default=None, null=True), + ), + migrations.AddField( + model_name="stream", + name="status", + field=models.TextField( + choices=[("enabled", "Enabled"), ("paused", "Paused"), ("disabled", "Disabled")], + default="enabled", + ), + ), + migrations.AlterField( + model_name="stream", + name="delivery_method", + field=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"), + ("urn:ietf:rfc:8935", "SSF RFC Push"), + ("urn:ietf:rfc:8936", "SSF RFC Pull"), + ] + ), + ), + ] diff --git a/authentik/enterprise/providers/ssf/models.py b/authentik/enterprise/providers/ssf/models.py index dabeacba3a..03864ef289 100644 --- a/authentik/enterprise/providers/ssf/models.py +++ b/authentik/enterprise/providers/ssf/models.py @@ -33,6 +33,8 @@ class DeliveryMethods(models.TextChoices): RISC_PUSH = "https://schemas.openid.net/secevent/risc/delivery-method/push" RISC_POLL = "https://schemas.openid.net/secevent/risc/delivery-method/poll" + RFC_PUSH = "urn:ietf:rfc:8935", _("SSF RFC Push") + RFC_PULL = "urn:ietf:rfc:8936", _("SSF RFC Pull") class SSFEventStatus(models.TextChoices): @@ -43,6 +45,13 @@ class SSFEventStatus(models.TextChoices): SENT = "sent" +class StreamStatus(models.TextChoices): + + ENABLED = "enabled" + PAUSED = "paused" + DISABLED = "disabled" + + class SSFProvider(TasksModel, BackchannelProvider): """Shared Signals Framework provider to allow applications to receive user events from authentik.""" @@ -54,6 +63,8 @@ class SSFProvider(TasksModel, BackchannelProvider): help_text=_("Key used to sign the SSF Events."), ) + push_verify_certificates = models.BooleanField(default=True) + oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None) token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) @@ -106,10 +117,14 @@ class Stream(models.Model): """SSF Stream""" uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False) + + status = models.TextField(choices=StreamStatus.choices, default=StreamStatus.ENABLED) + provider = models.ForeignKey(SSFProvider, on_delete=models.CASCADE) delivery_method = models.TextField(choices=DeliveryMethods.choices) endpoint_url = models.TextField(null=True) + authorization_header = models.TextField(null=True, default=None) events_requested = ArrayField(models.TextField(choices=EventTypes.choices), default=list) format = models.TextField() @@ -146,7 +161,7 @@ class Stream(models.Model): } def encode(self, data: dict) -> str: - headers = {} + headers = {"typ": "secevent+jwt"} if self.provider.signing_key: headers["kid"] = self.provider.signing_key.kid key, alg = self.provider.jwt_key diff --git a/authentik/enterprise/providers/ssf/tasks.py b/authentik/enterprise/providers/ssf/tasks.py index bb3e94cb9c..5b70295313 100644 --- a/authentik/enterprise/providers/ssf/tasks.py +++ b/authentik/enterprise/providers/ssf/tasks.py @@ -16,6 +16,7 @@ from authentik.enterprise.providers.ssf.models import ( SSFEventStatus, Stream, StreamEvent, + StreamStatus, ) from authentik.lib.utils.http import get_http_session from authentik.lib.utils.time import timedelta_from_string @@ -88,23 +89,42 @@ def send_ssf_event(stream_uuid: UUID, event_data: dict[str, Any]): self.set_uid(event.pk) if event.status == SSFEventStatus.SENT: return - if stream.delivery_method != DeliveryMethods.RISC_PUSH: + if stream.delivery_method not in [DeliveryMethods.RISC_PUSH, DeliveryMethods.RFC_PUSH]: return + headers = {"Content-Type": "application/secevent+jwt", "Accept": "application/json"} + if stream.authorization_header: + headers["Authorization"] = stream.authorization_header try: response = session.post( event.stream.endpoint_url, data=event.stream.encode(event.payload), - headers={"Content-Type": "application/secevent+jwt", "Accept": "application/json"}, + headers=headers, + verify=stream.provider.push_verify_certificates, + timeout=180, ) response.raise_for_status() event.status = SSFEventStatus.SENT event.save() - return + self.info("Event successfully sent", status=response.status_code) + # Cleanup, if we were the last pending message for this stream and it has been deleted + # (status=StreamStatus.DISABLED), then we can delete the stream + if ( + not StreamEvent.objects.filter( + stream=stream, + status__in=[SSFEventStatus.PENDING_FAILED, SSFEventStatus.PENDING_NEW], + ).exists() + and stream.status == StreamStatus.DISABLED + ): + LOGGER.info( + "Deleting inactive stream as all pending messages were sent.", stream=stream + ) + self.info("Deleting inactive stream as all pending messages were sent.") + stream.delete() except RequestException as exc: - LOGGER.warning("Failed to send SSF event", exc=exc) + LOGGER.warning("Failed to send SSF event", exc=exc, stream=stream) attrs = {} - if exc.response: + if exc.response is not None: attrs["response"] = { "content": exc.response.text, "status": exc.response.status_code, @@ -113,5 +133,6 @@ def send_ssf_event(stream_uuid: UUID, event_data: dict[str, Any]): self.warning("Failed to send request", **attrs) # Re-up the expiry of the stream event event.expires = now() + timedelta_from_string(event.stream.provider.event_retention) + self.info(f"Event will be re-sent at {event.expires}") event.status = SSFEventStatus.PENDING_FAILED event.save() diff --git a/authentik/enterprise/providers/ssf/tests/test_auth.py b/authentik/enterprise/providers/ssf/tests/test_auth.py new file mode 100644 index 0000000000..438328996d --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_auth.py @@ -0,0 +1,170 @@ +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, Token, TokenIntents +from authentik.core.tests.utils import ( + create_test_admin_user, + create_test_cert, + create_test_flow, + create_test_user, +) +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 TestSSFAuth(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_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_token_invalid(self): + 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}a", + ) + # Response code needs to be 401 according to spec + self.assertEqual(res.status_code, 401) + + def test_token_unrelated(self): + token = Token.objects.create( + identifier=generate_id(), user=create_test_user(), intent=TokenIntents.INTENT_API + ) + 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.key}", + ) + # Response code needs to be 401 according to spec + self.assertEqual(res.status_code, 401) diff --git a/authentik/enterprise/providers/ssf/tests/test_config.py b/authentik/enterprise/providers/ssf/tests/test_config.py index 4487a00fa3..f9ebfd7bff 100644 --- a/authentik/enterprise/providers/ssf/tests/test_config.py +++ b/authentik/enterprise/providers/ssf/tests/test_config.py @@ -44,3 +44,15 @@ class TestConfiguration(APITestCase): self.assertEqual(res.status_code, 200) content = json.loads(res.content) self.assertEqual(content["spec_version"], "1_0-ID2") + + def test_config_not_found(self): + """test SSF configuration (authenticated)""" + self.provider.delete() + 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, 404) diff --git a/authentik/enterprise/providers/ssf/tests/test_stream.py b/authentik/enterprise/providers/ssf/tests/test_stream.py index f849561ef4..9d9f38fe75 100644 --- a/authentik/enterprise/providers/ssf/tests/test_stream.py +++ b/authentik/enterprise/providers/ssf/tests/test_stream.py @@ -1,21 +1,18 @@ -import json -from dataclasses import asdict +from uuid import uuid4 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.core.tests.utils import create_test_cert from authentik.enterprise.providers.ssf.models import ( SSFEventStatus, SSFProvider, Stream, StreamEvent, + StreamStatus, ) 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): @@ -87,29 +84,71 @@ class TestStream(APITestCase): {"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"), - ) + 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) + stream.refresh_from_db() + self.assertEqual(stream.status, StreamStatus.DISABLED) - res = self.client.post( + def test_stream_get(self): + """get stream""" + Stream.objects.create(provider=self.provider) + res = self.client.get( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug}, + ), + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 200) + + def test_stream_get_filter_query(self): + """get stream""" + other_stream = Stream.objects.create(provider=self.provider) + stream = Stream.objects.create(provider=self.provider) + res = self.client.get( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug}, + ) + + f"?stream_id={stream.pk}", + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 200) + self.assertIn(str(stream.pk), res.content.decode()) + self.assertNotIn(str(other_stream.pk), res.content.decode()) + + def test_stream_patch(self): + """patch stream""" + other_stream = Stream.objects.create(provider=self.provider) + stream = Stream.objects.create(provider=self.provider) + res = self.client.patch( + reverse( + "authentik_providers_ssf:stream", + kwargs={"application_slug": self.application.slug}, + ), + data={ + "delivery": {"endpoint_url": "https://localhost"}, + "stream_id": str(stream.pk), + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 200) + self.assertIn(str(stream.pk), res.content.decode()) + self.assertNotIn(str(other_stream.pk), res.content.decode()) + + def test_stream_put(self): + """put stream""" + stream = Stream.objects.create(provider=self.provider) + res = self.client.put( reverse( "authentik_providers_ssf:stream", kwargs={"application_slug": self.application.slug}, @@ -126,29 +165,63 @@ class TestStream(APITestCase): "https://schemas.openid.net/secevent/caep/event-type/session-revoked", ], "format": "iss_sub", + "stream_id": str(stream.pk), }, - 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}}, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", ) + self.assertEqual(res.status_code, 200) + self.assertIn(str(stream.pk), res.content.decode()) + stream.refresh_from_db() + self.assertEqual(stream.aud, ["https://app.authentik.company"]) - def test_stream_delete(self): - """delete stream""" + def test_stream_verify(self): + """Test stream verify""" stream = Stream.objects.create(provider=self.provider) - res = self.client.delete( + res = self.client.post( reverse( - "authentik_providers_ssf:stream", + "authentik_providers_ssf:stream-verify", kwargs={"application_slug": self.application.slug}, ), + data={ + "stream_id": str(stream.pk), + }, HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", ) self.assertEqual(res.status_code, 204) - self.assertFalse(Stream.objects.filter(pk=stream.pk).exists()) + + def test_stream_status(self): + """Test stream status""" + stream = Stream.objects.create(provider=self.provider) + res = self.client.get( + reverse( + "authentik_providers_ssf:stream-status", + kwargs={"application_slug": self.application.slug}, + ), + data={ + "stream_id": str(stream.pk), + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content, + { + "stream_id": str(stream.pk), + "status": str(stream.status), + }, + ) + + def test_stream_status_not_found(self): + """Test stream status""" + Stream.objects.create(provider=self.provider) + res = self.client.get( + reverse( + "authentik_providers_ssf:stream-status", + kwargs={"application_slug": self.application.slug}, + ), + data={ + "stream_id": str(uuid4()), + }, + HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}", + ) + self.assertEqual(res.status_code, 404) diff --git a/authentik/enterprise/providers/ssf/tests/test_tasks.py b/authentik/enterprise/providers/ssf/tests/test_tasks.py new file mode 100644 index 0000000000..82e3d5346b --- /dev/null +++ b/authentik/enterprise/providers/ssf/tests/test_tasks.py @@ -0,0 +1,123 @@ +from jwt import decode_complete +from requests_mock import Mocker +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 ( + DeliveryMethods, + EventTypes, + SSFProvider, + Stream, + StreamStatus, +) +from authentik.enterprise.providers.ssf.tasks import send_ssf_event +from authentik.lib.generators import generate_id +from authentik.tasks.models import TaskLog + + +class TestTasks(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_push_simple(self): + stream = Stream.objects.create( + provider=self.provider, + delivery_method=DeliveryMethods.RFC_PUSH, + endpoint_url="http://localhost/ssf-push", + ) + event_data = stream.prepare_event_payload( + EventTypes.SET_VERIFICATION, + {"state": None}, + sub_id={"format": "opaque", "id": str(stream.uuid)}, + ) + with Mocker() as mocker: + mocker.post("http://localhost/ssf-push", status_code=202) + send_ssf_event.send_with_options( + args=(stream.pk, event_data), rel_obj=stream.provider + ).get_result(block=True, timeout=1) + self.assertEqual( + mocker.request_history[0].headers["Content-Type"], "application/secevent+jwt" + ) + jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False}) + self.assertEqual(jwt["header"]["typ"], "secevent+jwt") + self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"]) + + def test_push_auth(self): + auth = generate_id() + stream = Stream.objects.create( + provider=self.provider, + delivery_method=DeliveryMethods.RFC_PUSH, + endpoint_url="http://localhost/ssf-push", + authorization_header=auth, + ) + event_data = stream.prepare_event_payload( + EventTypes.SET_VERIFICATION, + {"state": None}, + sub_id={"format": "opaque", "id": str(stream.uuid)}, + ) + with Mocker() as mocker: + mocker.post("http://localhost/ssf-push", status_code=202) + send_ssf_event.send_with_options( + args=(stream.pk, event_data), rel_obj=stream.provider + ).get_result(block=True, timeout=1) + self.assertEqual(mocker.request_history[0].headers["Authorization"], auth) + self.assertEqual( + mocker.request_history[0].headers["Content-Type"], "application/secevent+jwt" + ) + jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False}) + self.assertEqual(jwt["header"]["typ"], "secevent+jwt") + self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"]) + + def test_push_stream_disable(self): + auth = generate_id() + stream = Stream.objects.create( + provider=self.provider, + delivery_method=DeliveryMethods.RFC_PUSH, + endpoint_url="http://localhost/ssf-push", + authorization_header=auth, + status=StreamStatus.DISABLED, + ) + event_data = stream.prepare_event_payload( + EventTypes.SET_VERIFICATION, + {"state": None}, + sub_id={"format": "opaque", "id": str(stream.uuid)}, + ) + with Mocker() as mocker: + mocker.post("http://localhost/ssf-push", status_code=202) + send_ssf_event.send_with_options( + args=(stream.pk, event_data), rel_obj=stream.provider + ).get_result(block=True, timeout=1) + jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False}) + self.assertEqual(jwt["header"]["typ"], "secevent+jwt") + self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"]) + self.assertFalse(Stream.objects.filter(pk=stream.pk).exists()) + + def test_push_error(self): + stream = Stream.objects.create( + provider=self.provider, + delivery_method=DeliveryMethods.RFC_PUSH, + endpoint_url="http://localhost/ssf-push", + ) + event_data = stream.prepare_event_payload( + EventTypes.SET_VERIFICATION, + {"state": None}, + sub_id={"format": "opaque", "id": str(stream.uuid)}, + ) + with Mocker() as mocker: + mocker.post("http://localhost/ssf-push", text="error", status_code=400) + send_ssf_event.send_with_options( + args=(stream.pk, event_data), rel_obj=stream.provider + ).get_result(block=True, timeout=1) + logs = ( + TaskLog.objects.filter(task__actor_name=send_ssf_event.actor_name) + .order_by("timestamp") + .filter(event="Failed to send request") + .first() + ) + self.assertEqual(logs.attributes, {"response": {"status": 400, "content": "error"}}) diff --git a/authentik/enterprise/providers/ssf/urls.py b/authentik/enterprise/providers/ssf/urls.py index 26734913d5..0940aed321 100644 --- a/authentik/enterprise/providers/ssf/urls.py +++ b/authentik/enterprise/providers/ssf/urls.py @@ -6,7 +6,11 @@ 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 +from authentik.enterprise.providers.ssf.views.stream import ( + StreamStatusView, + StreamVerifyView, + StreamView, +) urlpatterns = [ path( @@ -24,6 +28,16 @@ urlpatterns = [ StreamView.as_view(), name="stream", ), + path( + "application/ssf//stream/verify/", + StreamVerifyView.as_view(), + name="stream-verify", + ), + path( + "application/ssf//stream/status/", + StreamStatusView.as_view(), + name="stream-status", + ), ] api_urlpatterns = [ diff --git a/authentik/enterprise/providers/ssf/views/auth.py b/authentik/enterprise/providers/ssf/views/auth.py index ef5d312e10..0385ea7385 100644 --- a/authentik/enterprise/providers/ssf/views/auth.py +++ b/authentik/enterprise/providers/ssf/views/auth.py @@ -64,3 +64,7 @@ class SSFTokenAuth(BaseAuthentication): if jwt_token: return (jwt_token.user, token) return None + + # Required to correctly propagate a 401 header which the SSF spec requires + def authenticate_header(self, request): + return "SSF" diff --git a/authentik/enterprise/providers/ssf/views/base.py b/authentik/enterprise/providers/ssf/views/base.py index 927f9fa2a5..6c5dd00626 100644 --- a/authentik/enterprise/providers/ssf/views/base.py +++ b/authentik/enterprise/providers/ssf/views/base.py @@ -1,10 +1,10 @@ -from django.http import HttpRequest +from django.http import Http404, 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.models import SSFProvider, Stream, StreamStatus from authentik.enterprise.providers.ssf.views.auth import SSFTokenAuth @@ -21,3 +21,18 @@ class SSFView(APIView): def get_authenticators(self): return [SSFTokenAuth(self)] + + +class SSFStreamView(SSFView): + def get_object(self, any_status=False) -> Stream: + streams = Stream.objects.filter(provider=self.provider) + if not any_status: + streams = streams.filter(status__in=[StreamStatus.ENABLED, StreamStatus.PAUSED]) + if "stream_id" in self.request.query_params: + streams = streams.filter(pk=self.request.query_params["stream_id"]) + if "stream_id" in self.request.data: + streams = streams.filter(pk=self.request.data["stream_id"]) + stream = streams.first() + if not stream: + raise Http404() + return stream diff --git a/authentik/enterprise/providers/ssf/views/configuration.py b/authentik/enterprise/providers/ssf/views/configuration.py index 4cdcaa98bf..ea671c012c 100644 --- a/authentik/enterprise/providers/ssf/views/configuration.py +++ b/authentik/enterprise/providers/ssf/views/configuration.py @@ -47,9 +47,23 @@ class ConfigurationView(SSFView): }, ) ), - "delivery_methods_supported": [ - DeliveryMethods.RISC_PUSH, - ], + "verification_endpoint": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:stream-verify", + kwargs={ + "application_slug": application.slug, + }, + ) + ), + "status_endpoint": self.request.build_absolute_uri( + reverse( + "authentik_providers_ssf:stream-status", + kwargs={ + "application_slug": application.slug, + }, + ) + ), + "delivery_methods_supported": [DeliveryMethods.RISC_PUSH, DeliveryMethods.RFC_PUSH], "authorization_schemes": [{"spec_urn": "urn:ietf:rfc:6749"}], } return JsonResponse(data) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index a4ea3f2ca7..45b2ce9cf0 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from django.http import HttpRequest from django.urls import reverse from rest_framework.exceptions import PermissionDenied, ValidationError @@ -13,9 +15,10 @@ from authentik.enterprise.providers.ssf.models import ( EventTypes, SSFProvider, Stream, + StreamStatus, ) from authentik.enterprise.providers.ssf.tasks import send_ssf_events -from authentik.enterprise.providers.ssf.views.base import SSFView +from authentik.enterprise.providers.ssf.views.base import SSFStreamView LOGGER = get_logger() @@ -23,6 +26,7 @@ LOGGER = get_logger() class StreamDeliverySerializer(PassiveSerializer): method = ChoiceField(choices=[(x.value, x.value) for x in DeliveryMethods]) endpoint_url = CharField(required=False) + authorization_header = CharField(required=False) def validate_method(self, method: DeliveryMethods): """Currently only push is supported""" @@ -31,7 +35,7 @@ class StreamDeliverySerializer(PassiveSerializer): return method def validate(self, attrs: dict) -> dict: - if attrs["method"] == DeliveryMethods.RISC_PUSH: + if attrs.get("method") in [DeliveryMethods.RISC_PUSH, DeliveryMethods.RFC_PUSH]: if not attrs.get("endpoint_url"): raise ValidationError("Endpoint URL is required when using push.") return attrs @@ -42,8 +46,8 @@ class StreamSerializer(ModelSerializer): events_requested = ListField( child=ChoiceField(choices=[(x.value, x.value) for x in EventTypes]) ) - format = CharField() - aud = ListField(child=CharField()) + format = CharField(default="iss_sub") + aud = ListField(child=CharField(), allow_empty=True, default=list) def create(self, validated_data): provider: SSFProvider = validated_data["provider"] @@ -58,15 +62,19 @@ class StreamSerializer(ModelSerializer): ) # Ensure that streams always get SET verification events sent to them validated_data["events_requested"].append(EventTypes.SET_VERIFICATION) + stream_id = uuid4() + default_aud = f"goauthentik.io/providers/ssf/{str(stream_id)}" return super().create( { "delivery_method": validated_data["delivery"]["method"], "endpoint_url": validated_data["delivery"].get("endpoint_url"), + "authorization_header": validated_data["delivery"].get("authorization_header"), "format": validated_data["format"], "provider": validated_data["provider"], "events_requested": validated_data["events_requested"], - "aud": validated_data["aud"], + "aud": validated_data["aud"] or [default_aud], "iss": iss, + "pk": stream_id, } ) @@ -101,7 +109,14 @@ class StreamResponseSerializer(PassiveSerializer): return [x.value for x in EventTypes] -class StreamView(SSFView): +class StreamView(SSFStreamView): + + def get(self, request: Request, *args, **kwargs): + stream = self.get_object() + return Response( + StreamResponseSerializer(instance=stream, context={"request": request}).data + ) + @validate(StreamSerializer) def post(self, request: Request, *args, body: StreamSerializer, **kwargs) -> Response: if not request.user.has_perm("authentik_providers_ssf.add_stream", self.provider): @@ -109,6 +124,8 @@ class StreamView(SSFView): "User does not have permission to create stream for this provider." ) instance: Stream = body.save(provider=self.provider) + + LOGGER.info("Sending verification event", stream=instance) send_ssf_events( EventTypes.SET_VERIFICATION, { @@ -120,10 +137,56 @@ class StreamView(SSFView): response = StreamResponseSerializer(instance=instance, context={"request": request}).data return Response(response, status=201) + def patch(self, request: Request, *args, **kwargs) -> Response: + stream = self.get_object() + serializer = StreamSerializer(stream, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + response = StreamResponseSerializer( + instance=serializer.instance, context={"request": request} + ).data + return Response(response, status=200) + + def put(self, request: Request, *args, **kwargs) -> Response: + stream = self.get_object() + serializer = StreamSerializer(stream, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + response = StreamResponseSerializer( + instance=serializer.instance, context={"request": request} + ).data + return Response(response, status=200) + 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() + stream = self.get_object() + stream.status = StreamStatus.DISABLED + stream.save() return Response(status=204) + + +class StreamVerifyView(SSFStreamView): + + def post(self, request: Request, *args, **kwargs): + stream = self.get_object() + state = request.data.get("state", None) + send_ssf_events( + EventTypes.SET_VERIFICATION, + { + "state": state, + }, + stream_filter={"pk": stream.uuid}, + sub_id={"format": "opaque", "id": str(stream.uuid)}, + ) + return Response(status=204) + + +class StreamStatusView(SSFStreamView): + + def get(self, request: Request, *args, **kwargs): + stream = self.get_object(any_status=True) + return Response( + { + "stream_id": str(stream.pk), + "status": str(stream.status), + } + ) diff --git a/blueprints/schema.json b/blueprints/schema.json index 8abdfe81c8..a7bd50266f 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -7507,6 +7507,10 @@ "type": "string", "minLength": 1, "title": "Event retention" + }, + "push_verify_certificates": { + "type": "boolean", + "title": "Push verify certificates" } }, "required": [] diff --git a/packages/client-go/api_ssf.go b/packages/client-go/api_ssf.go index 357c8ad916..9e4c08a629 100644 --- a/packages/client-go/api_ssf.go +++ b/packages/client-go/api_ssf.go @@ -23,6 +23,119 @@ import ( // SsfAPIService SsfAPI service type SsfAPIService service +type ApiSsfStreamsDestroyRequest struct { + ctx context.Context + ApiService *SsfAPIService + uuid string +} + +func (r ApiSsfStreamsDestroyRequest) Execute() (*http.Response, error) { + return r.ApiService.SsfStreamsDestroyExecute(r) +} + +/* +SsfStreamsDestroy Method for SsfStreamsDestroy + +SSFStream Viewset + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param uuid A UUID string identifying this SSF Stream. + @return ApiSsfStreamsDestroyRequest +*/ +func (a *SsfAPIService) SsfStreamsDestroy(ctx context.Context, uuid string) ApiSsfStreamsDestroyRequest { + return ApiSsfStreamsDestroyRequest{ + ApiService: a, + ctx: ctx, + uuid: uuid, + } +} + +// Execute executes the request +func (a *SsfAPIService) SsfStreamsDestroyExecute(r ApiSsfStreamsDestroyRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SsfAPIService.SsfStreamsDestroy") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/ssf/streams/{uuid}/" + localVarPath = strings.Replace(localVarPath, "{"+"uuid"+"}", url.PathEscape(parameterValueToString(r.uuid, "uuid")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v GenericError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type ApiSsfStreamsListRequest struct { ctx context.Context ApiService *SsfAPIService diff --git a/packages/client-go/model_delivery_method_enum.go b/packages/client-go/model_delivery_method_enum.go index 4c073bd435..dd8775e260 100644 --- a/packages/client-go/model_delivery_method_enum.go +++ b/packages/client-go/model_delivery_method_enum.go @@ -23,12 +23,16 @@ type DeliveryMethodEnum string const ( DELIVERYMETHODENUM_HTTPS___SCHEMAS_OPENID_NET_SECEVENT_RISC_DELIVERY_METHOD_PUSH DeliveryMethodEnum = "https://schemas.openid.net/secevent/risc/delivery-method/push" DELIVERYMETHODENUM_HTTPS___SCHEMAS_OPENID_NET_SECEVENT_RISC_DELIVERY_METHOD_POLL DeliveryMethodEnum = "https://schemas.openid.net/secevent/risc/delivery-method/poll" + DELIVERYMETHODENUM_URN_IETF_RFC_8935 DeliveryMethodEnum = "urn:ietf:rfc:8935" + DELIVERYMETHODENUM_URN_IETF_RFC_8936 DeliveryMethodEnum = "urn:ietf:rfc:8936" ) // All allowed values of DeliveryMethodEnum enum var AllowedDeliveryMethodEnumEnumValues = []DeliveryMethodEnum{ "https://schemas.openid.net/secevent/risc/delivery-method/push", "https://schemas.openid.net/secevent/risc/delivery-method/poll", + "urn:ietf:rfc:8935", + "urn:ietf:rfc:8936", } func (v *DeliveryMethodEnum) UnmarshalJSON(src []byte) error { diff --git a/packages/client-go/model_patched_ssf_provider_request.go b/packages/client-go/model_patched_ssf_provider_request.go index 84712b3b5d..39a8c0ed9b 100644 --- a/packages/client-go/model_patched_ssf_provider_request.go +++ b/packages/client-go/model_patched_ssf_provider_request.go @@ -22,10 +22,11 @@ var _ MappedNullable = &PatchedSSFProviderRequest{} type PatchedSSFProviderRequest struct { Name *string `json:"name,omitempty"` // Key used to sign the SSF Events. - SigningKey *string `json:"signing_key,omitempty"` - OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` - EventRetention *string `json:"event_retention,omitempty"` - AdditionalProperties map[string]interface{} + SigningKey *string `json:"signing_key,omitempty"` + OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` + EventRetention *string `json:"event_retention,omitempty"` + PushVerifyCertificates *bool `json:"push_verify_certificates,omitempty"` + AdditionalProperties map[string]interface{} } type _PatchedSSFProviderRequest PatchedSSFProviderRequest @@ -175,6 +176,38 @@ func (o *PatchedSSFProviderRequest) SetEventRetention(v string) { o.EventRetention = &v } +// GetPushVerifyCertificates returns the PushVerifyCertificates field value if set, zero value otherwise. +func (o *PatchedSSFProviderRequest) GetPushVerifyCertificates() bool { + if o == nil || IsNil(o.PushVerifyCertificates) { + var ret bool + return ret + } + return *o.PushVerifyCertificates +} + +// GetPushVerifyCertificatesOk returns a tuple with the PushVerifyCertificates field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PatchedSSFProviderRequest) GetPushVerifyCertificatesOk() (*bool, bool) { + if o == nil || IsNil(o.PushVerifyCertificates) { + return nil, false + } + return o.PushVerifyCertificates, true +} + +// HasPushVerifyCertificates returns a boolean if a field has been set. +func (o *PatchedSSFProviderRequest) HasPushVerifyCertificates() bool { + if o != nil && !IsNil(o.PushVerifyCertificates) { + return true + } + + return false +} + +// SetPushVerifyCertificates gets a reference to the given bool and assigns it to the PushVerifyCertificates field. +func (o *PatchedSSFProviderRequest) SetPushVerifyCertificates(v bool) { + o.PushVerifyCertificates = &v +} + func (o PatchedSSFProviderRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -197,6 +230,9 @@ func (o PatchedSSFProviderRequest) ToMap() (map[string]interface{}, error) { if !IsNil(o.EventRetention) { toSerialize["event_retention"] = o.EventRetention } + if !IsNil(o.PushVerifyCertificates) { + toSerialize["push_verify_certificates"] = o.PushVerifyCertificates + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -223,6 +259,7 @@ func (o *PatchedSSFProviderRequest) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "signing_key") delete(additionalProperties, "oidc_auth_providers") delete(additionalProperties, "event_retention") + delete(additionalProperties, "push_verify_certificates") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_ssf_provider.go b/packages/client-go/model_ssf_provider.go index 3ea283366a..bf820eec1e 100644 --- a/packages/client-go/model_ssf_provider.go +++ b/packages/client-go/model_ssf_provider.go @@ -32,13 +32,14 @@ type SSFProvider struct { // Return internal model name MetaModelName string `json:"meta_model_name"` // Key used to sign the SSF Events. - SigningKey string `json:"signing_key"` - TokenObj Token `json:"token_obj"` - OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` - OidcAuthProvidersObj []Provider `json:"oidc_auth_providers_obj"` - SsfUrl NullableString `json:"ssf_url"` - EventRetention *string `json:"event_retention,omitempty"` - AdditionalProperties map[string]interface{} + SigningKey string `json:"signing_key"` + TokenObj Token `json:"token_obj"` + OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` + OidcAuthProvidersObj []Provider `json:"oidc_auth_providers_obj"` + SsfUrl NullableString `json:"ssf_url"` + EventRetention *string `json:"event_retention,omitempty"` + PushVerifyCertificates *bool `json:"push_verify_certificates,omitempty"` + AdditionalProperties map[string]interface{} } type _SSFProvider SSFProvider @@ -376,6 +377,38 @@ func (o *SSFProvider) SetEventRetention(v string) { o.EventRetention = &v } +// GetPushVerifyCertificates returns the PushVerifyCertificates field value if set, zero value otherwise. +func (o *SSFProvider) GetPushVerifyCertificates() bool { + if o == nil || IsNil(o.PushVerifyCertificates) { + var ret bool + return ret + } + return *o.PushVerifyCertificates +} + +// GetPushVerifyCertificatesOk returns a tuple with the PushVerifyCertificates field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SSFProvider) GetPushVerifyCertificatesOk() (*bool, bool) { + if o == nil || IsNil(o.PushVerifyCertificates) { + return nil, false + } + return o.PushVerifyCertificates, true +} + +// HasPushVerifyCertificates returns a boolean if a field has been set. +func (o *SSFProvider) HasPushVerifyCertificates() bool { + if o != nil && !IsNil(o.PushVerifyCertificates) { + return true + } + + return false +} + +// SetPushVerifyCertificates gets a reference to the given bool and assigns it to the PushVerifyCertificates field. +func (o *SSFProvider) SetPushVerifyCertificates(v bool) { + o.PushVerifyCertificates = &v +} + func (o SSFProvider) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -402,6 +435,9 @@ func (o SSFProvider) ToMap() (map[string]interface{}, error) { if !IsNil(o.EventRetention) { toSerialize["event_retention"] = o.EventRetention } + if !IsNil(o.PushVerifyCertificates) { + toSerialize["push_verify_certificates"] = o.PushVerifyCertificates + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -466,6 +502,7 @@ func (o *SSFProvider) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "oidc_auth_providers_obj") delete(additionalProperties, "ssf_url") delete(additionalProperties, "event_retention") + delete(additionalProperties, "push_verify_certificates") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_ssf_provider_request.go b/packages/client-go/model_ssf_provider_request.go index 4c4bf774bb..ac362f53b5 100644 --- a/packages/client-go/model_ssf_provider_request.go +++ b/packages/client-go/model_ssf_provider_request.go @@ -23,10 +23,11 @@ var _ MappedNullable = &SSFProviderRequest{} type SSFProviderRequest struct { Name string `json:"name"` // Key used to sign the SSF Events. - SigningKey string `json:"signing_key"` - OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` - EventRetention *string `json:"event_retention,omitempty"` - AdditionalProperties map[string]interface{} + SigningKey string `json:"signing_key"` + OidcAuthProviders []int32 `json:"oidc_auth_providers,omitempty"` + EventRetention *string `json:"event_retention,omitempty"` + PushVerifyCertificates *bool `json:"push_verify_certificates,omitempty"` + AdditionalProperties map[string]interface{} } type _SSFProviderRequest SSFProviderRequest @@ -162,6 +163,38 @@ func (o *SSFProviderRequest) SetEventRetention(v string) { o.EventRetention = &v } +// GetPushVerifyCertificates returns the PushVerifyCertificates field value if set, zero value otherwise. +func (o *SSFProviderRequest) GetPushVerifyCertificates() bool { + if o == nil || IsNil(o.PushVerifyCertificates) { + var ret bool + return ret + } + return *o.PushVerifyCertificates +} + +// GetPushVerifyCertificatesOk returns a tuple with the PushVerifyCertificates field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SSFProviderRequest) GetPushVerifyCertificatesOk() (*bool, bool) { + if o == nil || IsNil(o.PushVerifyCertificates) { + return nil, false + } + return o.PushVerifyCertificates, true +} + +// HasPushVerifyCertificates returns a boolean if a field has been set. +func (o *SSFProviderRequest) HasPushVerifyCertificates() bool { + if o != nil && !IsNil(o.PushVerifyCertificates) { + return true + } + + return false +} + +// SetPushVerifyCertificates gets a reference to the given bool and assigns it to the PushVerifyCertificates field. +func (o *SSFProviderRequest) SetPushVerifyCertificates(v bool) { + o.PushVerifyCertificates = &v +} + func (o SSFProviderRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -180,6 +213,9 @@ func (o SSFProviderRequest) ToMap() (map[string]interface{}, error) { if !IsNil(o.EventRetention) { toSerialize["event_retention"] = o.EventRetention } + if !IsNil(o.PushVerifyCertificates) { + toSerialize["push_verify_certificates"] = o.PushVerifyCertificates + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -228,6 +264,7 @@ func (o *SSFProviderRequest) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "signing_key") delete(additionalProperties, "oidc_auth_providers") delete(additionalProperties, "event_retention") + delete(additionalProperties, "push_verify_certificates") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_ssf_stream.go b/packages/client-go/model_ssf_stream.go index d40e17f020..169aae7b82 100644 --- a/packages/client-go/model_ssf_stream.go +++ b/packages/client-go/model_ssf_stream.go @@ -22,6 +22,7 @@ var _ MappedNullable = &SSFStream{} // SSFStream SSFStream Serializer type SSFStream struct { Pk string `json:"pk"` + Status *SSFStreamStatusEnum `json:"status,omitempty"` Provider int32 `json:"provider"` ProviderObj SSFProvider `json:"provider_obj"` DeliveryMethod DeliveryMethodEnum `json:"delivery_method"` @@ -82,6 +83,38 @@ func (o *SSFStream) SetPk(v string) { o.Pk = v } +// GetStatus returns the Status field value if set, zero value otherwise. +func (o *SSFStream) GetStatus() SSFStreamStatusEnum { + if o == nil || IsNil(o.Status) { + var ret SSFStreamStatusEnum + return ret + } + return *o.Status +} + +// GetStatusOk returns a tuple with the Status field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SSFStream) GetStatusOk() (*SSFStreamStatusEnum, bool) { + if o == nil || IsNil(o.Status) { + return nil, false + } + return o.Status, true +} + +// HasStatus returns a boolean if a field has been set. +func (o *SSFStream) HasStatus() bool { + if o != nil && !IsNil(o.Status) { + return true + } + + return false +} + +// SetStatus gets a reference to the given SSFStreamStatusEnum and assigns it to the Status field. +func (o *SSFStream) SetStatus(v SSFStreamStatusEnum) { + o.Status = &v +} + // GetProvider returns the Provider field value func (o *SSFStream) GetProvider() int32 { if o == nil { @@ -320,6 +353,9 @@ func (o SSFStream) MarshalJSON() ([]byte, error) { func (o SSFStream) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["pk"] = o.Pk + if !IsNil(o.Status) { + toSerialize["status"] = o.Status + } toSerialize["provider"] = o.Provider toSerialize["provider_obj"] = o.ProviderObj toSerialize["delivery_method"] = o.DeliveryMethod @@ -383,6 +419,7 @@ func (o *SSFStream) UnmarshalJSON(data []byte) (err error) { if err = json.Unmarshal(data, &additionalProperties); err == nil { delete(additionalProperties, "pk") + delete(additionalProperties, "status") delete(additionalProperties, "provider") delete(additionalProperties, "provider_obj") delete(additionalProperties, "delivery_method") diff --git a/packages/client-go/model_ssf_stream_status_enum.go b/packages/client-go/model_ssf_stream_status_enum.go new file mode 100644 index 0000000000..b45731afe7 --- /dev/null +++ b/packages/client-go/model_ssf_stream_status_enum.go @@ -0,0 +1,113 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.5.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// SSFStreamStatusEnum the model 'SSFStreamStatusEnum' +type SSFStreamStatusEnum string + +// List of SSFStreamStatusEnum +const ( + SSFSTREAMSTATUSENUM_ENABLED SSFStreamStatusEnum = "enabled" + SSFSTREAMSTATUSENUM_PAUSED SSFStreamStatusEnum = "paused" + SSFSTREAMSTATUSENUM_DISABLED SSFStreamStatusEnum = "disabled" +) + +// All allowed values of SSFStreamStatusEnum enum +var AllowedSSFStreamStatusEnumEnumValues = []SSFStreamStatusEnum{ + "enabled", + "paused", + "disabled", +} + +func (v *SSFStreamStatusEnum) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := SSFStreamStatusEnum(value) + for _, existing := range AllowedSSFStreamStatusEnumEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid SSFStreamStatusEnum", value) +} + +// NewSSFStreamStatusEnumFromValue returns a pointer to a valid SSFStreamStatusEnum +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewSSFStreamStatusEnumFromValue(v string) (*SSFStreamStatusEnum, error) { + ev := SSFStreamStatusEnum(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for SSFStreamStatusEnum: valid values are %v", v, AllowedSSFStreamStatusEnumEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v SSFStreamStatusEnum) IsValid() bool { + for _, existing := range AllowedSSFStreamStatusEnumEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to SSFStreamStatusEnum value +func (v SSFStreamStatusEnum) Ptr() *SSFStreamStatusEnum { + return &v +} + +type NullableSSFStreamStatusEnum struct { + value *SSFStreamStatusEnum + isSet bool +} + +func (v NullableSSFStreamStatusEnum) Get() *SSFStreamStatusEnum { + return v.value +} + +func (v *NullableSSFStreamStatusEnum) Set(val *SSFStreamStatusEnum) { + v.value = val + v.isSet = true +} + +func (v NullableSSFStreamStatusEnum) IsSet() bool { + return v.isSet +} + +func (v *NullableSSFStreamStatusEnum) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSSFStreamStatusEnum(val *SSFStreamStatusEnum) *NullableSSFStreamStatusEnum { + return &NullableSSFStreamStatusEnum{value: val, isSet: true} +} + +func (v NullableSSFStreamStatusEnum) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSSFStreamStatusEnum) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-rust/src/apis/ssf_api.rs b/packages/client-rust/src/apis/ssf_api.rs index 122fc4cc1d..1f0b3e029c 100644 --- a/packages/client-rust/src/apis/ssf_api.rs +++ b/packages/client-rust/src/apis/ssf_api.rs @@ -12,6 +12,15 @@ use serde::{Deserialize, Serialize, de::Error as _}; use super::{ContentType, Error, configuration}; use crate::{apis::ResponseContent, models}; +/// struct for typed errors of method [`ssf_streams_destroy`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SsfStreamsDestroyError { + Status400(models::ValidationError), + Status403(models::GenericError), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`ssf_streams_list`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -30,6 +39,48 @@ pub enum SsfStreamsRetrieveError { UnknownValue(serde_json::Value), } +/// SSFStream Viewset +pub async fn ssf_streams_destroy( + configuration: &configuration::Configuration, + uuid: &str, +) -> Result<(), Error> { + // add a prefix to parameters to efficiently prevent name collisions + let p_path_uuid = uuid; + + let uri_str = format!( + "{}/ssf/streams/{uuid}/", + configuration.base_path, + uuid = crate::apis::urlencode(p_path_uuid) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::DELETE, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + if !status.is_client_error() && !status.is_server_error() { + Ok(()) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + /// SSFStream Viewset pub async fn ssf_streams_list( configuration: &configuration::Configuration, diff --git a/packages/client-rust/src/models/delivery_method_enum.rs b/packages/client-rust/src/models/delivery_method_enum.rs index 2d5d0ebe7b..75752a90d9 100644 --- a/packages/client-rust/src/models/delivery_method_enum.rs +++ b/packages/client-rust/src/models/delivery_method_enum.rs @@ -17,6 +17,10 @@ pub enum DeliveryMethodEnum { HttpsColonSlashSlashSchemasOpenidNetSlashSeceventSlashRiscSlashDeliveryMethodSlashPush, #[serde(rename = "https://schemas.openid.net/secevent/risc/delivery-method/poll")] HttpsColonSlashSlashSchemasOpenidNetSlashSeceventSlashRiscSlashDeliveryMethodSlashPoll, + #[serde(rename = "urn:ietf:rfc:8935")] + UrnColonIetfColonRfcColon8935, + #[serde(rename = "urn:ietf:rfc:8936")] + UrnColonIetfColonRfcColon8936, } impl std::fmt::Display for DeliveryMethodEnum { @@ -24,6 +28,8 @@ impl std::fmt::Display for DeliveryMethodEnum { match self { Self::HttpsColonSlashSlashSchemasOpenidNetSlashSeceventSlashRiscSlashDeliveryMethodSlashPush => write!(f, "https://schemas.openid.net/secevent/risc/delivery-method/push"), Self::HttpsColonSlashSlashSchemasOpenidNetSlashSeceventSlashRiscSlashDeliveryMethodSlashPoll => write!(f, "https://schemas.openid.net/secevent/risc/delivery-method/poll"), + Self::UrnColonIetfColonRfcColon8935 => write!(f, "urn:ietf:rfc:8935"), + Self::UrnColonIetfColonRfcColon8936 => write!(f, "urn:ietf:rfc:8936"), } } } diff --git a/packages/client-rust/src/models/mod.rs b/packages/client-rust/src/models/mod.rs index 20ab5838d6..298a86d252 100644 --- a/packages/client-rust/src/models/mod.rs +++ b/packages/client-rust/src/models/mod.rs @@ -1528,6 +1528,8 @@ pub mod ssf_provider_request; pub use self::ssf_provider_request::SsfProviderRequest; pub mod ssf_stream; pub use self::ssf_stream::SsfStream; +pub mod ssf_stream_status_enum; +pub use self::ssf_stream_status_enum::SsfStreamStatusEnum; pub mod stage; pub use self::stage::Stage; pub mod stage_mode_enum; diff --git a/packages/client-rust/src/models/patched_ssf_provider_request.rs b/packages/client-rust/src/models/patched_ssf_provider_request.rs index 47442f1ab5..2b4971fb03 100644 --- a/packages/client-rust/src/models/patched_ssf_provider_request.rs +++ b/packages/client-rust/src/models/patched_ssf_provider_request.rs @@ -25,6 +25,11 @@ pub struct PatchedSsfProviderRequest { pub oidc_auth_providers: Option>, #[serde(rename = "event_retention", skip_serializing_if = "Option::is_none")] pub event_retention: Option, + #[serde( + rename = "push_verify_certificates", + skip_serializing_if = "Option::is_none" + )] + pub push_verify_certificates: Option, } impl PatchedSsfProviderRequest { @@ -35,6 +40,7 @@ impl PatchedSsfProviderRequest { signing_key: None, oidc_auth_providers: None, event_retention: None, + push_verify_certificates: None, } } } diff --git a/packages/client-rust/src/models/ssf_provider.rs b/packages/client-rust/src/models/ssf_provider.rs index 62c9cb09b2..6e489955ef 100644 --- a/packages/client-rust/src/models/ssf_provider.rs +++ b/packages/client-rust/src/models/ssf_provider.rs @@ -45,6 +45,11 @@ pub struct SsfProvider { pub ssf_url: Option, #[serde(rename = "event_retention", skip_serializing_if = "Option::is_none")] pub event_retention: Option, + #[serde( + rename = "push_verify_certificates", + skip_serializing_if = "Option::is_none" + )] + pub push_verify_certificates: Option, } impl SsfProvider { @@ -74,6 +79,7 @@ impl SsfProvider { oidc_auth_providers_obj, ssf_url, event_retention: None, + push_verify_certificates: None, } } } diff --git a/packages/client-rust/src/models/ssf_provider_request.rs b/packages/client-rust/src/models/ssf_provider_request.rs index ab0e13eb03..4d7914414d 100644 --- a/packages/client-rust/src/models/ssf_provider_request.rs +++ b/packages/client-rust/src/models/ssf_provider_request.rs @@ -25,6 +25,11 @@ pub struct SsfProviderRequest { pub oidc_auth_providers: Option>, #[serde(rename = "event_retention", skip_serializing_if = "Option::is_none")] pub event_retention: Option, + #[serde( + rename = "push_verify_certificates", + skip_serializing_if = "Option::is_none" + )] + pub push_verify_certificates: Option, } impl SsfProviderRequest { @@ -35,6 +40,7 @@ impl SsfProviderRequest { signing_key, oidc_auth_providers: None, event_retention: None, + push_verify_certificates: None, } } } diff --git a/packages/client-rust/src/models/ssf_stream.rs b/packages/client-rust/src/models/ssf_stream.rs index 851d7baee3..3c1b5ea622 100644 --- a/packages/client-rust/src/models/ssf_stream.rs +++ b/packages/client-rust/src/models/ssf_stream.rs @@ -15,6 +15,8 @@ use crate::models; pub struct SsfStream { #[serde(rename = "pk")] pub pk: uuid::Uuid, + #[serde(rename = "status", skip_serializing_if = "Option::is_none")] + pub status: Option, #[serde(rename = "provider")] pub provider: i32, #[serde(rename = "provider_obj")] @@ -50,6 +52,7 @@ impl SsfStream { ) -> SsfStream { SsfStream { pk, + status: None, provider, provider_obj, delivery_method, diff --git a/packages/client-rust/src/models/ssf_stream_status_enum.rs b/packages/client-rust/src/models/ssf_stream_status_enum.rs new file mode 100644 index 0000000000..b7df0f67c3 --- /dev/null +++ b/packages/client-rust/src/models/ssf_stream_status_enum.rs @@ -0,0 +1,38 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.5.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum SsfStreamStatusEnum { + #[serde(rename = "enabled")] + Enabled, + #[serde(rename = "paused")] + Paused, + #[serde(rename = "disabled")] + Disabled, +} + +impl std::fmt::Display for SsfStreamStatusEnum { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Enabled => write!(f, "enabled"), + Self::Paused => write!(f, "paused"), + Self::Disabled => write!(f, "disabled"), + } + } +} + +impl Default for SsfStreamStatusEnum { + fn default() -> SsfStreamStatusEnum { + Self::Enabled + } +} diff --git a/packages/client-ts/Makefile b/packages/client-ts/Makefile index b07a718032..cb692d021e 100644 --- a/packages/client-ts/Makefile +++ b/packages/client-ts/Makefile @@ -15,4 +15,4 @@ build: --git-repo-id authentik \ --git-user-id goauthentik rm -rf "${PWD}/.openapi-generator" - npx prettier --cache --write -u "${PWD}" + npx prettier --write -u "${PWD}" diff --git a/packages/client-ts/src/apis/SsfApi.ts b/packages/client-ts/src/apis/SsfApi.ts index 925a9e9127..b3532eab04 100644 --- a/packages/client-ts/src/apis/SsfApi.ts +++ b/packages/client-ts/src/apis/SsfApi.ts @@ -16,6 +16,10 @@ import type { DeliveryMethodEnum, PaginatedSSFStreamList, SSFStream } from "../m import { PaginatedSSFStreamListFromJSON, SSFStreamFromJSON } from "../models/index"; import * as runtime from "../runtime"; +export interface SsfStreamsDestroyRequest { + uuid: string; +} + export interface SsfStreamsListRequest { deliveryMethod?: DeliveryMethodEnum; endpointUrl?: string; @@ -34,6 +38,69 @@ export interface SsfStreamsRetrieveRequest { * */ export class SsfApi extends runtime.BaseAPI { + /** + * Creates request options for ssfStreamsDestroy without sending the request + */ + async ssfStreamsDestroyRequestOpts( + requestParameters: SsfStreamsDestroyRequest, + ): Promise { + if (requestParameters["uuid"] == null) { + throw new runtime.RequiredError( + "uuid", + 'Required parameter "uuid" was null or undefined when calling ssfStreamsDestroy().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("authentik", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/ssf/streams/{uuid}/`; + urlPath = urlPath.replace( + `{${"uuid"}}`, + encodeURIComponent(String(requestParameters["uuid"])), + ); + + return { + path: urlPath, + method: "DELETE", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * SSFStream Viewset + */ + async ssfStreamsDestroyRaw( + requestParameters: SsfStreamsDestroyRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.ssfStreamsDestroyRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * SSFStream Viewset + */ + async ssfStreamsDestroy( + requestParameters: SsfStreamsDestroyRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + await this.ssfStreamsDestroyRaw(requestParameters, initOverrides); + } + /** * Creates request options for ssfStreamsList without sending the request */ diff --git a/packages/client-ts/src/models/DeliveryMethodEnum.ts b/packages/client-ts/src/models/DeliveryMethodEnum.ts index d21567e543..2a76cac1f8 100644 --- a/packages/client-ts/src/models/DeliveryMethodEnum.ts +++ b/packages/client-ts/src/models/DeliveryMethodEnum.ts @@ -21,6 +21,8 @@ export const DeliveryMethodEnum = { "https://schemas.openid.net/secevent/risc/delivery-method/push", HttpsSchemasOpenidNetSeceventRiscDeliveryMethodPoll: "https://schemas.openid.net/secevent/risc/delivery-method/poll", + UrnIetfRfc8935: "urn:ietf:rfc:8935", + UrnIetfRfc8936: "urn:ietf:rfc:8936", UnknownDefaultOpenApi: "11184809", } as const; export type DeliveryMethodEnum = (typeof DeliveryMethodEnum)[keyof typeof DeliveryMethodEnum]; diff --git a/packages/client-ts/src/models/PatchedSSFProviderRequest.ts b/packages/client-ts/src/models/PatchedSSFProviderRequest.ts index 674af80683..e3eb13808d 100644 --- a/packages/client-ts/src/models/PatchedSSFProviderRequest.ts +++ b/packages/client-ts/src/models/PatchedSSFProviderRequest.ts @@ -42,6 +42,12 @@ export interface PatchedSSFProviderRequest { * @memberof PatchedSSFProviderRequest */ eventRetention?: string; + /** + * + * @type {boolean} + * @memberof PatchedSSFProviderRequest + */ + pushVerifyCertificates?: boolean; } /** @@ -70,6 +76,8 @@ export function PatchedSSFProviderRequestFromJSONTyped( oidcAuthProviders: json["oidc_auth_providers"] == null ? undefined : json["oidc_auth_providers"], eventRetention: json["event_retention"] == null ? undefined : json["event_retention"], + pushVerifyCertificates: + json["push_verify_certificates"] == null ? undefined : json["push_verify_certificates"], }; } @@ -90,5 +98,6 @@ export function PatchedSSFProviderRequestToJSONTyped( signing_key: value["signingKey"], oidc_auth_providers: value["oidcAuthProviders"], event_retention: value["eventRetention"], + push_verify_certificates: value["pushVerifyCertificates"], }; } diff --git a/packages/client-ts/src/models/SSFProvider.ts b/packages/client-ts/src/models/SSFProvider.ts index 4bd0f68e77..9bc0ff0887 100644 --- a/packages/client-ts/src/models/SSFProvider.ts +++ b/packages/client-ts/src/models/SSFProvider.ts @@ -95,6 +95,12 @@ export interface SSFProvider { * @memberof SSFProvider */ eventRetention?: string; + /** + * + * @type {boolean} + * @memberof SSFProvider + */ + pushVerifyCertificates?: boolean; } /** @@ -137,6 +143,8 @@ export function SSFProviderFromJSONTyped(json: any, ignoreDiscriminator: boolean oidcAuthProvidersObj: (json["oidc_auth_providers_obj"] as Array).map(ProviderFromJSON), ssfUrl: json["ssf_url"], eventRetention: json["event_retention"] == null ? undefined : json["event_retention"], + pushVerifyCertificates: + json["push_verify_certificates"] == null ? undefined : json["push_verify_certificates"], }; } @@ -167,5 +175,6 @@ export function SSFProviderToJSONTyped( signing_key: value["signingKey"], oidc_auth_providers: value["oidcAuthProviders"], event_retention: value["eventRetention"], + push_verify_certificates: value["pushVerifyCertificates"], }; } diff --git a/packages/client-ts/src/models/SSFProviderRequest.ts b/packages/client-ts/src/models/SSFProviderRequest.ts index 723f87e72c..d8e6c7c3e3 100644 --- a/packages/client-ts/src/models/SSFProviderRequest.ts +++ b/packages/client-ts/src/models/SSFProviderRequest.ts @@ -42,6 +42,12 @@ export interface SSFProviderRequest { * @memberof SSFProviderRequest */ eventRetention?: string; + /** + * + * @type {boolean} + * @memberof SSFProviderRequest + */ + pushVerifyCertificates?: boolean; } /** @@ -70,6 +76,8 @@ export function SSFProviderRequestFromJSONTyped( oidcAuthProviders: json["oidc_auth_providers"] == null ? undefined : json["oidc_auth_providers"], eventRetention: json["event_retention"] == null ? undefined : json["event_retention"], + pushVerifyCertificates: + json["push_verify_certificates"] == null ? undefined : json["push_verify_certificates"], }; } @@ -90,5 +98,6 @@ export function SSFProviderRequestToJSONTyped( signing_key: value["signingKey"], oidc_auth_providers: value["oidcAuthProviders"], event_retention: value["eventRetention"], + push_verify_certificates: value["pushVerifyCertificates"], }; } diff --git a/packages/client-ts/src/models/SSFStream.ts b/packages/client-ts/src/models/SSFStream.ts index 5abbf46d88..9c2a5de687 100644 --- a/packages/client-ts/src/models/SSFStream.ts +++ b/packages/client-ts/src/models/SSFStream.ts @@ -18,6 +18,8 @@ import type { EventsRequestedEnum } from "./EventsRequestedEnum"; import { EventsRequestedEnumFromJSON, EventsRequestedEnumToJSON } from "./EventsRequestedEnum"; import type { SSFProvider } from "./SSFProvider"; import { SSFProviderFromJSON } from "./SSFProvider"; +import type { SSFStreamStatusEnum } from "./SSFStreamStatusEnum"; +import { SSFStreamStatusEnumFromJSON, SSFStreamStatusEnumToJSON } from "./SSFStreamStatusEnum"; /** * SSFStream Serializer @@ -31,6 +33,12 @@ export interface SSFStream { * @memberof SSFStream */ readonly pk: string; + /** + * + * @type {SSFStreamStatusEnum} + * @memberof SSFStream + */ + status?: SSFStreamStatusEnum; /** * * @type {number} @@ -104,6 +112,7 @@ export function SSFStreamFromJSONTyped(json: any, ignoreDiscriminator: boolean): } return { pk: json["pk"], + status: json["status"] == null ? undefined : SSFStreamStatusEnumFromJSON(json["status"]), provider: json["provider"], providerObj: SSFProviderFromJSON(json["provider_obj"]), deliveryMethod: DeliveryMethodEnumFromJSON(json["delivery_method"]), @@ -131,6 +140,7 @@ export function SSFStreamToJSONTyped( } return { + status: SSFStreamStatusEnumToJSON(value["status"]), provider: value["provider"], delivery_method: DeliveryMethodEnumToJSON(value["deliveryMethod"]), endpoint_url: value["endpointUrl"], diff --git a/packages/client-ts/src/models/SSFStreamStatusEnum.ts b/packages/client-ts/src/models/SSFStreamStatusEnum.ts new file mode 100644 index 0000000000..0a49d84a3c --- /dev/null +++ b/packages/client-ts/src/models/SSFStreamStatusEnum.ts @@ -0,0 +1,58 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const SSFStreamStatusEnum = { + Enabled: "enabled", + Paused: "paused", + Disabled: "disabled", + UnknownDefaultOpenApi: "11184809", +} as const; +export type SSFStreamStatusEnum = (typeof SSFStreamStatusEnum)[keyof typeof SSFStreamStatusEnum]; + +export function instanceOfSSFStreamStatusEnum(value: any): boolean { + for (const key in SSFStreamStatusEnum) { + if (Object.prototype.hasOwnProperty.call(SSFStreamStatusEnum, key)) { + if (SSFStreamStatusEnum[key as keyof typeof SSFStreamStatusEnum] === value) { + return true; + } + } + } + return false; +} + +export function SSFStreamStatusEnumFromJSON(json: any): SSFStreamStatusEnum { + return SSFStreamStatusEnumFromJSONTyped(json, false); +} + +export function SSFStreamStatusEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): SSFStreamStatusEnum { + return json as SSFStreamStatusEnum; +} + +export function SSFStreamStatusEnumToJSON(value?: SSFStreamStatusEnum | null): any { + return value as any; +} + +export function SSFStreamStatusEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): SSFStreamStatusEnum { + return value as SSFStreamStatusEnum; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 09cd87fe7a..ab2638432c 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -745,6 +745,7 @@ export * from "./SMSDeviceRequest"; export * from "./SSFProvider"; export * from "./SSFProviderRequest"; export * from "./SSFStream"; +export * from "./SSFStreamStatusEnum"; export * from "./Schedule"; export * from "./ScheduleRequest"; export * from "./ScopeMapping"; diff --git a/schema.yml b/schema.yml index 28f268e39f..d90cd2fcd8 100644 --- a/schema.yml +++ b/schema.yml @@ -26629,6 +26629,28 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + delete: + operationId: ssf_streams_destroy + 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: + '204': + description: No response body + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /stages/all/: get: operationId: stages_all_list @@ -36574,6 +36596,8 @@ components: enum: - https://schemas.openid.net/secevent/risc/delivery-method/push - https://schemas.openid.net/secevent/risc/delivery-method/poll + - urn:ietf:rfc:8935 + - urn:ietf:rfc:8936 type: string DeniedActionEnum: enum: @@ -50276,6 +50300,8 @@ components: event_retention: type: string minLength: 1 + push_verify_certificates: + type: boolean PatchedScheduleRequest: type: object properties: @@ -54696,6 +54722,8 @@ components: readOnly: true event_retention: type: string + push_verify_certificates: + type: boolean required: - component - meta_model_name @@ -54725,6 +54753,8 @@ components: event_retention: type: string minLength: 1 + push_verify_certificates: + type: boolean required: - name - signing_key @@ -54737,6 +54767,8 @@ components: format: uuid readOnly: true title: Uuid + status: + $ref: '#/components/schemas/SSFStreamStatusEnum' provider: type: integer provider_obj: @@ -54767,6 +54799,12 @@ components: - pk - provider - provider_obj + SSFStreamStatusEnum: + enum: + - enabled + - paused + - disabled + type: string Schedule: type: object properties: diff --git a/tests/openid_conformance/compose.yml b/tests/openid_conformance/compose.yml index 31c8daf48f..9d8debe497 100644 --- a/tests/openid_conformance/compose.yml +++ b/tests/openid_conformance/compose.yml @@ -1,15 +1,15 @@ services: mongodb: image: mongo:6.0.13 - httpd: - image: ghcr.io/beryju/oidc-conformance-suite-httpd:v5.1.32 + nginx: + image: ghcr.io/beryju/oidc-conformance-suite-nginx:v5.1.41 ports: - "8443:8443" - "8444:8444" depends_on: - server server: - image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.32 + image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.41 ports: - "9999:9999" extra_hosts: diff --git a/web/src/admin/providers/ssf/SSFProviderFormPage.ts b/web/src/admin/providers/ssf/SSFProviderFormPage.ts index ce36b306f5..7aaa7f3c5d 100644 --- a/web/src/admin/providers/ssf/SSFProviderFormPage.ts +++ b/web/src/admin/providers/ssf/SSFProviderFormPage.ts @@ -6,6 +6,7 @@ import "#elements/forms/FormGroup"; import "#elements/forms/HorizontalFormElement"; import "#elements/forms/SearchSelect/index"; import "#elements/utils/TimeDeltaHelp"; +import "#components/ak-switch-input"; import { DEFAULT_CONFIG } from "#common/api/config"; @@ -76,6 +77,12 @@ export class SSFProviderFormPage extends BaseProviderForm { >

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

+ + - ${msg("SSF Provider is in preview.")} - ${msg("Send us feedback!")} + return html`
+
+
+ ${renderDescriptionList([ + [msg("Name"), html`${this.provider.name}`], + [ + msg("URL"), + html``, + ], + [ + msg("Federated OAuth2/OpenID Providers"), + (this.provider.oidcAuthProvidersObj || []).length > 0 + ? html`
    + ${this.provider.oidcAuthProvidersObj.map((provider) => { + return html` +
  • + + ${provider.name} + +
  • + `; + })} +
` + : html`-`, + ], + [ + msg("Related actions"), + html` + ${msg("Save Changes")} + ${msg("Update SSF Provider")} + + + + `, + ], + ])} +
-
-
-
- ${renderDescriptionList([ - [msg("Name"), html`${this.provider.name}`], - [ - msg("URL"), - html``, - ], - [ - msg("Federated OAuth2/OpenID Providers"), - (this.provider.oidcAuthProvidersObj || []).length > 0 - ? html`
    - ${this.provider.oidcAuthProvidersObj.map((provider) => { - return html` -
  • - - ${provider.name} - -
  • - `; - })} -
` - : html`-`, - ], - [ - msg("Related actions"), - html` - ${msg("Save Changes")} - ${msg("Update SSF Provider")} - - - - `, - ], - ])} -
-
-
-
${msg("Streams")}
- - -
-
-
${msg("Tasks")}
- -
-
`; +
+
${msg("Streams")}
+ + +
+
+
${msg("Tasks")}
+ +
+
`; } } diff --git a/web/src/admin/providers/ssf/StreamTable.ts b/web/src/admin/providers/ssf/StreamTable.ts index cb888515dd..183530c448 100644 --- a/web/src/admin/providers/ssf/StreamTable.ts +++ b/web/src/admin/providers/ssf/StreamTable.ts @@ -8,17 +8,24 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; +import renderDescriptionList from "#components/DescriptionList"; + +import { SSFDeliveryMethodToLabel } from "#admin/providers/ssf/utils"; + import { SsfApi, SSFStream } from "@goauthentik/api"; import { msg } from "@lit/localize"; -import { html } from "lit"; +import { CSSResult, html, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; + @customElement("ak-provider-ssf-stream-list") export class SSFProviderStreamList extends Table { protected override searchEnabled = true; - checkbox = true; - clearOnRefresh = true; + public checkbox: boolean = true; + public clearOnRefresh: boolean = true; + public expandable: boolean = true; @property({ type: Number }) providerId?: number; @@ -26,13 +33,43 @@ export class SSFProviderStreamList extends Table { @property() order = "name"; + static styles: CSSResult[] = [...super.styles, PFDescriptionList]; + async apiEndpoint(): Promise> { return new SsfApi(DEFAULT_CONFIG).ssfStreamsList({ provider: this.providerId, ...(await this.defaultEndpointConfig()), + pageSize: 10, }); } + protected override renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return new SsfApi(DEFAULT_CONFIG).ssfStreamsDestroy({ + uuid: item.pk, + }); + }} + .metadata=${(item: SSFStream) => { + return [ + { key: msg("Audience"), value: item.aud }, + { + key: msg("Delivery method"), + value: SSFDeliveryMethodToLabel(item.deliveryMethod), + }, + { key: msg("Endpoint"), value: item.endpointUrl ?? "-" }, + ]; + }} + > + + `; + } + protected override rowLabel(item: SSFStream): string | null { return item.aud?.join(", ") ?? null; } @@ -40,10 +77,18 @@ export class SSFProviderStreamList extends Table { protected columns: TableColumn[] = [ // --- [msg("Audience"), "aud"], + [msg("Delivery Method"), "delivery_method"], ]; + protected renderExpanded(item: SSFStream): SlottedTemplateResult { + return html`${renderDescriptionList([ + [msg("Delivery method"), html`${SSFDeliveryMethodToLabel(item.deliveryMethod)}`], + [msg("Endpoint"), html`${item.endpointUrl ?? "-"}`], + ])}`; + } + row(item: SSFStream): SlottedTemplateResult[] { - return [html`${item.aud}`]; + return [html`${item.aud}`, html`${SSFDeliveryMethodToLabel(item.deliveryMethod)}`]; } } diff --git a/web/src/admin/providers/ssf/utils.ts b/web/src/admin/providers/ssf/utils.ts new file mode 100644 index 0000000000..b47eab0854 --- /dev/null +++ b/web/src/admin/providers/ssf/utils.ts @@ -0,0 +1,16 @@ +import { DeliveryMethodEnum } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; + +export function SSFDeliveryMethodToLabel(method?: DeliveryMethodEnum): string { + if (!method) return ""; + switch (method) { + case DeliveryMethodEnum.HttpsSchemasOpenidNetSeceventRiscDeliveryMethodPoll: + case DeliveryMethodEnum.UrnIetfRfc8936: + return msg("Pull"); + case DeliveryMethodEnum.HttpsSchemasOpenidNetSeceventRiscDeliveryMethodPush: + case DeliveryMethodEnum.UrnIetfRfc8935: + return msg("Push"); + } + return ""; +} diff --git a/website/docs/add-secure-apps/providers/ssf/index.md b/website/docs/add-secure-apps/providers/ssf/index.md index a8b2159a39..128ef22b33 100644 --- a/website/docs/add-secure-apps/providers/ssf/index.md +++ b/website/docs/add-secure-apps/providers/ssf/index.md @@ -4,8 +4,7 @@ sidebar_label: SSF Provider description: "Overview of SSF and the authentik SSF provider" authentik_version: "2025.2.0" authentik_enterprise: true -authentik_preview: true -tags: [Shared Signals Framework, SSF, Apple Business Manager] +tags: [Shared Signals Framework, SSF, Apple Business Manager, Apple School Manager] --- The Shared Signals Framework (SSF) provider allows you to integrate applications with the Shared Signals Framework protocol.