diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py index c284fed9d01b..c5c837ec426c 100644 --- a/authentik/providers/saml/api/providers.py +++ b/authentik/providers/saml/api/providers.py @@ -16,6 +16,7 @@ from rest_framework.fields import CharField, FileField, SerializerMethodField from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny +from rest_framework.renderers import BaseRenderer, JSONRenderer from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import PrimaryKeyRelatedField, ValidationError @@ -38,6 +39,16 @@ LOGGER = get_logger() +class RawXMLDataRenderer(BaseRenderer): + """Renderer to allow application/xml as value for 'Accept' in the metadata endpoint.""" + + media_type = "application/xml" + format = "xml" + + def render(self, data, accepted_media_type=None, renderer_context=None): + return data + + class SAMLProviderSerializer(ProviderSerializer): """SAMLProvider Serializer""" @@ -238,9 +249,21 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet): ], description="Optionally force the metadata to only include one binding.", ), + # Explicitly excluded, because otherwise spectacular automatically + # add it when using multiple renderer_classes + OpenApiParameter( + name="format", + exclude=True, + required=False, + ), ], ) - @action(methods=["GET"], detail=True, permission_classes=[AllowAny]) + @action( + methods=["GET"], + detail=True, + permission_classes=[AllowAny], + renderer_classes=[JSONRenderer, RawXMLDataRenderer], + ) def metadata(self, request: Request, pk: int) -> Response: """Return metadata as XML string""" # We don't use self.get_object() on purpose as this view is un-authenticated @@ -258,9 +281,9 @@ def metadata(self, request: Request, pk: int) -> Response: f'attachment; filename="{provider.name}_authentik_meta.xml"' ) return response - return Response({"metadata": metadata}) + return Response({"metadata": metadata}, content_type="application/json") except Provider.application.RelatedObjectDoesNotExist: - return Response({"metadata": ""}) + return Response({"metadata": ""}, content_type="application/json") @permission_required( None, diff --git a/authentik/providers/saml/tests/test_api.py b/authentik/providers/saml/tests/test_api.py index b0a27aeaf14c..e437cd04b7ba 100644 --- a/authentik/providers/saml/tests/test_api.py +++ b/authentik/providers/saml/tests/test_api.py @@ -104,6 +104,22 @@ def test_metadata_download(self): ) self.assertEqual(200, response.status_code) self.assertIn("Content-Disposition", response) + # Test download with Accept: application/xml + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}) + + "?download", + HTTP_ACCEPT="application/xml", + ) + self.assertEqual(200, response.status_code) + self.assertIn("Content-Disposition", response) + + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}) + + "?download", + HTTP_ACCEPT="application/xml;charset=UTF-8", + ) + self.assertEqual(200, response.status_code) + self.assertIn("Content-Disposition", response) def test_metadata_invalid(self): """Test metadata export (invalid)""" @@ -121,6 +137,11 @@ def test_metadata_invalid(self): reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}), ) self.assertEqual(404, response.status_code) + response = self.client.get( + reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}), + HTTP_ACCEPT="application/invalid-mime-type", + ) + self.assertEqual(406, response.status_code) def test_import_success(self): """Test metadata import (success case)""" diff --git a/schema.yml b/schema.yml index bcbe961d1293..7c01e39f5c54 100644 --- a/schema.yml +++ b/schema.yml @@ -22090,6 +22090,9 @@ paths: application/json: schema: $ref: '#/components/schemas/SAMLMetadata' + application/xml: + schema: + $ref: '#/components/schemas/SAMLMetadata' description: '' '404': description: Provider has no application assigned