From 3d63143c38a3225a139257606eae1ec72ad576cc Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Tue, 6 Aug 2024 14:00:01 +0200 Subject: [PATCH] brands: add OIDC webfinger support (#10400) * brands: add OIDC webfinger support for default application Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/brands/apps.py | 3 ++ authentik/brands/models.py | 11 +++++++ authentik/brands/tests.py | 46 ++++++++++++++++++++++++++++ authentik/brands/urls_root.py | 9 ++++++ authentik/brands/views/__init__.py | 0 authentik/brands/views/webfinger.py | 29 ++++++++++++++++++ authentik/providers/oauth2/models.py | 21 ++++++++++++- 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 authentik/brands/urls_root.py create mode 100644 authentik/brands/views/__init__.py create mode 100644 authentik/brands/views/webfinger.py diff --git a/authentik/brands/apps.py b/authentik/brands/apps.py index 43c20acc75..ce9681396a 100644 --- a/authentik/brands/apps.py +++ b/authentik/brands/apps.py @@ -9,3 +9,6 @@ class AuthentikBrandsConfig(AppConfig): name = "authentik.brands" label = "authentik_brands" verbose_name = "authentik Brands" + mountpoints = { + "authentik.brands.urls_root": "", + } diff --git a/authentik/brands/models.py b/authentik/brands/models.py index 9b4fe8e65d..3a7bc775cf 100644 --- a/authentik/brands/models.py +++ b/authentik/brands/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.db import models +from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger @@ -98,3 +99,13 @@ class Brand(SerializerModel): models.Index(fields=["domain"]), models.Index(fields=["default"]), ] + + +class WebfingerProvider(models.Model): + """Provider which supports webfinger discovery""" + + class Meta: + abstract = True + + def webfinger(self, resource: str, request: HttpRequest) -> dict: + raise NotImplementedError() diff --git a/authentik/brands/tests.py b/authentik/brands/tests.py index 712818fda1..69b8d10d71 100644 --- a/authentik/brands/tests.py +++ b/authentik/brands/tests.py @@ -5,7 +5,11 @@ from rest_framework.test import APITestCase from authentik.brands.api import Themes from authentik.brands.models import Brand +from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_brand +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.models import OAuth2Provider +from authentik.providers.saml.models import SAMLProvider class TestBrands(APITestCase): @@ -75,3 +79,45 @@ class TestBrands(APITestCase): reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True} ) self.assertEqual(response.status_code, 400) + + def test_webfinger_no_app(self): + """Test Webfinger""" + create_test_brand() + self.assertJSONEqual( + self.client.get(reverse("authentik_brands:webfinger")).content.decode(), {} + ) + + def test_webfinger_not_supported(self): + """Test Webfinger""" + brand = create_test_brand() + provider = SAMLProvider.objects.create( + name=generate_id(), + ) + app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) + brand.default_application = app + brand.save() + self.assertJSONEqual( + self.client.get(reverse("authentik_brands:webfinger")).content.decode(), {} + ) + + def test_webfinger_oidc(self): + """Test Webfinger""" + brand = create_test_brand() + provider = OAuth2Provider.objects.create( + name=generate_id(), + ) + app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) + brand.default_application = app + brand.save() + self.assertJSONEqual( + self.client.get(reverse("authentik_brands:webfinger")).content.decode(), + { + "links": [ + { + "href": f"http://testserver/application/o/{app.slug}/", + "rel": "http://openid.net/specs/connect/1.0/issuer", + } + ], + "subject": None, + }, + ) diff --git a/authentik/brands/urls_root.py b/authentik/brands/urls_root.py new file mode 100644 index 0000000000..4951e52eac --- /dev/null +++ b/authentik/brands/urls_root.py @@ -0,0 +1,9 @@ +"""authentik brand root URLs""" + +from django.urls import path + +from authentik.brands.views.webfinger import WebFingerView + +urlpatterns = [ + path(".well-known/webfinger", WebFingerView.as_view(), name="webfinger"), +] diff --git a/authentik/brands/views/__init__.py b/authentik/brands/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/brands/views/webfinger.py b/authentik/brands/views/webfinger.py new file mode 100644 index 0000000000..e898d9227d --- /dev/null +++ b/authentik/brands/views/webfinger.py @@ -0,0 +1,29 @@ +from typing import Any + +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.views import View + +from authentik.brands.models import Brand, WebfingerProvider +from authentik.core.models import Application + + +class WebFingerView(View): + """Webfinger endpoint""" + + def get(self, request: HttpRequest) -> HttpResponse: + brand: Brand = request.brand + if not brand.default_application: + return JsonResponse({}) + application: Application = brand.default_application + provider = application.get_provider() + if not provider or not isinstance(provider, WebfingerProvider): + return JsonResponse({}) + webfinger_data = provider.webfinger(request.GET.get("resource"), request) + return JsonResponse(webfinger_data) + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + response = super().dispatch(request, *args, **kwargs) + # RFC7033 spec + response["Access-Control-Allow-Origin"] = "*" + response["Content-Type"] = "application/jrd+json" + return response diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 45b2d9e0b8..3e09527d16 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -22,6 +22,7 @@ from jwt import encode from rest_framework.serializers import Serializer from structlog.stdlib import get_logger +from authentik.brands.models import WebfingerProvider from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User from authentik.crypto.models import CertificateKeyPair from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key @@ -120,7 +121,7 @@ class ScopeMapping(PropertyMapping): verbose_name_plural = _("Scope Mappings") -class OAuth2Provider(Provider): +class OAuth2Provider(WebfingerProvider, Provider): """OAuth2 Provider for generic OAuth and OpenID Connect Applications.""" client_type = models.CharField( @@ -288,6 +289,24 @@ class OAuth2Provider(Provider): key, alg = self.jwt_key return encode(payload, key, algorithm=alg, headers=headers) + def webfinger(self, resource: str, request: HttpRequest): + return { + "subject": resource, + "links": [ + { + "rel": "http://openid.net/specs/connect/1.0/issuer", + "href": request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:provider-root", + kwargs={ + "application_slug": self.application.slug, + }, + ) + ), + }, + ], + } + class Meta: verbose_name = _("OAuth2/OpenID Provider") verbose_name_plural = _("OAuth2/OpenID Providers")