diff --git a/authentik/blueprints/migrations/0001_initial.py b/authentik/blueprints/migrations/0001_initial.py
index c8373214a3..0d1bfd7ac8 100644
--- a/authentik/blueprints/migrations/0001_initial.py
+++ b/authentik/blueprints/migrations/0001_initial.py
@@ -29,9 +29,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1:
return
blueprint_file.seek(0)
- instance: BlueprintInstance = (
- BlueprintInstance.objects.using(db_alias).filter(path=path).first()
- )
+ instance = BlueprintInstance.objects.using(db_alias).filter(path=path).first()
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None
if metadata:
diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py
index ae5547fe66..89e6f14e36 100644
--- a/authentik/core/api/providers.py
+++ b/authentik/core/api/providers.py
@@ -38,6 +38,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"name",
"authentication_flow",
"authorization_flow",
+ "invalidation_flow",
"property_mappings",
"component",
"assigned_application_slug",
@@ -50,6 +51,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
]
extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
+ "invalidation_flow": {"required": True, "allow_null": False},
}
diff --git a/authentik/core/migrations/0040_provider_invalidation_flow.py b/authentik/core/migrations/0040_provider_invalidation_flow.py
new file mode 100644
index 0000000000..c3979e86cc
--- /dev/null
+++ b/authentik/core/migrations/0040_provider_invalidation_flow.py
@@ -0,0 +1,55 @@
+# Generated by Django 5.0.9 on 2024-10-02 11:35
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def migrate_invalidation_flow_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ from authentik.flows.models import FlowDesignation, FlowAuthenticationRequirement
+
+ db_alias = schema_editor.connection.alias
+
+ Flow = apps.get_model("authentik_flows", "Flow")
+ Provider = apps.get_model("authentik_core", "Provider")
+
+ # So this flow is managed via a blueprint, bue we're in a migration so we don't want to rely on that
+ # since the blueprint is just an empty flow we can just create it here
+ # and let it be managed by the blueprint later
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-provider-invalidation-flow",
+ defaults={
+ "name": "Logged out of application",
+ "title": "You've logged out of %(app)s.",
+ "authentication": FlowAuthenticationRequirement.NONE,
+ "designation": FlowDesignation.INVALIDATION,
+ },
+ )
+ Provider.objects.using(db_alias).filter(invalidation_flow=None).update(invalidation_flow=flow)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
+ ("authentik_flows", "0027_auto_20231028_1424"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="provider",
+ name="invalidation_flow",
+ field=models.ForeignKey(
+ default=None,
+ help_text="Flow used ending the session from a provider.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_DEFAULT,
+ related_name="provider_invalidation",
+ to="authentik_flows.flow",
+ ),
+ ),
+ migrations.RunPython(migrate_invalidation_flow_default),
+ ]
diff --git a/authentik/core/models.py b/authentik/core/models.py
index 8b6fbcbf56..4c8e247e72 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -391,14 +391,23 @@ class Provider(SerializerModel):
),
related_name="provider_authentication",
)
-
authorization_flow = models.ForeignKey(
"authentik_flows.Flow",
+ # Set to cascade even though null is allowed, since most providers
+ # still require an authorization flow set
on_delete=models.CASCADE,
null=True,
help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization",
)
+ invalidation_flow = models.ForeignKey(
+ "authentik_flows.Flow",
+ on_delete=models.SET_DEFAULT,
+ default=None,
+ null=True,
+ help_text=_("Flow used ending the session from a provider."),
+ related_name="provider_invalidation",
+ )
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
diff --git a/authentik/core/templates/if/end_session.html b/authentik/core/templates/if/end_session.html
deleted file mode 100644
index 88cb345a14..0000000000
--- a/authentik/core/templates/if/end_session.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{% extends 'login/base_full.html' %}
-
-{% load static %}
-{% load i18n %}
-
-{% block title %}
-{% trans 'End session' %} - {{ brand.branding_title }}
-{% endblock %}
-
-{% block card_title %}
-{% blocktrans with application=application.name %}
-You've logged out of {{ application }}.
-{% endblocktrans %}
-{% endblock %}
-
-{% block card %}
-
-{% endblock %}
diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py
index 51adf4b878..1244776b2a 100644
--- a/authentik/core/tests/test_applications_api.py
+++ b/authentik/core/tests/test_applications_api.py
@@ -134,6 +134,7 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
+ "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
@@ -186,6 +187,7 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
+ "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
diff --git a/authentik/core/tests/test_transactional_applications_api.py b/authentik/core/tests/test_transactional_applications_api.py
index 3d18e8cd9b..d0804fb3b6 100644
--- a/authentik/core/tests/test_transactional_applications_api.py
+++ b/authentik/core/tests/test_transactional_applications_api.py
@@ -19,7 +19,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
uid = generate_id()
- authorization_flow = create_test_flow()
response = self.client.put(
reverse("authentik_api:core-transactional-application"),
data={
@@ -30,7 +29,8 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": {
"name": uid,
- "authorization_flow": str(authorization_flow.pk),
+ "authorization_flow": str(create_test_flow().pk),
+ "invalidation_flow": str(create_test_flow().pk),
},
},
)
@@ -56,10 +56,16 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider": {
"name": uid,
"authorization_flow": "",
+ "invalidation_flow": "",
},
},
)
self.assertJSONEqual(
response.content.decode(),
- {"provider": {"authorization_flow": ["This field may not be null."]}},
+ {
+ "provider": {
+ "authorization_flow": ["This field may not be null."],
+ "invalidation_flow": ["This field may not be null."],
+ }
+ },
)
diff --git a/authentik/core/urls.py b/authentik/core/urls.py
index e0b9badcf9..448dcca6b6 100644
--- a/authentik/core/urls.py
+++ b/authentik/core/urls.py
@@ -24,7 +24,6 @@ from authentik.core.views.interface import (
InterfaceView,
RootRedirectView,
)
-from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
@@ -60,11 +59,6 @@ urlpatterns = [
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
- path(
- "if/session-end//",
- ensure_csrf_cookie(EndSessionView.as_view()),
- name="if-session-end",
- ),
# Fallback for WS
path("ws/outpost//", InterfaceView.as_view(template_name="if/admin.html")),
path(
diff --git a/authentik/core/views/session.py b/authentik/core/views/session.py
deleted file mode 100644
index c0ad9d7714..0000000000
--- a/authentik/core/views/session.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""authentik Session Views"""
-
-from typing import Any
-
-from django.shortcuts import get_object_or_404
-from django.views.generic.base import TemplateView
-
-from authentik.core.models import Application
-from authentik.policies.views import PolicyAccessView
-
-
-class EndSessionView(TemplateView, PolicyAccessView):
- """Allow the client to end the Session"""
-
- template_name = "if/end_session.html"
-
- def resolve_provider_application(self):
- self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
-
- def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
- context = super().get_context_data(**kwargs)
- context["application"] = self.application
- return context
diff --git a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
index 1ad9b70daf..4916e74ed5 100644
--- a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
+++ b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
@@ -68,6 +68,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
+ "invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
@@ -120,6 +121,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
+ "invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
@@ -149,6 +151,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
+ "invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py
index ddb1d5f196..deb3b3483b 100644
--- a/authentik/flows/challenge.py
+++ b/authentik/flows/challenge.py
@@ -110,9 +110,22 @@ class FlowErrorChallenge(Challenge):
class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
- error_message = CharField(required=False)
component = CharField(default="ak-stage-access-denied")
+ error_message = CharField(required=False)
+
+
+class SessionEndChallenge(WithUserInfoChallenge):
+ """Challenge for ending a session"""
+
+ component = CharField(default="ak-stage-session-end")
+
+ application_name = CharField(required=False)
+ application_launch_url = CharField(required=False)
+
+ invalidation_flow_url = CharField(required=False)
+ brand_name = CharField(required=True)
+
class PermissionDict(TypedDict):
"""Consent Permission"""
diff --git a/authentik/flows/migrations/0027_auto_20231028_1424.py b/authentik/flows/migrations/0027_auto_20231028_1424.py
index c4314da9ec..1d0597a879 100644
--- a/authentik/flows/migrations/0027_auto_20231028_1424.py
+++ b/authentik/flows/migrations/0027_auto_20231028_1424.py
@@ -6,20 +6,18 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
- from guardian.shortcuts import get_anonymous_user
+ from guardian.conf import settings as guardian_settings
Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
- users = User.objects.using(db_alias).exclude(username="akadmin")
- try:
- users = users.exclude(pk=get_anonymous_user().pk)
-
- except Exception: # nosec
- pass
-
+ users = (
+ User.objects.using(db_alias)
+ .exclude(username="akadmin")
+ .exclude(username=guardian_settings.ANONYMOUS_USER_NAME)
+ )
if users.exists():
Flow.objects.using(db_alias).filter(slug="initial-setup").update(
authentication="require_superuser"
diff --git a/authentik/flows/models.py b/authentik/flows/models.py
index f34b0e3472..d871aab5ec 100644
--- a/authentik/flows/models.py
+++ b/authentik/flows/models.py
@@ -107,7 +107,9 @@ class Stage(SerializerModel):
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
- """Creates an in-memory stage instance, based on a `view` as view."""
+ """Creates an in-memory stage instance, based on a `view` as view.
+ Any key-word arguments are set as attributes on the stage object,
+ accessible via `self.executor.current_stage`."""
stage = Stage()
# Because we can't pickle a locally generated function,
# we set the view as a separate property and reference a generic function
diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py
index 4d9656a257..18ffc15e8a 100644
--- a/authentik/flows/stage.py
+++ b/authentik/flows/stage.py
@@ -13,7 +13,7 @@ from rest_framework.request import Request
from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger
-from authentik.core.models import User
+from authentik.core.models import Application, User
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
@@ -21,6 +21,7 @@ from authentik.flows.challenge import (
ContextualFlowInfo,
HttpChallengeResponse,
RedirectChallenge,
+ SessionEndChallenge,
WithUserInfoChallenge,
)
from authentik.flows.exceptions import StageInvalidException
@@ -230,7 +231,7 @@ class ChallengeStageView(StageView):
return HttpChallengeResponse(challenge_response)
-class AccessDeniedChallengeView(ChallengeStageView):
+class AccessDeniedStage(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()"""
error_message: str | None
@@ -268,3 +269,31 @@ class RedirectStage(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return HttpChallengeResponse(self.get_challenge())
+
+
+class SessionEndStage(ChallengeStageView):
+ """Stage inserted when a flow is used as invalidation flow. By default shows actions
+ that the user is likely to take after signing out of a provider."""
+
+ def get_challenge(self, *args, **kwargs) -> Challenge:
+ application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
+ data = {
+ "component": "ak-stage-session-end",
+ "brand_name": self.request.brand.branding_title,
+ }
+ if application:
+ data["application_name"] = application.name
+ data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
+ if self.request.brand.flow_invalidation:
+ data["invalidation_flow_url"] = reverse(
+ "authentik_core:if-flow",
+ kwargs={
+ "flow_slug": self.request.brand.flow_invalidation.slug,
+ },
+ )
+ return SessionEndChallenge(data=data)
+
+ # This can never be reached since this challenge is created on demand and only the
+ # .get() method is called
+ def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
+ return self.executor.cancel()
diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py
index 2e2b53f091..32b5cf3a09 100644
--- a/authentik/flows/views/executor.py
+++ b/authentik/flows/views/executor.py
@@ -54,7 +54,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
-from authentik.flows.stage import AccessDeniedChallengeView, StageView
+from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path
@@ -441,7 +441,7 @@ class FlowExecutorView(APIView):
)
return self.restart_flow(keep_context)
self.cancel()
- challenge_view = AccessDeniedChallengeView(self, error_message)
+ challenge_view = AccessDeniedStage(self, error_message)
challenge_view.request = self.request
return to_stage_response(self.request, challenge_view.get(self.request))
diff --git a/authentik/providers/ldap/api.py b/authentik/providers/ldap/api.py
index 232a239f38..d5eed6cdf5 100644
--- a/authentik/providers/ldap/api.py
+++ b/authentik/providers/ldap/api.py
@@ -87,6 +87,7 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
application_slug = SerializerMethodField()
bind_flow_slug = CharField(source="authorization_flow.slug")
+ unbind_flow_slug = SerializerMethodField()
def get_application_slug(self, instance: LDAPProvider) -> str:
"""Prioritise backchannel slug over direct application slug"""
@@ -94,6 +95,16 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
return instance.backchannel_application.slug
return instance.application.slug
+ def get_unbind_flow_slug(self, instance: LDAPProvider) -> str | None:
+ """Get slug for unbind flow, defaulting to brand's default flow."""
+ flow = instance.invalidation_flow
+ if not flow and "request" in self.context:
+ request = self.context.get("request")
+ flow = request.brand.flow_invalidation
+ if not flow:
+ return None
+ return flow.slug
+
class Meta:
model = LDAPProvider
fields = [
@@ -101,6 +112,7 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
"name",
"base_dn",
"bind_flow_slug",
+ "unbind_flow_slug",
"application_slug",
"certificate",
"tls_server_name",
diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py
index 12a29d8292..fdd4f41750 100644
--- a/authentik/providers/oauth2/urls.py
+++ b/authentik/providers/oauth2/urls.py
@@ -12,6 +12,7 @@ from authentik.providers.oauth2.api.tokens import (
)
from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
from authentik.providers.oauth2.views.device_backchannel import DeviceView
+from authentik.providers.oauth2.views.end_session import EndSessionView
from authentik.providers.oauth2.views.introspection import TokenIntrospectionView
from authentik.providers.oauth2.views.jwks import JWKSView
from authentik.providers.oauth2.views.provider import ProviderInfoView
@@ -44,7 +45,7 @@ urlpatterns = [
),
path(
"/end-session/",
- RedirectView.as_view(pattern_name="authentik_core:if-session-end", query_string=True),
+ EndSessionView.as_view(),
name="end-session",
),
path("/jwks/", JWKSView.as_view(), name="jwks"),
diff --git a/authentik/providers/oauth2/views/end_session.py b/authentik/providers/oauth2/views/end_session.py
new file mode 100644
index 0000000000..343e46964a
--- /dev/null
+++ b/authentik/providers/oauth2/views/end_session.py
@@ -0,0 +1,45 @@
+"""oauth2 provider end_session Views"""
+
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+
+from authentik.core.models import Application
+from authentik.flows.models import Flow, in_memory_stage
+from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
+from authentik.flows.stage import SessionEndStage
+from authentik.flows.views.executor import SESSION_KEY_PLAN
+from authentik.lib.utils.urls import redirect_with_qs
+from authentik.policies.views import PolicyAccessView
+
+
+class EndSessionView(PolicyAccessView):
+ """Redirect to application's provider's invalidation flow"""
+
+ flow: Flow
+
+ def resolve_provider_application(self):
+ self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
+ self.provider = self.application.get_provider()
+ if not self.provider:
+ raise Http404
+ self.flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
+ if not self.flow:
+ raise Http404
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Dispatch the flow planner for the invalidation flow"""
+ planner = FlowPlanner(self.flow)
+ planner.allow_empty_flows = True
+ plan = planner.plan(
+ request,
+ {
+ PLAN_CONTEXT_APPLICATION: self.application,
+ },
+ )
+ plan.insert_stage(in_memory_stage(SessionEndStage))
+ request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_core:if-flow",
+ self.request.GET,
+ flow_slug=self.flow.slug,
+ )
diff --git a/authentik/providers/proxy/tests.py b/authentik/providers/proxy/tests.py
index b0686cc221..08b0a27e16 100644
--- a/authentik/providers/proxy/tests.py
+++ b/authentik/providers/proxy/tests.py
@@ -24,6 +24,7 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
"basic_auth_enabled": True,
@@ -41,6 +42,7 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
"basic_auth_enabled": True,
@@ -64,6 +66,7 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
},
)
@@ -82,6 +85,7 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},
@@ -99,6 +103,7 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},
@@ -114,6 +119,7 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
+ "invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},
diff --git a/authentik/providers/saml/api/providers.py b/authentik/providers/saml/api/providers.py
index 40ff7b0eb4..c5a9e6c32e 100644
--- a/authentik/providers/saml/api/providers.py
+++ b/authentik/providers/saml/api/providers.py
@@ -188,6 +188,9 @@ class SAMLProviderImportSerializer(PassiveSerializer):
authorization_flow = PrimaryKeyRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
)
+ invalidation_flow = PrimaryKeyRelatedField(
+ queryset=Flow.objects.filter(designation=FlowDesignation.INVALIDATION),
+ )
file = FileField()
@@ -277,7 +280,9 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
try:
metadata = ServiceProviderMetadataParser().parse(file.read().decode())
metadata.to_provider(
- data.validated_data["name"], data.validated_data["authorization_flow"]
+ data.validated_data["name"],
+ data.validated_data["authorization_flow"],
+ data.validated_data["invalidation_flow"],
)
except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc))
diff --git a/authentik/providers/saml/processors/metadata_parser.py b/authentik/providers/saml/processors/metadata_parser.py
index dd42b52af5..c131d8c263 100644
--- a/authentik/providers/saml/processors/metadata_parser.py
+++ b/authentik/providers/saml/processors/metadata_parser.py
@@ -49,12 +49,13 @@ class ServiceProviderMetadata:
signing_keypair: CertificateKeyPair | None = None
- def to_provider(self, name: str, authorization_flow: Flow) -> SAMLProvider:
+ def to_provider(
+ self, name: str, authorization_flow: Flow, invalidation_flow: Flow
+ ) -> SAMLProvider:
"""Create a SAMLProvider instance from the details. `name` is required,
as depending on the metadata CertificateKeypairs might have to be created."""
provider = SAMLProvider.objects.create(
- name=name,
- authorization_flow=authorization_flow,
+ name=name, authorization_flow=authorization_flow, invalidation_flow=invalidation_flow
)
provider.issuer = self.entity_id
provider.sp_binding = self.acs_binding
diff --git a/authentik/providers/saml/tests/test_api.py b/authentik/providers/saml/tests/test_api.py
index e273b4b607..b0a27aeaf1 100644
--- a/authentik/providers/saml/tests/test_api.py
+++ b/authentik/providers/saml/tests/test_api.py
@@ -47,11 +47,12 @@ class TestSAMLProviderAPI(APITestCase):
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
+ "invalidation_flow": create_test_flow().pk,
"acs_url": "http://localhost",
"signing_kp": cert.pk,
},
)
- self.assertEqual(400, response.status_code)
+ self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
@@ -68,12 +69,13 @@ class TestSAMLProviderAPI(APITestCase):
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
+ "invalidation_flow": create_test_flow().pk,
"acs_url": "http://localhost",
"signing_kp": cert.pk,
"sign_assertion": True,
},
)
- self.assertEqual(201, response.status_code)
+ self.assertEqual(response.status_code, 201)
def test_metadata(self):
"""Test metadata export (normal)"""
@@ -131,6 +133,7 @@ class TestSAMLProviderAPI(APITestCase):
"file": metadata,
"name": generate_id(),
"authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk,
+ "invalidation_flow": create_test_flow(FlowDesignation.INVALIDATION).pk,
},
format="multipart",
)
diff --git a/authentik/providers/saml/tests/test_metadata.py b/authentik/providers/saml/tests/test_metadata.py
index e309cde23e..30d67b4122 100644
--- a/authentik/providers/saml/tests/test_metadata.py
+++ b/authentik/providers/saml/tests/test_metadata.py
@@ -82,7 +82,7 @@ class TestServiceProviderMetadataParser(TestCase):
def test_simple(self):
"""Test simple metadata without Signing"""
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/simple.xml"))
- provider = metadata.to_provider("test", self.flow)
+ provider = metadata.to_provider("test", self.flow, self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
@@ -95,7 +95,7 @@ class TestServiceProviderMetadataParser(TestCase):
"""Test Metadata with signing cert"""
create_test_cert()
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/cert.xml"))
- provider = metadata.to_provider("test", self.flow)
+ provider = metadata.to_provider("test", self.flow, self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
diff --git a/authentik/providers/saml/views/slo.py b/authentik/providers/saml/views/slo.py
index 7f38b1f31a..65d261a448 100644
--- a/authentik/providers/saml/views/slo.py
+++ b/authentik/providers/saml/views/slo.py
@@ -1,8 +1,8 @@
"""SLO Views"""
-from django.http import HttpRequest
+from django.http import Http404, HttpRequest
from django.http.response import HttpResponse
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
@@ -10,6 +10,11 @@ from structlog.stdlib import get_logger
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
+from authentik.flows.models import Flow, in_memory_stage
+from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
+from authentik.flows.stage import SessionEndStage
+from authentik.flows.views.executor import SESSION_KEY_PLAN
+from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
@@ -28,11 +33,16 @@ class SAMLSLOView(PolicyAccessView):
""" "SAML SLO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
+ flow: Flow
+
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=self.application.provider_id
)
+ self.flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
+ if not self.flow:
+ raise Http404
def check_saml_request(self) -> HttpRequest | None:
"""Handler to verify the SAML Request. Must be implemented by a subclass"""
@@ -45,9 +55,20 @@ class SAMLSLOView(PolicyAccessView):
method_response = self.check_saml_request()
if method_response:
return method_response
- return redirect(
- "authentik_core:if-session-end",
- application_slug=self.kwargs["application_slug"],
+ planner = FlowPlanner(self.flow)
+ planner.allow_empty_flows = True
+ plan = planner.plan(
+ request,
+ {
+ PLAN_CONTEXT_APPLICATION: self.application,
+ },
+ )
+ plan.insert_stage(in_memory_stage(SessionEndStage))
+ request.session[SESSION_KEY_PLAN] = plan
+ return redirect_with_qs(
+ "authentik_core:if-flow",
+ self.request.GET,
+ flow_slug=self.flow.slug,
)
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
diff --git a/blueprints/default/flow-default-provider-invalidation.yaml b/blueprints/default/flow-default-provider-invalidation.yaml
new file mode 100644
index 0000000000..eae84628a5
--- /dev/null
+++ b/blueprints/default/flow-default-provider-invalidation.yaml
@@ -0,0 +1,13 @@
+version: 1
+metadata:
+ name: Default - Provider invalidation flow
+entries:
+- attrs:
+ designation: invalidation
+ name: Logged out of application
+ title: You've logged out of %(app)s.
+ authentication: none
+ identifiers:
+ slug: default-provider-invalidation-flow
+ model: authentik_flows.flow
+ id: flow
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 93b8ff7857..25836f1d06 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -5117,6 +5117,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
@@ -5287,6 +5293,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
@@ -5428,6 +5440,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
@@ -5563,6 +5581,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
@@ -5688,6 +5712,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
@@ -12761,6 +12791,12 @@
"title": "Authorization flow",
"description": "Flow used when authorizing this provider."
},
+ "invalidation_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Invalidation flow",
+ "description": "Flow used ending the session from a provider."
+ },
"property_mappings": {
"type": "array",
"items": {
diff --git a/internal/outpost/ldap/bind/direct/unbind.go b/internal/outpost/ldap/bind/direct/unbind.go
index 5dc6e322a9..03c55c06f7 100644
--- a/internal/outpost/ldap/bind/direct/unbind.go
+++ b/internal/outpost/ldap/bind/direct/unbind.go
@@ -8,11 +8,17 @@ import (
)
func (db *DirectBinder) Unbind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
+ flowSlug := db.si.GetInvalidationFlowSlug()
+ if flowSlug == nil {
+ req.Log().Debug("Provider does not have a logout flow configured")
+ db.si.SetFlags(req.BindDN, nil)
+ return ldap.LDAPResultSuccess, nil
+ }
flags := db.si.GetFlags(req.BindDN)
if flags == nil || flags.Session == nil {
return ldap.LDAPResultSuccess, nil
}
- fe := flow.NewFlowExecutor(req.Context(), db.si.GetInvalidationFlowSlug(), db.si.GetAPIClient().GetConfig(), log.Fields{
+ fe := flow.NewFlowExecutor(req.Context(), *flowSlug, db.si.GetAPIClient().GetConfig(), log.Fields{
"boundDN": req.BindDN,
"client": req.RemoteAddr(),
"requestId": req.ID(),
@@ -22,7 +28,7 @@ func (db *DirectBinder) Unbind(username string, req *bind.Request) (ldap.LDAPRes
fe.Params.Add("goauthentik.io/outpost/ldap", "true")
_, err := fe.Execute()
if err != nil {
- db.log.WithError(err).Warning("failed to logout user")
+ req.Log().WithError(err).Warning("failed to logout user")
}
db.si.SetFlags(req.BindDN, nil)
return ldap.LDAPResultSuccess, nil
diff --git a/internal/outpost/ldap/instance.go b/internal/outpost/ldap/instance.go
index 0b2d5ba38d..fc88ad13c1 100644
--- a/internal/outpost/ldap/instance.go
+++ b/internal/outpost/ldap/instance.go
@@ -26,7 +26,7 @@ type ProviderInstance struct {
appSlug string
authenticationFlowSlug string
- invalidationFlowSlug string
+ invalidationFlowSlug *string
s *LDAPServer
log *log.Entry
@@ -99,7 +99,7 @@ func (pi *ProviderInstance) GetAuthenticationFlowSlug() string {
return pi.authenticationFlowSlug
}
-func (pi *ProviderInstance) GetInvalidationFlowSlug() string {
+func (pi *ProviderInstance) GetInvalidationFlowSlug() *string {
return pi.invalidationFlowSlug
}
diff --git a/internal/outpost/ldap/refresh.go b/internal/outpost/ldap/refresh.go
index 7a336c621a..0f00bbeb26 100644
--- a/internal/outpost/ldap/refresh.go
+++ b/internal/outpost/ldap/refresh.go
@@ -29,16 +29,6 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
return nil
}
-func (ls *LDAPServer) getInvalidationFlow() string {
- req, _, err := ls.ac.Client.CoreApi.CoreBrandsCurrentRetrieve(context.Background()).Execute()
- if err != nil {
- ls.log.WithError(err).Warning("failed to fetch brand config")
- return ""
- }
- flow := req.GetFlowInvalidation()
- return flow
-}
-
func (ls *LDAPServer) Refresh() error {
apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()), ak.PaginatorOptions{
PageSize: 100,
@@ -51,7 +41,6 @@ func (ls *LDAPServer) Refresh() error {
return errors.New("no ldap provider defined")
}
providers := make([]*ProviderInstance, len(apiProviders))
- invalidationFlow := ls.getInvalidationFlow()
for idx, provider := range apiProviders {
userDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUUsers, *provider.BaseDn))
groupDN := strings.ToLower(fmt.Sprintf("ou=%s,%s", constants.OUGroups, *provider.BaseDn))
@@ -75,7 +64,7 @@ func (ls *LDAPServer) Refresh() error {
UserDN: userDN,
appSlug: provider.ApplicationSlug,
authenticationFlowSlug: provider.BindFlowSlug,
- invalidationFlowSlug: invalidationFlow,
+ invalidationFlowSlug: provider.UnbindFlowSlug.Get(),
boundUsersMutex: usersMutex,
boundUsers: users,
s: ls,
diff --git a/internal/outpost/ldap/server/base.go b/internal/outpost/ldap/server/base.go
index 092959f8b8..2090a69c22 100644
--- a/internal/outpost/ldap/server/base.go
+++ b/internal/outpost/ldap/server/base.go
@@ -12,7 +12,7 @@ type LDAPServerInstance interface {
GetOutpostName() string
GetAuthenticationFlowSlug() string
- GetInvalidationFlowSlug() string
+ GetInvalidationFlowSlug() *string
GetAppSlug() string
GetProviderID() int32
diff --git a/schema.yml b/schema.yml
index 3e6dd0534a..2eb347b4d2 100644
--- a/schema.yml
+++ b/schema.yml
@@ -20755,6 +20755,11 @@ paths:
schema:
type: string
format: uuid
+ - in: query
+ name: invalidation_flow
+ schema:
+ type: string
+ format: uuid
- in: query
name: is_backchannel
schema:
@@ -37547,6 +37552,7 @@ components:
- $ref: '#/components/schemas/PlexAuthenticationChallenge'
- $ref: '#/components/schemas/PromptChallenge'
- $ref: '#/components/schemas/RedirectChallenge'
+ - $ref: '#/components/schemas/SessionEndChallenge'
- $ref: '#/components/schemas/ShellChallenge'
- $ref: '#/components/schemas/UserLoginChallenge'
discriminator:
@@ -37573,6 +37579,7 @@ components:
ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge'
ak-stage-prompt: '#/components/schemas/PromptChallenge'
xak-flow-redirect: '#/components/schemas/RedirectChallenge'
+ ak-stage-session-end: '#/components/schemas/SessionEndChallenge'
xak-flow-shell: '#/components/schemas/ShellChallenge'
ak-stage-user-login: '#/components/schemas/UserLoginChallenge'
ClientTypeEnum:
@@ -40923,6 +40930,11 @@ components:
description: DN under which objects are accessible.
bind_flow_slug:
type: string
+ unbind_flow_slug:
+ type: string
+ nullable: true
+ description: Get slug for unbind flow, defaulting to brand's default flow.
+ readOnly: true
application_slug:
type: string
description: Prioritise backchannel slug over direct application slug
@@ -40964,6 +40976,7 @@ components:
- bind_flow_slug
- name
- pk
+ - unbind_flow_slug
LDAPProvider:
type: object
description: LDAPProvider Serializer
@@ -40984,6 +40997,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -41068,6 +41085,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- outpost_set
@@ -41091,6 +41109,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -41134,6 +41156,7 @@ components:
if it contains a semicolon.
required:
- authorization_flow
+ - invalidation_flow
- name
LDAPSource:
type: object
@@ -42282,6 +42305,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -42379,6 +42406,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- pk
@@ -42401,6 +42429,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -42465,6 +42497,7 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to authenticate.
required:
- authorization_flow
+ - invalidation_flow
- name
OAuth2ProviderSetupURLs:
type: object
@@ -45857,6 +45890,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -46177,6 +46214,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -46701,6 +46742,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -46806,6 +46851,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -46856,6 +46905,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -46947,6 +47000,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -48465,6 +48522,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -48509,6 +48570,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- pk
@@ -48548,6 +48610,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -48555,6 +48621,7 @@ components:
format: uuid
required:
- authorization_flow
+ - invalidation_flow
- name
ProviderTypeEnum:
enum:
@@ -48695,6 +48762,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -48811,6 +48882,7 @@ components:
- client_id
- component
- external_host
+ - invalidation_flow
- meta_model_name
- name
- outpost_set
@@ -48835,6 +48907,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -48905,6 +48981,7 @@ components:
required:
- authorization_flow
- external_host
+ - invalidation_flow
- name
RACPropertyMapping:
type: object
@@ -48998,6 +49075,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49055,6 +49136,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- outpost_set
@@ -49078,6 +49160,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49094,6 +49180,7 @@ components:
description: When set to true, connection tokens will be deleted upon disconnect.
required:
- authorization_flow
+ - invalidation_flow
- name
RadiusCheckAccess:
type: object
@@ -49159,6 +49246,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49223,6 +49314,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- outpost_set
@@ -49313,6 +49405,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49337,6 +49433,7 @@ components:
if it contains a semicolon.
required:
- authorization_flow
+ - invalidation_flow
- name
RedirectChallenge:
type: object
@@ -49647,6 +49744,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49785,6 +49886,7 @@ components:
- assigned_backchannel_application_slug
- authorization_flow
- component
+ - invalidation_flow
- meta_model_name
- name
- pk
@@ -49806,12 +49908,16 @@ components:
authorization_flow:
type: string
format: uuid
+ invalidation_flow:
+ type: string
+ format: uuid
file:
type: string
format: binary
required:
- authorization_flow
- file
+ - invalidation_flow
- name
SAMLProviderRequest:
type: object
@@ -49830,6 +49936,10 @@ components:
type: string
format: uuid
description: Flow used when authorizing this provider.
+ invalidation_flow:
+ type: string
+ format: uuid
+ description: Flow used ending the session from a provider.
property_mappings:
type: array
items:
@@ -49912,6 +50022,7 @@ components:
required:
- acs_url
- authorization_flow
+ - invalidation_flow
- name
SAMLSource:
type: object
@@ -50952,6 +51063,37 @@ components:
required:
- healthy
- version
+ SessionEndChallenge:
+ type: object
+ description: Challenge for ending a session
+ properties:
+ flow_info:
+ $ref: '#/components/schemas/ContextualFlowInfo'
+ component:
+ type: string
+ default: ak-stage-session-end
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ application_name:
+ type: string
+ application_launch_url:
+ type: string
+ invalidation_flow_url:
+ type: string
+ brand_name:
+ type: string
+ required:
+ - brand_name
+ - pending_user
+ - pending_user_avatar
SessionUser:
type: object
description: |-
diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py
index 0bdd167560..ded28f5958 100644
--- a/tests/e2e/test_provider_oauth2_grafana.py
+++ b/tests/e2e/test_provider_oauth2_grafana.py
@@ -181,9 +181,15 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
+ "default/default-brand.yaml",
+ )
+ @apply_blueprint(
+ "default/flow-default-provider-authorization-implicit-consent.yaml",
+ "default/flow-default-provider-invalidation.yaml",
+ )
+ @apply_blueprint(
+ "system/providers-oauth2.yaml",
)
- @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
- @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_logout(self):
"""test OpenID Provider flow with logout"""
@@ -192,6 +198,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
+ invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow")
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL,
@@ -200,6 +207,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
signing_key=create_test_cert(),
redirect_uris="http://localhost:3000/login/generic_oauth",
authorization_flow=authorization_flow,
+ invalidation_flow=invalidation_flow,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -242,11 +250,13 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.get("http://localhost:3000/logout")
self.wait_for_url(
self.url(
- "authentik_core:if-session-end",
- application_slug=self.app_slug,
+ "authentik_core:if-flow",
+ flow_slug=invalidation_flow.slug,
)
)
- self.driver.find_element(By.ID, "logout").click()
+ flow_executor = self.get_shadow_root("ak-flow-executor")
+ session_end_stage = self.get_shadow_root("ak-stage-session-end", flow_executor)
+ session_end_stage.find_element(By.ID, "logout").click()
@retry()
@apply_blueprint(
diff --git a/tests/e2e/test_provider_proxy.py b/tests/e2e/test_provider_proxy.py
index bb4844dcae..30c7060071 100644
--- a/tests/e2e/test_provider_proxy.py
+++ b/tests/e2e/test_provider_proxy.py
@@ -65,6 +65,7 @@ class TestProviderProxy(SeleniumTestCase):
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
+ "default/flow-default-provider-invalidation.yaml",
)
@apply_blueprint(
"system/providers-oauth2.yaml",
@@ -82,6 +83,7 @@ class TestProviderProxy(SeleniumTestCase):
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
+ invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
)
@@ -120,8 +122,10 @@ class TestProviderProxy(SeleniumTestCase):
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
sleep(2)
- full_body_text = self.driver.find_element(By.CSS_SELECTOR, ".pf-c-title.pf-m-3xl").text
- self.assertIn("You've logged out of", full_body_text)
+ flow_executor = self.get_shadow_root("ak-flow-executor")
+ session_end_stage = self.get_shadow_root("ak-stage-session-end", flow_executor)
+ title = session_end_stage.find_element(By.CSS_SELECTOR, ".pf-c-title.pf-m-3xl").text
+ self.assertIn("You've logged out of", title)
@retry()
@apply_blueprint(
@@ -130,6 +134,7 @@ class TestProviderProxy(SeleniumTestCase):
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
+ "default/flow-default-provider-invalidation.yaml",
)
@apply_blueprint(
"system/providers-oauth2.yaml",
@@ -149,6 +154,7 @@ class TestProviderProxy(SeleniumTestCase):
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
+ invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
basic_auth_enabled=True,
@@ -191,8 +197,10 @@ class TestProviderProxy(SeleniumTestCase):
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
sleep(2)
- full_body_text = self.driver.find_element(By.CSS_SELECTOR, ".pf-c-title.pf-m-3xl").text
- self.assertIn("You've logged out of", full_body_text)
+ flow_executor = self.get_shadow_root("ak-flow-executor")
+ session_end_stage = self.get_shadow_root("ak-stage-session-end", flow_executor)
+ title = session_end_stage.find_element(By.CSS_SELECTOR, ".pf-c-title.pf-m-3xl").text
+ self.assertIn("You've logged out of", title)
# TODO: Fix flaky test
diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py
index 27c7c94fc4..170353741e 100644
--- a/tests/e2e/test_provider_saml.py
+++ b/tests/e2e/test_provider_saml.py
@@ -414,6 +414,7 @@ class TestProviderSAML(SeleniumTestCase):
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
+ "default/flow-default-provider-invalidation.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
@@ -425,6 +426,7 @@ class TestProviderSAML(SeleniumTestCase):
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
+ invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow")
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url="http://localhost:9009/saml/acs",
@@ -432,11 +434,12 @@ class TestProviderSAML(SeleniumTestCase):
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
+ invalidation_flow=invalidation_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
- app = Application.objects.create(
+ Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
@@ -447,9 +450,11 @@ class TestProviderSAML(SeleniumTestCase):
self.wait_for_url("http://localhost:9009/")
self.driver.get("http://localhost:9009/saml/logout")
- self.wait_for_url(
- self.url(
- "authentik_core:if-session-end",
- application_slug=app.slug,
- )
+ should_url = self.url(
+ "authentik_core:if-flow",
+ flow_slug=invalidation_flow.slug,
+ )
+ self.wait.until(
+ lambda driver: driver.current_url.startswith(should_url),
+ f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
)
diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts
index dd913dd3ed..d57cbd5908 100644
--- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts
+++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts
@@ -101,6 +101,21 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
${msg("Flow used when authorizing this provider.")}
+
+
+
+ ${msg("Flow used when logging out of this provider.")}
+
+
${msg("Protocol settings")}
diff --git a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts
index df65e92183..725fe1e5a4 100644
--- a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts
+++ b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts
@@ -121,10 +121,9 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
)}
-
@@ -137,6 +136,21 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
${msg("Flow used when authorizing this provider.")}
+
+
+
+ ${msg("Flow used when logging out of this provider.")}
+
+
${this.renderProxyMode()}
diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts
index 403398bf37..a2ca32cecf 100644
--- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts
+++ b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts
@@ -97,7 +97,6 @@ export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPane
${msg(
@@ -105,7 +104,6 @@ export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPane
)}
-
+
+
+
+ ${msg("Flow used when logging out of this provider.")}
+
+
${msg("Protocol settings")}
diff --git a/web/src/admin/providers/ldap/LDAPProviderForm.ts b/web/src/admin/providers/ldap/LDAPProviderForm.ts
index c0c6a5a1e5..69828add78 100644
--- a/web/src/admin/providers/ldap/LDAPProviderForm.ts
+++ b/web/src/admin/providers/ldap/LDAPProviderForm.ts
@@ -57,19 +57,6 @@ export class LDAPProviderFormPage extends WithBrandConfig(BaseProviderForm
-
-
- ${msg("Flow used for users to authenticate.")}
-
+
+ ${msg("Flow settings")}
+
+
${msg("Protocol settings")}