@ -8,6 +8,6 @@ class AuthentikEnterpriseProviderApplePSSOConfig(EnterpriseConfig):
|
|||||||
verbose_name = "authentik Enterprise.Providers.Apple Platform SSO"
|
verbose_name = "authentik Enterprise.Providers.Apple Platform SSO"
|
||||||
default = True
|
default = True
|
||||||
mountpoints = {
|
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": "",
|
"authentik.enterprise.providers.apple_psso.urls_root": "",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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):
|
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()
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from authentik.enterprise.providers.apple_psso.views.nonce import NonceView
|
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 = [
|
urlpatterns = [
|
||||||
|
path("token/", TokenView.as_view(), name="token"),
|
||||||
path("nonce/", NonceView.as_view(), name="nonce"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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 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):
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
37
authentik/enterprise/providers/apple_psso/views/token.py
Normal file
37
authentik/enterprise/providers/apple_psso/views/token.py
Normal file
@ -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)
|
||||||
@ -15,9 +15,9 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
TENANT_APPS = [
|
TENANT_APPS = [
|
||||||
"authentik.enterprise.audit",
|
"authentik.enterprise.audit",
|
||||||
"authentik.enterprise.policies.unique_password",
|
"authentik.enterprise.policies.unique_password",
|
||||||
|
"authentik.enterprise.providers.apple_psso",
|
||||||
"authentik.enterprise.providers.google_workspace",
|
"authentik.enterprise.providers.google_workspace",
|
||||||
"authentik.enterprise.providers.microsoft_entra",
|
"authentik.enterprise.providers.microsoft_entra",
|
||||||
"authentik.enterprise.providers.apple_psso",
|
|
||||||
"authentik.enterprise.providers.ssf",
|
"authentik.enterprise.providers.ssf",
|
||||||
"authentik.enterprise.search",
|
"authentik.enterprise.search",
|
||||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||||
|
|||||||
@ -555,6 +555,8 @@ class TokenView(View):
|
|||||||
|
|
||||||
provider: OAuth2Provider | None = None
|
provider: OAuth2Provider | None = None
|
||||||
params: TokenParams | None = None
|
params: TokenParams | None = None
|
||||||
|
params_class = TokenParams
|
||||||
|
provider_class = OAuth2Provider
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||||
response = super().dispatch(request, *args, **kwargs)
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
@ -574,12 +576,14 @@ class TokenView(View):
|
|||||||
op="authentik.providers.oauth2.post.parse",
|
op="authentik.providers.oauth2.post.parse",
|
||||||
):
|
):
|
||||||
client_id, client_secret = extract_client_auth(request)
|
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:
|
if not self.provider:
|
||||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
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(
|
with start_span(
|
||||||
op="authentik.providers.oauth2.post.response",
|
op="authentik.providers.oauth2.post.response",
|
||||||
|
|||||||
Reference in New Issue
Block a user