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 %} -
-

- {% blocktrans with application=application.name branding_title=brand.branding_title %} - You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account. - {% endblocktrans %} -

- - - {% trans 'Go back to overview' %} - - - - {% blocktrans with branding_title=brand.branding_title %} - Log out of {{ branding_title }} - {% endblocktrans %} - - - {% if application.get_launch_url %} - - {% blocktrans with application=application.name %} - Log back into {{ application }} - {% endblocktrans %} - - {% endif %} - -
-{% 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("Flow used for users to authenticate.")} +

+
+ + +

+ ${msg("Flow used for unbinding users.")} +

+
+
+
${msg("Protocol settings")}
diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index f9ece5a3ad..98041ec4d2 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -155,34 +155,7 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { required > - - -

- ${msg("Flow used when a user access this provider and is not authenticated.")} -

-
- - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - + ${msg("Protocol settings")}
{
+ + ${msg("Flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ ${msg("Advanced protocol settings")}
diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 60ea064708..48ecc5e30a 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -249,7 +249,8 @@ export class ProxyProviderFormPage extends BaseProviderForm { } renderForm(): TemplateResult { - return html` + return html` + { required /> - - -

- ${msg("Flow used when a user access this provider and is not authenticated.")} -

-
- - -

- ${msg("Flow used when authorizing this provider.")} -

-
${this.renderModeSelector()}
@@ -418,7 +391,57 @@ ${this.instance?.skipPathRegex}
- `; + + + + ${msg("Flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ `; } } diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index b15beeb75d..ae18f67200 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.ts @@ -89,34 +89,6 @@ export class SAMLProviderFormPage extends BaseProviderForm { required />
- - -

- ${msg("Flow used when a user access this provider and is not authenticated.")} -

-
- - -

- ${msg("Flow used when authorizing this provider.")} -

-
${msg("Protocol settings")} @@ -182,6 +154,55 @@ export class SAMLProviderFormPage extends BaseProviderForm {
+ + ${msg("Flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ ${msg("Advanced protocol settings")}
diff --git a/web/src/admin/providers/saml/SAMLProviderImportForm.ts b/web/src/admin/providers/saml/SAMLProviderImportForm.ts index f348cdcdca..98b7c3409c 100644 --- a/web/src/admin/providers/saml/SAMLProviderImportForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderImportForm.ts @@ -26,6 +26,7 @@ export class SAMLProviderImportForm extends Form { file: file, name: data.name, authorizationFlow: data.authorizationFlow || "", + invalidationFlow: data.invalidationFlow || "", }); } @@ -46,6 +47,19 @@ export class SAMLProviderImportForm extends Form { ${msg("Flow used when authorizing this provider.")}

+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 3fd9f0c912..4dcf5c6f9b 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -408,6 +408,12 @@ export class FlowExecutor extends Interface implements StageHost { .host=${this as StageHost} .challenge=${this.challenge} >`; + case "ak-stage-session-end": + await import("@goauthentik/flow/providers/SessionEnd"); + return html``; // Internal stages case "ak-stage-flow-error": return html` { + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton]; + } + + render(): TemplateResult { + if (!this.challenge) { + return html` + `; + } + return html` + +
+ +
`; + } +} diff --git a/website/docs/add-secure-apps/flows-stages/flow/index.md b/website/docs/add-secure-apps/flows-stages/flow/index.md index 6bfc125f14..18707cd67a 100644 --- a/website/docs/add-secure-apps/flows-stages/flow/index.md +++ b/website/docs/add-secure-apps/flows-stages/flow/index.md @@ -66,17 +66,17 @@ When creating or editing a flow in the UI of the Admin interface, you can set th - **Authentication**: this option designates a flow to be used for authentication. The authentication flow should always contain a [**User Login**](../stages/user_login/index.md) stage, which attaches the staged user to the current session. -- **Authorization**: designates a flow to be used for authorization. The authorization flow `default-provider-authorization-explicit-consent` should always contain a consent stage. +- **Authorization**: designates a flow to be used for authorization of an application. Can be used to add additional verification steps before the user is allowed to access an application. -- **Invalidation**: designates a flow to be used to invalidate a session. This flow should always contain a [**User Logout**](../stages/user_logout.md) stage, which resets the current session. +- **Invalidation**: designates a flow to be used to invalidate a session. Both used to invalidate a session from authentik and when the session of an application ends. When used as a global invalidation flow should contain a [**User Logout**](../stages/user_logout.md) stage. -- **Enrollment**: designates a flow for enrollment. This flow can contain any amount of verification stages, such as [**email**](../stages/email/index.mdx) or [**captcha**](../stages/captcha/index.md). At the end, to create the user, you can use the [**user_write**](../stages/user_write.md) stage, which either updates the currently staged user, or if none exists, creates a new one. +- **Enrollment**: designates a flow for enrollment. This flow can contain any amount of verification stages, such as [**Email**](../stages/email/index.mdx) or [**Captcha**](../stages/captcha/index.md). At the end, to create the user, you can use the [**User Write**](../stages/user_write.md) stage, which either updates the currently staged user, or if none exists, creates a new one. -- **Unenrollment**: designates a flow for unenrollment. This flow can contain any amount of verification stages, such as [**email**](../stages/email/index.mdx) or [**captcha**](../stages/captcha/index.md). As a final stage, to delete the account, use the [**user_delete**](../stages/user_delete.md) stage. +- **Unenrollment**: designates a flow for unenrollment. This flow can contain any amount of verification stages, such as [**email**](../stages/email/index.mdx) or [**Captcha**](../stages/captcha/index.md). As a final stage, to delete the account, use the [**user_delete**](../stages/user_delete.md) stage. -- **Recovery**: designates a flow for recovery. This flow normally contains an [**identification**](../stages/identification/index.md) stage to find the user. It can also contain any amount of verification stages, such as [**email**](../stages/email/index.mdx) or [**captcha**](../stages/captcha/index.md). Afterwards, use the [**prompt**](../stages/prompt/index.md) stage to ask the user for a new password and the [**user_write**](../stages/user_write.md) stage to update the password. +- **Recovery**: designates a flow for recovery. This flow normally contains an [**Identification**](../stages/identification/index.md) stage to find the user. It can also contain any amount of verification stages, such as [**Email**](../stages/email/index.mdx) or [**captcha**](../stages/captcha/index.md). Afterwards, use the [**Prompt**](../stages/prompt/index.md) stage to ask the user for a new password and the [**User Write**](../stages/user_write.md) stage to update the password. -- **Stage configuration**: designates a flow for general setup. This designation doesn't have any constraints in what you can do. For example, by default this designation is used to configure Factors, like change a password and setup TOTP. +- **Stage configuration**: designates a flow for general setup. This designation doesn't have any constraints in what you can do. For example, by default this designation is used to configure authenticators, like change a password and setup TOTP. **Authentication**: Using this option, you can configure whether the the flow requires initial authentication or not, whether the user must be a superuser, or if the flow requires an outpost. diff --git a/website/docs/releases/2024/v2024.10.md b/website/docs/releases/2024/v2024.10.md new file mode 100644 index 0000000000..08defac33a --- /dev/null +++ b/website/docs/releases/2024/v2024.10.md @@ -0,0 +1,54 @@ +--- +title: Release 2024.10 +slug: "/releases/2024.10" +--- + +:::::note +2024.10 has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates. + +To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as 2024.10.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet. +::::: + + + +## New features + +- **Invalidation flows for providers** + + The sign-out experience when the session in an application ends can be configured now. Previously where this was always a static page, any flow can be used now. This can be used for additional validation, or redirecting the user to a custom URL. + +## Upgrading + +This release does not introduce any new requirements. You can follow the upgrade instructions below; for more detailed information about upgrading authentik, refer to our [Upgrade documentation](../../install-config/upgrade.mdx). + +:::warning +When you upgrade, be aware that the version of the authentik instance and of any outposts must be the same. We recommended that you always upgrade any outposts at the same time you upgrade your authentik instance. +::: + +### Docker Compose + +To upgrade, download the new docker-compose file and update the Docker stack with the new version, using these commands: + +```shell +wget -O docker-compose.yml https://goauthentik.io/version/2024.10/docker-compose.yml +docker compose up -d +``` + +The `-O` flag retains the downloaded file's name, overwriting any existing local file with the same name. + +### Kubernetes + +Upgrade the Helm Chart to the new version, using the following commands: + +```shell +helm repo update +helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.10 +``` + +## Minor changes/fixes + + + +## API Changes + +