From c8ac4fcdd6f031abbac89ba6128d6c6a6544b1bc Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 28 Jun 2025 18:15:59 +0200 Subject: [PATCH] snap Signed-off-by: Jens Langhammer --- .../enterprise/providers/apple_psso/apps.py | 2 +- .../apple_psso/migrations/0001_initial.py | 36 +++++++ ...ledeviceuser_appledevice_users_and_more.py | 94 ++++++++++++++++++ .../apple_psso/migrations/__init__.py | 0 .../enterprise/providers/apple_psso/models.py | 65 +++++++++++- .../enterprise/providers/apple_psso/urls.py | 10 +- .../providers/apple_psso/views/nonce.py | 23 ++++- .../providers/apple_psso/views/register.py | 98 ++++++++++++++++++- .../providers/apple_psso/views/token.py | 37 +++++++ authentik/enterprise/settings.py | 2 +- authentik/providers/oauth2/views/token.py | 8 +- 11 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 authentik/enterprise/providers/apple_psso/migrations/0001_initial.py create mode 100644 authentik/enterprise/providers/apple_psso/migrations/0002_appledevice_appledeviceuser_appledevice_users_and_more.py create mode 100644 authentik/enterprise/providers/apple_psso/migrations/__init__.py create mode 100644 authentik/enterprise/providers/apple_psso/views/token.py diff --git a/authentik/enterprise/providers/apple_psso/apps.py b/authentik/enterprise/providers/apple_psso/apps.py index a440bf1950..88905de02b 100644 --- a/authentik/enterprise/providers/apple_psso/apps.py +++ b/authentik/enterprise/providers/apple_psso/apps.py @@ -8,6 +8,6 @@ class AuthentikEnterpriseProviderApplePSSOConfig(EnterpriseConfig): verbose_name = "authentik Enterprise.Providers.Apple Platform SSO" default = True mountpoints = { - "authentik.enterprise.providers.apple_psso.urls": "application/apple_psso/", + "authentik.enterprise.providers.apple_psso.urls": "application/apple/sso/", "authentik.enterprise.providers.apple_psso.urls_root": "", } diff --git a/authentik/enterprise/providers/apple_psso/migrations/0001_initial.py b/authentik/enterprise/providers/apple_psso/migrations/0001_initial.py new file mode 100644 index 0000000000..d483399889 --- /dev/null +++ b/authentik/enterprise/providers/apple_psso/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.11 on 2025-06-28 00:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_providers_oauth2", "0028_migrate_session"), + ] + + operations = [ + migrations.CreateModel( + name="ApplePlatformSSOProvider", + fields=[ + ( + "oauth2provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_providers_oauth2.oauth2provider", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("authentik_providers_oauth2.oauth2provider",), + ), + ] diff --git a/authentik/enterprise/providers/apple_psso/migrations/0002_appledevice_appledeviceuser_appledevice_users_and_more.py b/authentik/enterprise/providers/apple_psso/migrations/0002_appledevice_appledeviceuser_appledevice_users_and_more.py new file mode 100644 index 0000000000..c42d120b58 --- /dev/null +++ b/authentik/enterprise/providers/apple_psso/migrations/0002_appledevice_appledeviceuser_appledevice_users_and_more.py @@ -0,0 +1,94 @@ +# Generated by Django 5.1.11 on 2025-06-28 15:50 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_apple_psso", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AppleDevice", + fields=[ + ( + "endpoint_uuid", + models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), + ), + ("signing_key", models.TextField()), + ("encryption_key", models.TextField()), + ("key_exchange_key", models.TextField()), + ("sign_key_id", models.TextField()), + ("enc_key_id", models.TextField()), + ("creation_time", models.DateTimeField(auto_now_add=True)), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_apple_psso.appleplatformssoprovider", + ), + ), + ], + ), + migrations.CreateModel( + name="AppleDeviceUser", + fields=[ + ("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("signing_key", models.TextField()), + ("encryption_key", models.TextField()), + ("sign_key_id", models.TextField()), + ("enc_key_id", models.TextField()), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_apple_psso.appledevice", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddField( + model_name="appledevice", + name="users", + field=models.ManyToManyField( + through="authentik_providers_apple_psso.AppleDeviceUser", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="AppleNonce", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("expires", models.DateTimeField(default=None, null=True)), + ("expiring", models.BooleanField(default=True)), + ("nonce", models.TextField()), + ], + options={ + "abstract": False, + "indexes": [ + models.Index(fields=["expires"], name="authentik_p_expires_47d534_idx"), + models.Index(fields=["expiring"], name="authentik_p_expirin_87253e_idx"), + models.Index( + fields=["expiring", "expires"], name="authentik_p_expirin_20a7c9_idx" + ), + ], + }, + ), + ] diff --git a/authentik/enterprise/providers/apple_psso/migrations/__init__.py b/authentik/enterprise/providers/apple_psso/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/apple_psso/models.py b/authentik/enterprise/providers/apple_psso/models.py index 26945f57c0..9d3c9b0a4d 100644 --- a/authentik/enterprise/providers/apple_psso/models.py +++ b/authentik/enterprise/providers/apple_psso/models.py @@ -1,5 +1,66 @@ -from authentik.providers.oauth2.models import OAuth2Provider +from uuid import uuid4 + +from django.db import models + +from authentik.core.models import ExpiringModel, User +from authentik.crypto.models import CertificateKeyPair +from authentik.providers.oauth2.models import ( + ClientTypes, + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, + ScopeMapping, +) +from authentik.stages.authenticator.models import Device class ApplePlatformSSOProvider(OAuth2Provider): - pass + + def set_oauth_defaults(self): + """Ensure all OAuth2-related settings are correct""" + self.client_type = ClientTypes.PUBLIC + self.signing_key = CertificateKeyPair.objects.get(name="authentik Self-signed Certificate") + self.include_claims_in_id_token = True + scopes = ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-offline_access", + "goauthentik.io/providers/oauth2/scope-authentik_api", + ] + ) + self.property_mappings.add(*list(scopes)) + self.redirect_uris = [ + RedirectURI(RedirectURIMatchingMode.STRICT, "io.goauthentik.endpoint:/oauth2redirect"), + ] + + +class AppleDevice(models.Model): + + endpoint_uuid = models.UUIDField(default=uuid4, primary_key=True) + + signing_key = models.TextField() + encryption_key = models.TextField() + key_exchange_key = models.TextField() + sign_key_id = models.TextField() + enc_key_id = models.TextField() + creation_time = models.DateTimeField(auto_now_add=True) + provider = models.ForeignKey(ApplePlatformSSOProvider, on_delete=models.CASCADE) + users = models.ManyToManyField(User, through="AppleDeviceUser") + + +class AppleDeviceUser(models.Model): + + uuid = models.UUIDField(default=uuid4, primary_key=True) + + device = models.ForeignKey(AppleDevice, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + signing_key = models.TextField() + encryption_key = models.TextField() + sign_key_id = models.TextField() + enc_key_id = models.TextField() + +class AppleNonce(ExpiringModel): + nonce = models.TextField() diff --git a/authentik/enterprise/providers/apple_psso/urls.py b/authentik/enterprise/providers/apple_psso/urls.py index 59e659d631..5a111e6330 100644 --- a/authentik/enterprise/providers/apple_psso/urls.py +++ b/authentik/enterprise/providers/apple_psso/urls.py @@ -1,9 +1,15 @@ from django.urls import path from authentik.enterprise.providers.apple_psso.views.nonce import NonceView -from authentik.enterprise.providers.apple_psso.views.register import RegisterView +from authentik.enterprise.providers.apple_psso.views.register import ( + RegisterDeviceView, + RegisterUserView, +) +from authentik.enterprise.providers.apple_psso.views.token import TokenView urlpatterns = [ + path("token/", TokenView.as_view(), name="token"), path("nonce/", NonceView.as_view(), name="nonce"), - path("register/", RegisterView.as_view(), name="register"), + path("register/device/", RegisterDeviceView.as_view(), name="register-device"), + path("register/user/", RegisterUserView.as_view(), name="register-user"), ] diff --git a/authentik/enterprise/providers/apple_psso/views/nonce.py b/authentik/enterprise/providers/apple_psso/views/nonce.py index bfea375948..411332c1ae 100644 --- a/authentik/enterprise/providers/apple_psso/views/nonce.py +++ b/authentik/enterprise/providers/apple_psso/views/nonce.py @@ -1,5 +1,26 @@ +from base64 import b64encode +from datetime import timedelta +from secrets import token_bytes + +from django.http import HttpRequest, JsonResponse +from django.utils.decorators import method_decorator +from django.utils.timezone import now from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from authentik.enterprise.providers.apple_psso.models import AppleNonce +@method_decorator(csrf_exempt, name="dispatch") class NonceView(View): - ... + + def post(self, request: HttpRequest, *args, **kwargs): + nonce = AppleNonce.objects.create( + nonce=b64encode(token_bytes(32)).decode(), expires=now() + timedelta(minutes=5) + ) + print(request.headers) + return JsonResponse( + { + "Nonce": nonce.nonce, + } + ) diff --git a/authentik/enterprise/providers/apple_psso/views/register.py b/authentik/enterprise/providers/apple_psso/views/register.py index c56030c817..21b26985c3 100644 --- a/authentik/enterprise/providers/apple_psso/views/register.py +++ b/authentik/enterprise/providers/apple_psso/views/register.py @@ -1,6 +1,98 @@ +from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework.authentication import BaseAuthentication +from rest_framework.fields import CharField +from rest_framework.relations import StringRelatedField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView -from django.views import View +from authentik.api.authentication import TokenAuthentication +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import User +from authentik.enterprise.providers.apple_psso.models import ( + AppleDevice, + AppleDeviceUser, + ApplePlatformSSOProvider, +) +from authentik.lib.generators import generate_key -class RegisterView(View): - ... +class DeviceRegisterAuth(BaseAuthentication): + def authenticate(self, request): + # very temporary, lol + return (User(), None) + + +class RegisterDeviceView(APIView): + + class DeviceRegistration(PassiveSerializer): + + device_uuid = CharField() + client_id = CharField() + device_signing_key = CharField() + device_encryption_key = CharField() + sign_key_id = CharField() + enc_key_id = CharField() + + permission_classes = [] + pagination_class = None + filter_backends = [] + serializer_class = DeviceRegistration + authentication_classes = [DeviceRegisterAuth, TokenAuthentication] + + def post(self, request: Request) -> Response: + data = self.DeviceRegistration(data=request.data) + data.is_valid(raise_exception=True) + provider = get_object_or_404( + ApplePlatformSSOProvider, client_id=data.validated_data["client_id"] + ) + AppleDevice.objects.update_or_create( + endpoint_uuid=data.validated_data["device_uuid"], + defaults={ + "signing_key": data.validated_data["device_signing_key"], + "encryption_key": data.validated_data["device_encryption_key"], + "sign_key_id": data.validated_data["sign_key_id"], + "enc_key_id": data.validated_data["enc_key_id"], + "key_exchange_key": generate_key(), + "provider": provider, + }, + ) + return Response() + + +class RegisterUserView(APIView): + + class UserRegistration(PassiveSerializer): + + device_uuid = CharField() + user_id = CharField() + user_signing_key = CharField() + user_encryption_key = CharField() + sign_key_id = CharField() + enc_key_id = CharField() + + permission_classes = [] + pagination_class = None + filter_backends = [] + serializer_class = UserRegistration + authentication_classes = [TokenAuthentication] + + def post(self, request: Request) -> Response: + data = self.UserRegistration(data=request.data) + data.is_valid(raise_exception=True) + device = get_object_or_404(AppleDevice, endpoint_uuid=data.validated_data["device_uuid"]) + user = get_object_or_404(User, username=data.validated_data["user_id"]) + AppleDeviceUser.objects.update_or_create( + device=device, + user=user, + defaults={ + "signing_key": data.validated_data["user_signing_key"], + "encryption_key": data.validated_data["user_encryption_key"], + "sign_key_id": data.validated_data["sign_key_id"], + "enc_key_id": data.validated_data["enc_key_id"], + }, + ) + return Response() diff --git a/authentik/enterprise/providers/apple_psso/views/token.py b/authentik/enterprise/providers/apple_psso/views/token.py new file mode 100644 index 0000000000..eeb3053330 --- /dev/null +++ b/authentik/enterprise/providers/apple_psso/views/token.py @@ -0,0 +1,37 @@ +from pprint import pprint + +from django.http import Http404, HttpRequest, HttpResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from jwcrypto.common import json_encode +from jwcrypto.jwe import JWE +from jwcrypto.jwk import JWK +from jwt import PyJWT, decode + +from authentik.enterprise.providers.apple_psso.models import AppleDevice, ApplePlatformSSOProvider + + +@method_decorator(csrf_exempt, name="dispatch") +class TokenView(View): + + def post(self, request: HttpRequest) -> HttpResponse: + version = request.POST.get("platform_sso_version") + print(version) + assertion = request.POST.get("assertion", request.POST.get("request")) + if not assertion: + return HttpResponse(status=400) + + decode_unvalidated = PyJWT().decode_complete(assertion, options={"verify_signature": False}) + expected_kid = decode_unvalidated["header"]["kid"] + + device = AppleDevice.objects.filter(sign_key_id=expected_kid).first() + if not device: + raise Http404 + + # Properly decode the JWT with the key from the device + decoded = decode( + assertion, device.signing_key, algorithms=["ES256"], options={"verify_aud": False} + ) + pprint(decoded) + return HttpResponse(status=400) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 772030abcd..2dcf8358dd 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -15,9 +15,9 @@ CELERY_BEAT_SCHEDULE = { TENANT_APPS = [ "authentik.enterprise.audit", "authentik.enterprise.policies.unique_password", + "authentik.enterprise.providers.apple_psso", "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", - "authentik.enterprise.providers.apple_psso", "authentik.enterprise.providers.ssf", "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index ba8d571d88..8df6c02936 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -555,6 +555,8 @@ class TokenView(View): provider: OAuth2Provider | None = None params: TokenParams | None = None + params_class = TokenParams + provider_class = OAuth2Provider def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: response = super().dispatch(request, *args, **kwargs) @@ -574,12 +576,14 @@ class TokenView(View): op="authentik.providers.oauth2.post.parse", ): client_id, client_secret = extract_client_auth(request) - self.provider = OAuth2Provider.objects.filter(client_id=client_id).first() + self.provider = self.provider_class.objects.filter(client_id=client_id).first() if not self.provider: LOGGER.warning("OAuth2Provider does not exist", client_id=client_id) raise TokenError("invalid_client") CTX_AUTH_VIA.set("oauth_client_secret") - self.params = TokenParams.parse(request, self.provider, client_id, client_secret) + self.params = self.params_class.parse( + request, self.provider, client_id, client_secret + ) with start_span( op="authentik.providers.oauth2.post.response",