core: applications backchannel provider (#5449)

* backchannel applications

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add webui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include assigned app in provider

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve backchannel provider list display

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make ldap provider compatible

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show backchannel providers in app view

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make backchannel required for SCIM

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup api

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Apply suggestions from code review

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens L. <jens@beryju.org>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
Jens L 2023-05-08 15:29:12 +02:00 committed by GitHub
parent 9f4be4d150
commit 7acd0558f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 496 additions and 36 deletions

View File

@ -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},
}

View File

@ -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,

View File

@ -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),
]

View File

@ -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

View File

@ -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

View File

@ -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",
},
],

View File

@ -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

View File

@ -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 = [

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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")
)

View File

@ -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")]
)

View File

@ -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")]
)

View File

@ -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")
)

View File

@ -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."""

View File

@ -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

View File

@ -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<Application, string> {
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<Application, string> {
async send(data: Application): Promise<Application | void> {
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<Application, string> {
${t`Select a provider that this application should use.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Backchannel providers`}
name="backchannelProviders"
>
<div class="pf-c-input-group">
<ak-provider-select-table
?backchannelOnly=${true}
.confirm=${(items: Provider[]) => {
this.backchannelProviders = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-provider-select-table>
<div class="pf-c-form-control">
<ak-chip-group>
${this.backchannelProviders.map((provider) => {
return html`<ak-chip
.removable=${true}
value=${ifDefined(provider.pk)}
@remove=${() => {
const idx = this.backchannelProviders.indexOf(provider);
this.backchannelProviders.splice(idx, 1);
this.requestUpdate();
}}
>
${provider.name}
</ak-chip>`;
})}
</ak-chip-group>
</div>
</div>
<p class="pf-c-form__helper-text">
${t`Select backchannel providers which augment the functionality of the main provider.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Policy engine mode`}
?required=${true}

View File

@ -21,6 +21,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@ -65,7 +66,17 @@ export class ApplicationViewPage extends AKElement {
missingOutpost = false;
static get styles(): CSSResult[] {
return [PFBase, PFBanner, PFPage, PFContent, PFButton, PFDescriptionList, PFGrid, PFCard];
return [
PFBase,
PFList,
PFBanner,
PFPage,
PFContent,
PFButton,
PFDescriptionList,
PFGrid,
PFCard,
];
}
render(): TemplateResult {
@ -121,6 +132,35 @@ export class ApplicationViewPage extends AKElement {
</dd>
</div>`
: html``}
${(this.application.backchannelProvidersObj || []).length > 0
? html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Backchannel Providers`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ul class="pf-c-list">
${this.application.backchannelProvidersObj.map(
(provider) => {
return html`
<li>
<a
href="#/core/providers/${provider.pk}"
>
${provider.name}
(${provider.verboseName})
</a>
</li>
`;
},
)}
</ul>
</div>
</dd>
</div>`
: html``}
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"

View File

@ -0,0 +1,88 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TableModal } from "@goauthentik/elements/table/TableModal";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Provider, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-select-table")
export class ProviderSelectModal extends TableModal<Provider> {
checkbox = true;
checkboxChip = true;
searchEnabled(): boolean {
return true;
}
@property({ type: Boolean })
backchannelOnly = false;
@property()
confirm!: (selectedItems: Provider[]) => Promise<unknown>;
order = "name";
async apiEndpoint(page: number): Promise<PaginatedResponse<Provider>> {
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`<div>
<div>${item.name}</div>
</div>`,
html`${item.verboseName}`,
];
}
renderSelectedChip(item: Provider): TemplateResult {
return html`${item.name}`;
}
renderModalInner(): TemplateResult {
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">
${t`Select providers to add to application`}
</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>
<footer class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${async () => {
await this.confirm(this.selectedElements);
this.open = false;
}}
class="pf-m-primary"
>
${t`Add`} </ak-spinner-button
>&nbsp;
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>
${t`Cancel`}
</ak-spinner-button>
</footer>`;
}
}

View File

@ -82,24 +82,29 @@ export class ProviderListPage extends TablePage<Provider> {
</ak-forms-delete-bulk>`;
}
row(item: Provider): TemplateResult[] {
let app = html``;
if (item.component === "ak-provider-scim-form") {
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`No application required.`}`;
} else if (!item.assignedApplicationName) {
app = html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`;
} else {
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
rowApp(item: Provider): TemplateResult {
if (item.assignedApplicationName) {
return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application `}
<a href="#/core/applications/${item.assignedApplicationSlug}"
>${item.assignedApplicationName}</a
>`;
}
if (item.assignedBackchannelApplicationName) {
return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application (backchannel) `}
<a href="#/core/applications/${item.assignedBackchannelApplicationSlug}"
>${item.assignedBackchannelApplicationName}</a
>`;
}
return html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`;
}
row(item: Provider): TemplateResult[] {
return [
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
app,
this.rowApp(item),
html`${item.verboseName}`,
html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>

View File

@ -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/<slug>/`. 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.

View File

@ -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`