From 7acd0558f594835489fac8dde45c39faef82792f Mon Sep 17 00:00:00 2001
From: Jens L
Date: Mon, 8 May 2023 15:29:12 +0200
Subject: [PATCH] core: applications backchannel provider (#5449)
* backchannel applications
Signed-off-by: Jens Langhammer
* add webui
Signed-off-by: Jens Langhammer
* include assigned app in provider
Signed-off-by: Jens Langhammer
* improve backchannel provider list display
Signed-off-by: Jens Langhammer
* make ldap provider compatible
Signed-off-by: Jens Langhammer
* show backchannel providers in app view
Signed-off-by: Jens Langhammer
* make backchannel required for SCIM
Signed-off-by: Jens Langhammer
* cleanup api
Signed-off-by: Jens Langhammer
* update docs
Signed-off-by: Jens Langhammer
* fix tests
Signed-off-by: Jens Langhammer
* Apply suggestions from code review
Co-authored-by: Tana M Berry
Signed-off-by: Jens L.
* update docs
Signed-off-by: Jens Langhammer
---------
Signed-off-by: Jens Langhammer
Signed-off-by: Jens L.
Co-authored-by: Tana M Berry
---
authentik/core/api/applications.py | 6 ++
authentik/core/api/providers.py | 28 +++++-
...vider_backchannel_applications_and_more.py | 49 ++++++++++
authentik/core/models.py | 34 +++++++
authentik/core/signals.py | 12 ++-
authentik/core/tests/test_applications_api.py | 6 ++
authentik/core/tests/test_models.py | 5 +-
authentik/providers/ldap/api.py | 10 +-
authentik/providers/ldap/models.py | 4 +-
authentik/providers/scim/models.py | 10 +-
authentik/providers/scim/tasks.py | 22 ++++-
authentik/providers/scim/tests/test_client.py | 6 ++
authentik/providers/scim/tests/test_group.py | 7 +-
.../providers/scim/tests/test_membership.py | 8 +-
authentik/providers/scim/tests/test_user.py | 7 +-
authentik/root/test_runner.py | 3 +
schema.yml | 93 +++++++++++++++++++
web/src/admin/applications/ApplicationForm.ts | 49 +++++++++-
.../admin/applications/ApplicationViewPage.ts | 42 ++++++++-
.../admin/applications/ProviderSelectModal.ts | 88 ++++++++++++++++++
web/src/admin/providers/ProviderListPage.ts | 27 +++---
website/docs/core/applications.md | 14 ++-
.../docs/interfaces/user/customization.mdx | 2 +-
23 files changed, 496 insertions(+), 36 deletions(-)
create mode 100644 authentik/core/migrations/0029_provider_backchannel_applications_and_more.py
create mode 100644 web/src/admin/applications/ProviderSelectModal.ts
diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py
index 76b02903f..f40aa3165 100644
--- a/authentik/core/api/applications.py
+++ b/authentik/core/api/applications.py
@@ -52,6 +52,9 @@ class ApplicationSerializer(ModelSerializer):
launch_url = SerializerMethodField()
provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True)
+ backchannel_providers_obj = ProviderSerializer(
+ source="backchannel_providers", required=False, read_only=True, many=True
+ )
meta_icon = ReadOnlyField(source="get_meta_icon")
@@ -75,6 +78,8 @@ class ApplicationSerializer(ModelSerializer):
"slug",
"provider",
"provider_obj",
+ "backchannel_providers",
+ "backchannel_providers_obj",
"launch_url",
"open_in_new_tab",
"meta_launch_url",
@@ -86,6 +91,7 @@ class ApplicationSerializer(ModelSerializer):
]
extra_kwargs = {
"meta_icon": {"read_only": True},
+ "backchannel_providers": {"required": False},
}
diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py
index 096e926f4..67b2427fc 100644
--- a/authentik/core/api/providers.py
+++ b/authentik/core/api/providers.py
@@ -1,5 +1,7 @@
"""Provider API Views"""
from django.utils.translation import gettext_lazy as _
+from django_filters.filters import BooleanFilter
+from django_filters.filterset import FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
@@ -20,6 +22,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
+ assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
+ assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
component = SerializerMethodField()
@@ -40,6 +44,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"component",
"assigned_application_slug",
"assigned_application_name",
+ "assigned_backchannel_application_slug",
+ "assigned_backchannel_application_name",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
@@ -49,6 +55,22 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
}
+class ProviderFilter(FilterSet):
+ """Filter for groups"""
+
+ application__isnull = BooleanFilter(
+ field_name="application",
+ lookup_expr="isnull",
+ )
+ backchannel_only = BooleanFilter(
+ method="filter_backchannel_only",
+ )
+
+ def filter_backchannel_only(self, queryset, name, value):
+ """Only return backchannel providers"""
+ return queryset.filter(is_backchannel=value)
+
+
class ProviderViewSet(
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
@@ -60,9 +82,7 @@ class ProviderViewSet(
queryset = Provider.objects.none()
serializer_class = ProviderSerializer
- filterset_fields = {
- "application": ["isnull"],
- }
+ filterset_class = ProviderFilter
search_fields = [
"name",
"application__name",
@@ -78,6 +98,8 @@ class ProviderViewSet(
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: Provider
+ if subclass._meta.abstract:
+ continue
data.append(
{
"name": subclass._meta.verbose_name,
diff --git a/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py
new file mode 100644
index 000000000..1eb158813
--- /dev/null
+++ b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.1.7 on 2023-04-30 17:56
+
+import django.db.models.deletion
+from django.apps.registry import Apps
+from django.db import DatabaseError, InternalError, ProgrammingError, migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ from authentik.core.models import BackchannelProvider
+
+ for model in BackchannelProvider.__subclasses__():
+ try:
+ for obj in model.objects.all():
+ obj.is_backchannel = True
+ obj.save()
+ except (DatabaseError, InternalError, ProgrammingError):
+ # The model might not have been migrated yet/doesn't exist yet
+ # so we don't need to worry about backporting the data
+ pass
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("authentik_core", "0028_provider_authentication_flow"),
+ ("authentik_providers_ldap", "0002_ldapprovider_bind_mode"),
+ ("authentik_providers_scim", "0006_rename_parent_group_scimprovider_filter_group"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="provider",
+ name="backchannel_application",
+ field=models.ForeignKey(
+ default=None,
+ help_text="Accessed from applications; optional backchannel providers for protocols like LDAP and SCIM.",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="backchannel_providers",
+ to="authentik_core.application",
+ ),
+ ),
+ migrations.AddField(
+ model_name="provider",
+ name="is_backchannel",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.RunPython(backport_is_backchannel),
+ ]
diff --git a/authentik/core/models.py b/authentik/core/models.py
index b6505e5ea..3be7aefae 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -270,6 +270,20 @@ class Provider(SerializerModel):
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
+ backchannel_application = models.ForeignKey(
+ "Application",
+ default=None,
+ null=True,
+ on_delete=models.CASCADE,
+ help_text=_(
+ "Accessed from applications; optional backchannel providers for protocols "
+ "like LDAP and SCIM."
+ ),
+ related_name="backchannel_providers",
+ )
+
+ is_backchannel = models.BooleanField(default=False)
+
objects = InheritanceManager()
@property
@@ -292,6 +306,26 @@ class Provider(SerializerModel):
return str(self.name)
+class BackchannelProvider(Provider):
+ """Base class for providers that augment other providers, for example LDAP and SCIM.
+ Multiple of these providers can be configured per application, they may not use the application
+ slug in URLs as an application may have multiple instances of the same
+ type of Backchannel provider
+
+ They can use the application's policies and metadata"""
+
+ @property
+ def component(self) -> str:
+ raise NotImplementedError
+
+ @property
+ def serializer(self) -> type[Serializer]:
+ raise NotImplementedError
+
+ class Meta:
+ abstract = True
+
+
class Application(SerializerModel, PolicyBindingModel):
"""Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
diff --git a/authentik/core/signals.py b/authentik/core/signals.py
index 4120855dc..ca6d2c527 100644
--- a/authentik/core/signals.py
+++ b/authentik/core/signals.py
@@ -6,11 +6,11 @@ from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
-from django.db.models.signals import post_save, pre_delete
+from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.http.request import HttpRequest
-from authentik.core.models import Application, AuthenticatedSession
+from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider
# Arguments: user: User, password: str
password_changed = Signal()
@@ -54,3 +54,11 @@ def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSe
"""Delete session when authenticated session is deleted"""
cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
+
+
+@receiver(pre_save)
+def backchannel_provider_pre_save(sender: type[Model], instance: Model, **_):
+ """Ensure backchannel providers have is_backchannel set to true"""
+ if not isinstance(instance, BackchannelProvider):
+ return
+ instance.is_backchannel = True
diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py
index 547f9b9a6..2bed87237 100644
--- a/authentik/core/tests/test_applications_api.py
+++ b/authentik/core/tests/test_applications_api.py
@@ -139,6 +139,8 @@ class TestApplicationsAPI(APITestCase):
"verbose_name": "OAuth2/OpenID Provider",
"verbose_name_plural": "OAuth2/OpenID Providers",
},
+ "backchannel_providers": [],
+ "backchannel_providers_obj": [],
"launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True,
@@ -189,6 +191,8 @@ class TestApplicationsAPI(APITestCase):
"verbose_name": "OAuth2/OpenID Provider",
"verbose_name_plural": "OAuth2/OpenID Providers",
},
+ "backchannel_providers": [],
+ "backchannel_providers_obj": [],
"launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True,
@@ -210,6 +214,8 @@ class TestApplicationsAPI(APITestCase):
"policy_engine_mode": "any",
"provider": None,
"provider_obj": None,
+ "backchannel_providers": [],
+ "backchannel_providers_obj": [],
"slug": "denied",
},
],
diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py
index 1cf2cff36..a08dbb5af 100644
--- a/authentik/core/tests/test_models.py
+++ b/authentik/core/tests/test_models.py
@@ -53,9 +53,8 @@ def provider_tester_factory(test_model: type[Stage]) -> Callable:
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract: # pragma: no cover
- model_class = test_model.__bases__[0]()
- else:
- model_class = test_model()
+ return
+ model_class = test_model()
self.assertIsNotNone(model_class.component)
return tester
diff --git a/authentik/providers/ldap/api.py b/authentik/providers/ldap/api.py
index 64876b424..52c4cdc60 100644
--- a/authentik/providers/ldap/api.py
+++ b/authentik/providers/ldap/api.py
@@ -1,5 +1,5 @@
"""LDAPProvider API Views"""
-from rest_framework.fields import CharField, ListField
+from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
@@ -54,9 +54,15 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
class LDAPOutpostConfigSerializer(ModelSerializer):
"""LDAPProvider Serializer"""
- application_slug = CharField(source="application.slug")
+ application_slug = SerializerMethodField()
bind_flow_slug = CharField(source="authorization_flow.slug")
+ def get_application_slug(self, instance: LDAPProvider) -> str:
+ """Prioritise backchannel slug over direct application slug"""
+ if instance.backchannel_application:
+ return instance.backchannel_application.slug
+ return instance.application.slug
+
class Meta:
model = LDAPProvider
fields = [
diff --git a/authentik/providers/ldap/models.py b/authentik/providers/ldap/models.py
index ad90777ec..581d50230 100644
--- a/authentik/providers/ldap/models.py
+++ b/authentik/providers/ldap/models.py
@@ -5,7 +5,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
-from authentik.core.models import Group, Provider
+from authentik.core.models import BackchannelProvider, Group
from authentik.crypto.models import CertificateKeyPair
from authentik.outposts.models import OutpostModel
@@ -17,7 +17,7 @@ class APIAccessMode(models.TextChoices):
CACHED = "cached"
-class LDAPProvider(OutpostModel, Provider):
+class LDAPProvider(OutpostModel, BackchannelProvider):
"""Allow applications to authenticate against authentik's users using LDAP."""
base_dn = models.TextField(
diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py
index 179044c65..96c4b55d4 100644
--- a/authentik/providers/scim/models.py
+++ b/authentik/providers/scim/models.py
@@ -5,10 +5,16 @@ from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user
from rest_framework.serializers import Serializer
-from authentik.core.models import USER_ATTRIBUTE_SA, Group, PropertyMapping, Provider, User
+from authentik.core.models import (
+ USER_ATTRIBUTE_SA,
+ BackchannelProvider,
+ Group,
+ PropertyMapping,
+ User,
+)
-class SCIMProvider(Provider):
+class SCIMProvider(BackchannelProvider):
"""SCIM 2.0 provider to create users and groups in external applications"""
exclude_users_service_account = models.BooleanField(default=False)
diff --git a/authentik/providers/scim/tasks.py b/authentik/providers/scim/tasks.py
index 6c8f66d75..0480730fb 100644
--- a/authentik/providers/scim/tasks.py
+++ b/authentik/providers/scim/tasks.py
@@ -35,7 +35,7 @@ def client_for_model(provider: SCIMProvider, model: Model) -> SCIMClient:
@CELERY_APP.task()
def scim_sync_all():
"""Run sync for all providers"""
- for provider in SCIMProvider.objects.all():
+ for provider in SCIMProvider.objects.all(backchannel_application__isnull=False):
scim_sync.delay(provider.pk)
@@ -96,6 +96,14 @@ def scim_sync_users(page: int, provider_pk: int):
)
except StopSync as exc:
LOGGER.warning("Stopping sync", exc=exc)
+ messages.append(
+ _(
+ "Stopping sync due to error: %(error)s"
+ % {
+ "error": str(exc),
+ }
+ )
+ )
break
return messages
@@ -129,6 +137,14 @@ def scim_sync_group(page: int, provider_pk: int):
)
except StopSync as exc:
LOGGER.warning("Stopping sync", exc=exc)
+ messages.append(
+ _(
+ "Stopping sync due to error: %(error)s"
+ % {
+ "error": str(exc),
+ }
+ )
+ )
break
return messages
@@ -141,7 +157,7 @@ def scim_signal_direct(model: str, pk: Any, raw_op: str):
if not instance:
return
operation = PatchOp(raw_op)
- for provider in SCIMProvider.objects.all():
+ for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False):
client = client_for_model(provider, instance)
# Check if the object is allowed within the provider's restrictions
queryset: Optional[QuerySet] = None
@@ -172,7 +188,7 @@ def scim_signal_m2m(group_pk: str, action: str, pk_set: list[int]):
group = Group.objects.filter(pk=group_pk).first()
if not group:
return
- for provider in SCIMProvider.objects.all():
+ for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False):
# Check if the object is allowed within the provider's restrictions
queryset: QuerySet = provider.get_group_qs()
# The queryset we get from the provider must include the instance we've got given
diff --git a/authentik/providers/scim/tests/test_client.py b/authentik/providers/scim/tests/test_client.py
index c7d862824..d6a523192 100644
--- a/authentik/providers/scim/tests/test_client.py
+++ b/authentik/providers/scim/tests/test_client.py
@@ -3,6 +3,7 @@ from django.test import TestCase
from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint
+from authentik.core.models import Application
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@@ -18,6 +19,11 @@ class SCIMClientTests(TestCase):
url="https://localhost",
token=generate_id(),
)
+ self.app: Application = Application.objects.create(
+ name=generate_id(),
+ slug=generate_id(),
+ )
+ self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
)
diff --git a/authentik/providers/scim/tests/test_group.py b/authentik/providers/scim/tests/test_group.py
index 503117bcd..6004a453b 100644
--- a/authentik/providers/scim/tests/test_group.py
+++ b/authentik/providers/scim/tests/test_group.py
@@ -7,7 +7,7 @@ from jsonschema import validate
from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint
-from authentik.core.models import Group, User
+from authentik.core.models import Application, Group, User
from authentik.lib.generators import generate_id
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@@ -26,6 +26,11 @@ class SCIMGroupTests(TestCase):
url="https://localhost",
token=generate_id(),
)
+ self.app: Application = Application.objects.create(
+ name=generate_id(),
+ slug=generate_id(),
+ )
+ self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.set(
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
)
diff --git a/authentik/providers/scim/tests/test_membership.py b/authentik/providers/scim/tests/test_membership.py
index de0844450..184b91892 100644
--- a/authentik/providers/scim/tests/test_membership.py
+++ b/authentik/providers/scim/tests/test_membership.py
@@ -4,7 +4,7 @@ from guardian.shortcuts import get_anonymous_user
from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint
-from authentik.core.models import Group, User
+from authentik.core.models import Application, Group, User
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@@ -15,6 +15,7 @@ class SCIMMembershipTests(TestCase):
"""SCIM Membership tests"""
provider: SCIMProvider
+ app: Application
def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID
@@ -30,6 +31,11 @@ class SCIMMembershipTests(TestCase):
url="https://localhost",
token=generate_id(),
)
+ self.app: Application = Application.objects.create(
+ name=generate_id(),
+ slug=generate_id(),
+ )
+ self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.set(
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
)
diff --git a/authentik/providers/scim/tests/test_user.py b/authentik/providers/scim/tests/test_user.py
index 36ce9b282..dae05f268 100644
--- a/authentik/providers/scim/tests/test_user.py
+++ b/authentik/providers/scim/tests/test_user.py
@@ -7,7 +7,7 @@ from jsonschema import validate
from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint
-from authentik.core.models import Group, User
+from authentik.core.models import Application, Group, User
from authentik.lib.generators import generate_id
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
from authentik.providers.scim.tasks import scim_sync
@@ -28,6 +28,11 @@ class SCIMUserTests(TestCase):
token=generate_id(),
exclude_users_service_account=True,
)
+ self.app: Application = Application.objects.create(
+ name=generate_id(),
+ slug=generate_id(),
+ )
+ self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
)
diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py
index 938c9d614..4f3adbdc8 100644
--- a/authentik/root/test_runner.py
+++ b/authentik/root/test_runner.py
@@ -1,5 +1,6 @@
"""Integrate ./manage.py test with pytest"""
from argparse import ArgumentParser
+from unittest import TestCase
from django.conf import settings
@@ -7,6 +8,8 @@ from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init
from tests.e2e.utils import get_docker_tag
+TestCase.maxDiff = None
+
class PytestTestRunner: # pragma: no cover
"""Runs pytest to discover and run tests."""
diff --git a/schema.yml b/schema.yml
index 233014767..d154dd68e 100644
--- a/schema.yml
+++ b/schema.yml
@@ -14293,6 +14293,10 @@ paths:
name: application__isnull
schema:
type: boolean
+ - in: query
+ name: backchannel_only
+ schema:
+ type: boolean
- name: ordering
required: false
in: query
@@ -15831,6 +15835,11 @@ paths:
schema:
type: string
format: uuid
+ - in: query
+ name: backchannel_application
+ schema:
+ type: string
+ format: uuid
- in: query
name: digest_algorithm
schema:
@@ -15850,6 +15859,10 @@ paths:
* `http://www.w3.org/2001/04/xmlenc#sha256` - SHA256
* `http://www.w3.org/2001/04/xmldsig-more#sha384` - SHA384
* `http://www.w3.org/2001/04/xmlenc#sha512` - SHA512
+ - in: query
+ name: is_backchannel
+ schema:
+ type: boolean
- in: query
name: issuer
schema:
@@ -26466,6 +26479,15 @@ components:
allOf:
- $ref: '#/components/schemas/Provider'
readOnly: true
+ backchannel_providers:
+ type: array
+ items:
+ type: integer
+ backchannel_providers_obj:
+ type: array
+ items:
+ $ref: '#/components/schemas/Provider'
+ readOnly: true
launch_url:
type: string
nullable: true
@@ -26493,6 +26515,7 @@ components:
group:
type: string
required:
+ - backchannel_providers_obj
- launch_url
- meta_icon
- name
@@ -26516,6 +26539,10 @@ components:
provider:
type: integer
nullable: true
+ backchannel_providers:
+ type: array
+ items:
+ type: integer
open_in_new_tab:
type: boolean
description: Open launch URL in a new browser tab or window.
@@ -30550,6 +30577,8 @@ components:
type: string
application_slug:
type: string
+ description: Prioritise backchannel slug over direct application slug
+ readOnly: true
search_group:
type: string
format: uuid
@@ -30697,6 +30726,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -30751,6 +30788,8 @@ components:
required:
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- component
- meta_model_name
@@ -31441,6 +31480,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -31522,6 +31569,8 @@ components:
required:
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- component
- meta_model_name
@@ -35366,6 +35415,10 @@ components:
provider:
type: integer
nullable: true
+ backchannel_providers:
+ type: array
+ items:
+ type: integer
open_in_new_tab:
type: boolean
description: Open launch URL in a new browser tab or window.
@@ -38362,6 +38415,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -38377,6 +38438,8 @@ components:
required:
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- component
- meta_model_name
@@ -38594,6 +38657,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -38683,6 +38754,8 @@ components:
required:
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- client_id
- component
@@ -38850,6 +38923,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -38873,6 +38954,8 @@ components:
required:
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- component
- meta_model_name
@@ -39177,6 +39260,14 @@ components:
type: string
description: Application's display Name.
readOnly: true
+ assigned_backchannel_application_slug:
+ type: string
+ description: Internal application name, used in URLs.
+ readOnly: true
+ assigned_backchannel_application_name:
+ type: string
+ description: Application's display Name.
+ readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
@@ -39274,6 +39365,8 @@ components:
- acs_url
- assigned_application_name
- assigned_application_slug
+ - assigned_backchannel_application_name
+ - assigned_backchannel_application_slug
- authorization_flow
- component
- meta_model_name
diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts
index 233c70d12..d8f6e8cc1 100644
--- a/web/src/admin/applications/ApplicationForm.ts
+++ b/web/src/admin/applications/ApplicationForm.ts
@@ -1,3 +1,4 @@
+import "@goauthentik/admin/applications/ProviderSelectModal";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base";
@@ -12,7 +13,7 @@ import "@goauthentik/elements/forms/SearchSelect";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
@@ -32,12 +33,16 @@ export class ApplicationForm extends ModelForm {
slug: pk,
});
this.clearIcon = false;
+ this.backchannelProviders = app.backchannelProvidersObj || [];
return app;
}
@property({ attribute: false })
provider?: number;
+ @state()
+ backchannelProviders: Provider[] = [];
+
@property({ type: Boolean })
clearIcon = false;
@@ -51,6 +56,7 @@ export class ApplicationForm extends ModelForm {
async send(data: Application): Promise {
let app: Application;
+ data.backchannelProviders = this.backchannelProviders.map((p) => p.pk);
if (this.instance) {
app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({
slug: this.instance.slug,
@@ -143,6 +149,47 @@ export class ApplicationForm extends ModelForm {
${t`Select a provider that this application should use.`}
+
+
+
+
+ ${t`Select backchannel providers which augment the functionality of the main provider.`}
+
+
+
`
: html``}
+ ${(this.application.backchannelProvidersObj || []).length > 0
+ ? html`
+
+ ${t`Backchannel Providers`}
+
+
+
+
+
`
+ : html``}
{
+ checkbox = true;
+ checkboxChip = true;
+
+ searchEnabled(): boolean {
+ return true;
+ }
+
+ @property({ type: Boolean })
+ backchannelOnly = false;
+
+ @property()
+ confirm!: (selectedItems: Provider[]) => Promise;
+
+ order = "name";
+
+ async apiEndpoint(page: number): Promise> {
+ return new ProvidersApi(DEFAULT_CONFIG).providersAllList({
+ ordering: this.order,
+ page: page,
+ pageSize: (await uiConfig()).pagination.perPage,
+ search: this.search || "",
+ backchannelOnly: this.backchannelOnly,
+ });
+ }
+
+ columns(): TableColumn[] {
+ return [new TableColumn(t`Name`, "username"), new TableColumn(t`Type`)];
+ }
+
+ row(item: Provider): TemplateResult[] {
+ return [
+ html``,
+ html`${item.verboseName}`,
+ ];
+ }
+
+ renderSelectedChip(item: Provider): TemplateResult {
+ return html`${item.name}`;
+ }
+
+ renderModalInner(): TemplateResult {
+ return html`
+
+
+ ${t`Select providers to add to application`}
+
+
+
+
+ `;
+ }
+}
diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts
index f62495125..5432a456d 100644
--- a/web/src/admin/providers/ProviderListPage.ts
+++ b/web/src/admin/providers/ProviderListPage.ts
@@ -82,24 +82,29 @@ export class ProviderListPage extends TablePage {
`;
}
- row(item: Provider): TemplateResult[] {
- let app = html``;
- if (item.component === "ak-provider-scim-form") {
- app = html`
- ${t`No application required.`}`;
- } else if (!item.assignedApplicationName) {
- app = html`
- ${t`Warning: Provider not assigned to any application.`}`;
- } else {
- app = html`
+ rowApp(item: Provider): TemplateResult {
+ if (item.assignedApplicationName) {
+ return html`
${t`Assigned to application `}
${item.assignedApplicationName}`;
}
+ if (item.assignedBackchannelApplicationName) {
+ return html`
+ ${t`Assigned to application (backchannel) `}
+ ${item.assignedBackchannelApplicationName}`;
+ }
+ return html`
+ ${t`Warning: Provider not assigned to any application.`}`;
+ }
+
+ row(item: Provider): TemplateResult[] {
return [
html` ${item.name} `,
- app,
+ this.rowApp(item),
html`${item.verboseName}`,
html`
${t`Update`}
diff --git a/website/docs/core/applications.md b/website/docs/core/applications.md
index 7778cdb44..9908cdfa9 100644
--- a/website/docs/core/applications.md
+++ b/website/docs/core/applications.md
@@ -3,9 +3,9 @@ title: Applications
slug: /applications
---
-Applications in authentik are the other half of providers. They exist in a 1-to-1 relationship, each application needs a provider and every provider can be used with one application.
+Applications in authentik are the other half of providers. They exist in a 1-to-1 relationship, each application needs a provider and every provider can be used with one application. Starting with authentik 2023.5, applications can use multiple providers, to augment the functionality of the main provider. For more information, see [Backchannel providers](#backchannel-providers).
-Applications are used to configure and separate the authorization / access control and the appearance in the Library page.
+Applications are used to configure and separate the authorization / access control and the appearance in the _My applications_ page.
## Authorization
@@ -54,3 +54,13 @@ Requires authentik 2022.3
:::
To give users direct links to applications, you can now use an URL like `https://authentik.company/application/launch//`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them.
+
+### Backchannel providers
+
+:::info
+Requires authentik version 2023.5 or later.
+:::
+
+Backchannel providers can augment the functionality of applications by using additional protocols. The main provider of an application provides the SSO protocol that is used for logging into the application. Then, additional backchannel providers can be used for protocols such as [SCIM](../providers/scim/index.md) and [LDAP](../providers/ldap/index.md) to provide directory syncing.
+
+Access restrictions that are configured on an application apply to all of its backchannel providers.
diff --git a/website/docs/interfaces/user/customization.mdx b/website/docs/interfaces/user/customization.mdx
index 61c8bcfc5..b1f1d3563 100644
--- a/website/docs/interfaces/user/customization.mdx
+++ b/website/docs/interfaces/user/customization.mdx
@@ -53,7 +53,7 @@ settings:
### `settings.layout.type`
-Which layout to use for the Library view. Defaults to `row`. Choices: `row`, `2-column`, `3-column`
+Which layout to use for the _My applications_ view. Defaults to `row`. Choices: `row`, `2-column`, `3-column`
### `settings.locale`