diff --git a/authentik/flows/migrations/0027_auto_20231028_1424.py b/authentik/flows/migrations/0027_auto_20231028_1424.py index 1d0597a879..a46aec0a35 100644 --- a/authentik/flows/migrations/0027_auto_20231028_1424.py +++ b/authentik/flows/migrations/0027_auto_20231028_1424.py @@ -40,6 +40,7 @@ class Migration(migrations.Migration): ("require_authenticated", "Require Authenticated"), ("require_unauthenticated", "Require Unauthenticated"), ("require_superuser", "Require Superuser"), + ("require_redirect", "Require Redirect"), ("require_outpost", "Require Outpost"), ], default="none", diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 622fdff4e3..69b794172b 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -33,6 +33,7 @@ class FlowAuthenticationRequirement(models.TextChoices): REQUIRE_AUTHENTICATED = "require_authenticated" REQUIRE_UNAUTHENTICATED = "require_unauthenticated" REQUIRE_SUPERUSER = "require_superuser" + REQUIRE_REDIRECT = "require_redirect" REQUIRE_OUTPOST = "require_outpost" diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index d20ea19f81..1ede566e78 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -42,6 +42,8 @@ PLAN_CONTEXT_OUTPOST = "outpost" # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan # was restored. PLAN_CONTEXT_IS_RESTORED = "is_restored" +PLAN_CONTEXT_IS_REDIRECTED = "is_redirected" +PLAN_CONTEXT_REDIRECT_STAGE_TARGET = "redirect_stage_target" CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows") CACHE_PREFIX = "goauthentik.io/flows/planner/" @@ -181,7 +183,7 @@ class FlowPlanner: self.flow = flow self._logger = get_logger().bind(flow_slug=flow.slug) - def _check_authentication(self, request: HttpRequest): + def _check_authentication(self, request: HttpRequest, context: dict[str, Any]): """Check the flow's authentication level is matched by `request`""" if ( self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED @@ -198,6 +200,11 @@ class FlowPlanner: and not request.user.is_superuser ): raise FlowNonApplicableException() + if ( + self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_REDIRECT + and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None + ): + raise FlowNonApplicableException() outpost_user = ClientIPMiddleware.get_outpost_user(request) if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: if not outpost_user: @@ -229,18 +236,13 @@ class FlowPlanner: ) context = default_context or {} # Bit of a workaround here, if there is a pending user set in the default context - # we use that user for our cache key - # to make sure they don't get the generic response + # we use that user for our cache key to make sure they don't get the generic response if context and PLAN_CONTEXT_PENDING_USER in context: user = context[PLAN_CONTEXT_PENDING_USER] else: user = request.user - # We only need to check the flow authentication if it's planned without a user - # in the context, as a user in the context can only be set via the explicit code API - # or if a flow is restarted due to `invalid_response_action` being set to - # `restart_with_context`, which can only happen if the user was already authorized - # to use the flow - context.update(self._check_authentication(request)) + + context.update(self._check_authentication(request, context)) # First off, check the flow's direct policy bindings # to make sure the user even has access to the flow engine = PolicyEngine(self.flow, user, request) diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index e110c63f22..58fa1a74d3 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -93,7 +93,11 @@ class ChallengeStageView(StageView): def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Return a challenge for the frontend to solve""" - challenge = self._get_challenge(*args, **kwargs) + try: + challenge = self._get_challenge(*args, **kwargs) + except StageInvalidException as exc: + self.logger.debug("Got StageInvalidException", exc=exc) + return self.executor.stage_invalid() if not challenge.is_valid(): self.logger.warning( "f(ch): Invalid challenge", @@ -169,11 +173,7 @@ class ChallengeStageView(StageView): stage_type=self.__class__.__name__, method="get_challenge" ).time(), ): - try: - challenge = self.get_challenge(*args, **kwargs) - except StageInvalidException as exc: - self.logger.debug("Got StageInvalidException", exc=exc) - return self.executor.stage_invalid() + challenge = self.get_challenge(*args, **kwargs) with start_span( op="authentik.flow.stage._get_challenge", name=self.__class__.__name__, diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py index e0eb515c12..5711875e57 100644 --- a/authentik/flows/tests/test_planner.py +++ b/authentik/flows/tests/test_planner.py @@ -22,7 +22,12 @@ from authentik.flows.models import ( FlowStageBinding, in_memory_stage, ) -from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key +from authentik.flows.planner import ( + PLAN_CONTEXT_IS_REDIRECTED, + PLAN_CONTEXT_PENDING_USER, + FlowPlanner, + cache_key, +) from authentik.flows.stage import StageView from authentik.lib.tests.utils import dummy_get_response from authentik.outposts.apps import MANAGED_OUTPOST @@ -81,6 +86,24 @@ class TestFlowPlanner(TestCase): planner.allow_empty_flows = True planner.plan(request) + def test_authentication_redirect_required(self): + """Test flow authentication (redirect required)""" + flow = create_test_flow() + flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT + request = self.request_factory.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = AnonymousUser() + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + + with self.assertRaises(FlowNonApplicableException): + planner.plan(request) + + context = {} + context[PLAN_CONTEXT_IS_REDIRECTED] = create_test_flow() + planner.plan(request, context) + @reconcile_app("authentik_outposts") def test_authentication_outpost(self): """Test flow authentication (outpost)""" diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index be6e96f3d8..101913f13c 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -171,7 +171,8 @@ class FlowExecutorView(APIView): # Existing plan is deleted from session and instance self.plan = None self.cancel() - self._logger.debug("f(exec): Continuing existing plan") + else: + self._logger.debug("f(exec): Continuing existing plan") # Initial flow request, check if we have an upstream query string passed in request.session[SESSION_KEY_GET] = get_params diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 5900ef8df7..5d7eeebdca 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -114,6 +114,7 @@ TENANT_APPS = [ "authentik.stages.invitation", "authentik.stages.password", "authentik.stages.prompt", + "authentik.stages.redirect", "authentik.stages.user_delete", "authentik.stages.user_login", "authentik.stages.user_logout", diff --git a/authentik/stages/redirect/__init__.py b/authentik/stages/redirect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/stages/redirect/api.py b/authentik/stages/redirect/api.py new file mode 100644 index 0000000000..9fe20f4644 --- /dev/null +++ b/authentik/stages/redirect/api.py @@ -0,0 +1,42 @@ +"""RedirectStage API Views""" + +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import ValidationError +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.flows.api.stages import StageSerializer +from authentik.stages.redirect.models import RedirectMode, RedirectStage + + +class RedirectStageSerializer(StageSerializer): + """RedirectStage Serializer""" + + def validate(self, attrs): + mode = attrs.get("mode") + target_static = attrs.get("target_static") + target_flow = attrs.get("target_flow") + if mode == RedirectMode.STATIC and not target_static: + raise ValidationError(_("Target URL should be present when mode is Static.")) + if mode == RedirectMode.FLOW and not target_flow: + raise ValidationError(_("Target Flow should be present when mode is Flow.")) + return attrs + + class Meta: + model = RedirectStage + fields = StageSerializer.Meta.fields + [ + "keep_context", + "mode", + "target_static", + "target_flow", + ] + + +class RedirectStageViewSet(UsedByMixin, ModelViewSet): + """RedirectStage Viewset""" + + queryset = RedirectStage.objects.all() + serializer_class = RedirectStageSerializer + filterset_fields = ["name"] + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/stages/redirect/apps.py b/authentik/stages/redirect/apps.py new file mode 100644 index 0000000000..782aab69b3 --- /dev/null +++ b/authentik/stages/redirect/apps.py @@ -0,0 +1,11 @@ +"""authentik redirect app""" + +from django.apps import AppConfig + + +class AuthentikStageRedirectConfig(AppConfig): + """authentik redirect app""" + + name = "authentik.stages.redirect" + label = "authentik_stages_redirect" + verbose_name = "authentik Stages.Redirect" diff --git a/authentik/stages/redirect/migrations/0001_initial.py b/authentik/stages/redirect/migrations/0001_initial.py new file mode 100644 index 0000000000..5db2e45701 --- /dev/null +++ b/authentik/stages/redirect/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0.10 on 2024-12-11 14:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0027_auto_20231028_1424"), + ] + + operations = [ + migrations.CreateModel( + name="RedirectStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ("keep_context", models.BooleanField(default=True)), + ("mode", models.TextField(choices=[("static", "Static"), ("flow", "Flow")])), + ("target_static", models.CharField(blank=True, default="")), + ( + "target_flow", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_flows.flow", + ), + ), + ], + options={ + "verbose_name": "Redirect Stage", + "verbose_name_plural": "Redirect Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/redirect/migrations/__init__.py b/authentik/stages/redirect/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/stages/redirect/models.py b/authentik/stages/redirect/models.py new file mode 100644 index 0000000000..a022ed88b9 --- /dev/null +++ b/authentik/stages/redirect/models.py @@ -0,0 +1,49 @@ +"""authentik redirect stage""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Flow, Stage + + +class RedirectMode(models.TextChoices): + """Mode a Redirect stage can operate in""" + + STATIC = "static" + FLOW = "flow" + + +class RedirectStage(Stage): + """Redirect the user to another flow, potentially with all gathered context""" + + keep_context = models.BooleanField(default=True) + mode = models.TextField(choices=RedirectMode.choices) + target_static = models.CharField(blank=True, default="") + target_flow = models.ForeignKey( + Flow, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.stages.redirect.api import RedirectStageSerializer + + return RedirectStageSerializer + + @property + def view(self) -> type[View]: + from authentik.stages.redirect.stage import RedirectStageView + + return RedirectStageView + + @property + def component(self) -> str: + return "ak-stage-redirect-form" + + class Meta: + verbose_name = _("Redirect Stage") + verbose_name_plural = _("Redirect Stages") diff --git a/authentik/stages/redirect/stage.py b/authentik/stages/redirect/stage.py new file mode 100644 index 0000000000..9083af7dc7 --- /dev/null +++ b/authentik/stages/redirect/stage.py @@ -0,0 +1,110 @@ +"""authentik redirect stage""" + +from urllib.parse import urlsplit + +from django.http.response import HttpResponse +from rest_framework.fields import CharField + +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + RedirectChallenge, +) +from authentik.flows.exceptions import FlowNonApplicableException +from authentik.flows.models import ( + Flow, +) +from authentik.flows.planner import ( + PLAN_CONTEXT_IS_REDIRECTED, + PLAN_CONTEXT_REDIRECT_STAGE_TARGET, + FlowPlanner, +) +from authentik.flows.stage import ChallengeStageView +from authentik.flows.views.executor import SESSION_KEY_PLAN, InvalidStageError +from authentik.lib.utils.urls import reverse_with_qs +from authentik.stages.redirect.models import RedirectMode, RedirectStage + +URL_SCHEME_FLOW = "ak-flow" + + +class RedirectChallengeResponse(ChallengeResponse): + """Redirect challenge response""" + + component = CharField(default="xak-flow-redirect") + to = CharField() + + +class RedirectStageView(ChallengeStageView): + """Redirect stage to redirect to other Flows with context""" + + response_class = RedirectChallengeResponse + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + return self.executor.stage_ok() + + def parse_target(self, target: str) -> str | Flow: + parsed_target = urlsplit(target) + + if parsed_target.scheme != URL_SCHEME_FLOW: + return target + + flow = Flow.objects.filter(slug=parsed_target.netloc).first() + if not flow: + self.logger.warning( + f"Flow set by {PLAN_CONTEXT_REDIRECT_STAGE_TARGET} does not exist", + flow_slug=parsed_target.path, + ) + return flow + + def switch_flow_with_context(self, flow: Flow, keep_context=True) -> str: + """Switch to another flow, optionally keeping all context""" + self.logger.info( + "f(exec): Switching to new flow", new_flow=flow.slug, keep_context=keep_context + ) + planner = FlowPlanner(flow) + planner.use_cache = False + default_context = self.executor.plan.context if keep_context else {} + try: + default_context[PLAN_CONTEXT_IS_REDIRECTED] = self.executor.flow + plan = planner.plan(self.request, default_context) + except FlowNonApplicableException as exc: + raise InvalidStageError() from exc + self.request.session[SESSION_KEY_PLAN] = plan + kwargs = self.executor.kwargs + kwargs.update({"flow_slug": flow.slug}) + return reverse_with_qs("authentik_core:if-flow", self.request.GET, kwargs=kwargs) + + def get_challenge(self, *args, **kwargs) -> Challenge: + """Get the redirect target. Prioritize `redirect_stage_target` if present.""" + + current_stage: RedirectStage = self.executor.current_stage + target: str | Flow = "" + + target_url_override = self.executor.plan.context.get(PLAN_CONTEXT_REDIRECT_STAGE_TARGET, "") + if target_url_override: + target = self.parse_target(target_url_override) + # `target` is falsy if the override was to a Flow but that Flow doesn't exist. + if not target: + if current_stage.mode == RedirectMode.STATIC: + target = current_stage.target_static + if current_stage.mode == RedirectMode.FLOW: + target = current_stage.target_flow + + if isinstance(target, str): + redirect_to = target + else: + redirect_to = self.switch_flow_with_context( + target, keep_context=current_stage.keep_context + ) + + if not redirect_to: + raise InvalidStageError( + "No target found for Redirect stage. The stage's target_flow may have been deleted." + ) + + return RedirectChallenge( + data={ + "component": "xak-flow-redirect", + "to": redirect_to, + } + ) diff --git a/authentik/stages/redirect/tests.py b/authentik/stages/redirect/tests.py new file mode 100644 index 0000000000..7b59fee87a --- /dev/null +++ b/authentik/stages/redirect/tests.py @@ -0,0 +1,172 @@ +"""Test Redirect stage""" + +from django.urls.base import reverse +from rest_framework.exceptions import ValidationError + +from authentik.core.tests.utils import create_test_flow +from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding +from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.stages.dummy.models import DummyStage +from authentik.stages.redirect.api import RedirectStageSerializer +from authentik.stages.redirect.models import RedirectMode, RedirectStage + +URL = "https://url.test/" +URL_OVERRIDE = "https://urloverride.test/" + + +class TestRedirectStage(FlowTestCase): + """Test Redirect stage API""" + + def setUp(self): + super().setUp() + self.target_flow = create_test_flow(FlowDesignation.AUTHENTICATION) + self.dummy_stage = DummyStage.objects.create(name="dummy") + FlowStageBinding.objects.create(target=self.target_flow, stage=self.dummy_stage, order=0) + self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) + self.stage = RedirectStage.objects.create( + name="redirect", + keep_context=True, + mode=RedirectMode.STATIC, + target_static=URL, + target_flow=self.target_flow, + ) + self.binding = FlowStageBinding.objects.create( + target=self.flow, + stage=self.stage, + order=0, + ) + + def test_static(self): + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects(response, URL) + + def test_flow(self): + self.stage.mode = RedirectMode.FLOW + self.stage.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects( + response, reverse("authentik_core:if-flow", kwargs={"flow_slug": self.target_flow.slug}) + ) + + def test_override_static(self): + policy = ExpressionPolicy.objects.create( + name=generate_id(), + expression=f"context['flow_plan'].context['redirect_stage_target'] = " + f"'{URL_OVERRIDE}'; return True", + ) + PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects(response, URL_OVERRIDE) + + def test_override_flow(self): + target_flow_override = create_test_flow(FlowDesignation.AUTHENTICATION) + dummy_stage_override = DummyStage.objects.create(name="dummy_override") + FlowStageBinding.objects.create( + target=target_flow_override, stage=dummy_stage_override, order=0 + ) + policy = ExpressionPolicy.objects.create( + name=generate_id(), + expression=f"context['flow_plan'].context['redirect_stage_target'] = " + f"'ak-flow://{target_flow_override.slug}'; return True", + ) + PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects( + response, + reverse("authentik_core:if-flow", kwargs={"flow_slug": target_flow_override.slug}), + ) + + def test_override_nonexistant_flow(self): + policy = ExpressionPolicy.objects.create( + name=generate_id(), + expression="context['flow_plan'].context['redirect_stage_target'] = " + "'ak-flow://nonexistent'; return True", + ) + PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects(response, URL) + + def test_target_flow_requires_redirect(self): + self.target_flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT + self.target_flow.save() + self.stage.mode = RedirectMode.FLOW + self.stage.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageRedirects( + response, reverse("authentik_core:if-flow", kwargs={"flow_slug": self.target_flow.slug}) + ) + + def test_target_flow_non_applicable(self): + self.target_flow.authentication = FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED + self.target_flow.save() + self.stage.mode = RedirectMode.FLOW + self.stage.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertStageResponse(response, component="ak-stage-access-denied") + + def test_serializer(self): + with self.assertRaises(ValidationError): + RedirectStageSerializer( + data={ + "name": generate_id(20), + "mode": RedirectMode.STATIC, + } + ).is_valid(raise_exception=True) + + self.assertTrue( + RedirectStageSerializer( + data={ + "name": generate_id(20), + "mode": RedirectMode.STATIC, + "target_static": URL, + } + ).is_valid(raise_exception=True) + ) + + with self.assertRaises(ValidationError): + RedirectStageSerializer( + data={ + "name": generate_id(20), + "mode": RedirectMode.FLOW, + } + ).is_valid(raise_exception=True) + + self.assertTrue( + RedirectStageSerializer( + data={ + "name": generate_id(20), + "mode": RedirectMode.FLOW, + "target_flow": create_test_flow().flow_uuid, + } + ).is_valid(raise_exception=True) + ) diff --git a/authentik/stages/redirect/urls.py b/authentik/stages/redirect/urls.py new file mode 100644 index 0000000000..4ada223be2 --- /dev/null +++ b/authentik/stages/redirect/urls.py @@ -0,0 +1,5 @@ +"""API URLs""" + +from authentik.stages.redirect.api import RedirectStageViewSet + +api_urlpatterns = [("stages/redirect", RedirectStageViewSet)] diff --git a/blueprints/schema.json b/blueprints/schema.json index da364522cd..a34894b654 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -2801,6 +2801,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_redirect.redirectstage" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage" + } + } + }, { "type": "object", "required": [ @@ -4023,6 +4063,7 @@ "require_authenticated", "require_unauthenticated", "require_superuser", + "require_redirect", "require_outpost" ], "title": "Authentication", @@ -4493,6 +4534,7 @@ "authentik.stages.invitation", "authentik.stages.password", "authentik.stages.prompt", + "authentik.stages.redirect", "authentik.stages.user_delete", "authentik.stages.user_login", "authentik.stages.user_logout", @@ -4588,6 +4630,7 @@ "authentik_stages_password.passwordstage", "authentik_stages_prompt.prompt", "authentik_stages_prompt.promptstage", + "authentik_stages_redirect.redirectstage", "authentik_stages_user_delete.userdeletestage", "authentik_stages_user_login.userloginstage", "authentik_stages_user_logout.userlogoutstage", @@ -6813,6 +6856,10 @@ "authentik_stages_prompt.delete_promptstage", "authentik_stages_prompt.view_prompt", "authentik_stages_prompt.view_promptstage", + "authentik_stages_redirect.add_redirectstage", + "authentik_stages_redirect.change_redirectstage", + "authentik_stages_redirect.delete_redirectstage", + "authentik_stages_redirect.view_redirectstage", "authentik_stages_source.add_sourcestage", "authentik_stages_source.change_sourcestage", "authentik_stages_source.delete_sourcestage", @@ -11485,6 +11532,146 @@ } } }, + "model_authentik_stages_redirect.redirectstage": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "flow_set": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "pattern": "^[-a-zA-Z0-9_]+$", + "title": "Slug", + "description": "Visible in the URL." + }, + "title": { + "type": "string", + "minLength": 1, + "title": "Title", + "description": "Shown as the Title in Flow pages." + }, + "designation": { + "type": "string", + "enum": [ + "authentication", + "authorization", + "invalidation", + "enrollment", + "unenrollment", + "recovery", + "stage_configuration" + ], + "title": "Designation", + "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." + }, + "policy_engine_mode": { + "type": "string", + "enum": [ + "all", + "any" + ], + "title": "Policy engine mode" + }, + "compatibility_mode": { + "type": "boolean", + "title": "Compatibility mode", + "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices." + }, + "layout": { + "type": "string", + "enum": [ + "stacked", + "content_left", + "content_right", + "sidebar_left", + "sidebar_right" + ], + "title": "Layout" + }, + "denied_action": { + "type": "string", + "enum": [ + "message_continue", + "message", + "continue" + ], + "title": "Denied action", + "description": "Configure what should happen when a flow denies access to a user." + } + }, + "required": [ + "name", + "slug", + "title", + "designation" + ] + }, + "title": "Flow set" + }, + "keep_context": { + "type": "boolean", + "title": "Keep context" + }, + "mode": { + "type": "string", + "enum": [ + "static", + "flow" + ], + "title": "Mode" + }, + "target_static": { + "type": "string", + "title": "Target static" + }, + "target_flow": { + "type": "string", + "format": "uuid", + "title": "Target flow" + } + }, + "required": [] + }, + "model_authentik_stages_redirect.redirectstage_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_redirectstage", + "change_redirectstage", + "delete_redirectstage", + "view_redirectstage" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_user_delete.userdeletestage": { "type": "object", "properties": { @@ -12830,6 +13017,10 @@ "authentik_stages_prompt.delete_promptstage", "authentik_stages_prompt.view_prompt", "authentik_stages_prompt.view_promptstage", + "authentik_stages_redirect.add_redirectstage", + "authentik_stages_redirect.change_redirectstage", + "authentik_stages_redirect.delete_redirectstage", + "authentik_stages_redirect.view_redirectstage", "authentik_stages_source.add_sourcestage", "authentik_stages_source.change_sourcestage", "authentik_stages_source.delete_sourcestage", diff --git a/schema.yml b/schema.yml index faced58bd3..1f74a2ed70 100644 --- a/schema.yml +++ b/schema.yml @@ -23390,6 +23390,7 @@ paths: - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage + - authentik_stages_redirect.redirectstage - authentik_stages_source.sourcestage - authentik_stages_user_delete.userdeletestage - authentik_stages_user_login.userloginstage @@ -23629,6 +23630,7 @@ paths: - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage + - authentik_stages_redirect.redirectstage - authentik_stages_source.sourcestage - authentik_stages_user_delete.userdeletestage - authentik_stages_user_login.userloginstage @@ -35647,6 +35649,275 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /stages/redirect/: + get: + operationId: stages_redirect_list + description: RedirectStage Viewset + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedRedirectStageList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: stages_redirect_create + description: RedirectStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/redirect/{stage_uuid}/: + get: + operationId: stages_redirect_retrieve + description: RedirectStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Redirect Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: stages_redirect_update + description: RedirectStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Redirect Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: stages_redirect_partial_update + description: RedirectStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Redirect Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRedirectStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: stages_redirect_destroy + description: RedirectStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Redirect Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/redirect/{stage_uuid}/used_by/: + get: + operationId: stages_redirect_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Redirect Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/source/: get: operationId: stages_source_list @@ -37678,6 +37949,7 @@ components: - authentik.stages.invitation - authentik.stages.password - authentik.stages.prompt + - authentik.stages.redirect - authentik.stages.user_delete - authentik.stages.user_login - authentik.stages.user_logout @@ -37990,6 +38262,7 @@ components: - require_authenticated - require_unauthenticated - require_superuser + - require_redirect - require_outpost type: string AuthenticatorAttachmentEnum: @@ -41431,6 +41704,7 @@ components: - $ref: '#/components/schemas/PasswordChallengeResponseRequest' - $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' - $ref: '#/components/schemas/PromptChallengeResponseRequest' + - $ref: '#/components/schemas/RedirectChallengeResponseRequest' - $ref: '#/components/schemas/UserLoginChallengeResponseRequest' discriminator: propertyName: component @@ -41454,6 +41728,7 @@ components: ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest' ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest' + xak-flow-redirect: '#/components/schemas/RedirectChallengeResponseRequest' ak-stage-user-login: '#/components/schemas/UserLoginChallengeResponseRequest' FlowDesignationEnum: enum: @@ -44447,6 +44722,7 @@ components: - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage + - authentik_stages_redirect.redirectstage - authentik_stages_user_delete.userdeletestage - authentik_stages_user_login.userloginstage - authentik_stages_user_logout.userlogoutstage @@ -46544,6 +46820,18 @@ components: required: - pagination - results + PaginatedRedirectStageList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/RedirectStage' + required: + - pagination + - results PaginatedReputationList: type: object properties: @@ -49611,6 +49899,27 @@ components: should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon. + PatchedRedirectStageRequest: + type: object + description: RedirectStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + keep_context: + type: boolean + mode: + $ref: '#/components/schemas/RedirectStageModeEnum' + target_static: + type: string + target_flow: + type: string + format: uuid + nullable: true PatchedReputationPolicyRequest: type: object description: Reputation Policy Serializer @@ -52149,6 +52458,97 @@ components: type: string required: - to + RedirectChallengeResponseRequest: + type: object + description: Redirect challenge response + properties: + component: + type: string + minLength: 1 + default: xak-flow-redirect + to: + type: string + minLength: 1 + required: + - to + RedirectStage: + type: object + description: RedirectStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + description: Get object type so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSet' + keep_context: + type: boolean + mode: + $ref: '#/components/schemas/RedirectStageModeEnum' + target_static: + type: string + target_flow: + type: string + format: uuid + nullable: true + required: + - component + - meta_model_name + - mode + - name + - pk + - verbose_name + - verbose_name_plural + RedirectStageModeEnum: + enum: + - static + - flow + type: string + RedirectStageRequest: + type: object + description: RedirectStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + keep_context: + type: boolean + mode: + $ref: '#/components/schemas/RedirectStageModeEnum' + target_static: + type: string + target_flow: + type: string + format: uuid + nullable: true + required: + - mode + - name RedirectURI: type: object description: A single allowed redirect URI entry diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index e50efc4e49..e3473598e1 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -189,21 +189,28 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm) { ?selected=${this.instance?.authentication === AuthenticationEnum.RequireUnauthenticated} > - ${msg("Require no authentication.")} + ${msg("Require no authentication")} +

diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 7d99e15502..19e9b5e2b2 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -17,6 +17,7 @@ import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; +import "@goauthentik/admin/stages/redirect/RedirectStageForm"; import "@goauthentik/admin/stages/source/SourceStageForm"; import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm"; import "@goauthentik/admin/stages/user_login/UserLoginStageForm"; diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts index a20fec7569..1335ac280e 100644 --- a/web/src/admin/stages/StageWizard.ts +++ b/web/src/admin/stages/StageWizard.ts @@ -15,6 +15,7 @@ import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; +import "@goauthentik/admin/stages/redirect/RedirectStageForm"; import "@goauthentik/admin/stages/source/SourceStageForm"; import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm"; import "@goauthentik/admin/stages/user_login/UserLoginStageForm"; diff --git a/web/src/admin/stages/consent/ConsentStageForm.ts b/web/src/admin/stages/consent/ConsentStageForm.ts index 513a2a2799..9c45582baa 100644 --- a/web/src/admin/stages/consent/ConsentStageForm.ts +++ b/web/src/admin/stages/consent/ConsentStageForm.ts @@ -83,13 +83,13 @@ export class ConsentStageForm extends BaseStageForm { value=${ConsentStageModeEnum.Permanent} ?selected=${this.instance?.mode === ConsentStageModeEnum.Permanent} > - ${msg("Consent given last indefinitely")} + ${msg("Consent given lasts indefinitely")} diff --git a/web/src/admin/stages/redirect/RedirectStageForm.ts b/web/src/admin/stages/redirect/RedirectStageForm.ts new file mode 100644 index 0000000000..ad671fccea --- /dev/null +++ b/web/src/admin/stages/redirect/RedirectStageForm.ts @@ -0,0 +1,145 @@ +import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/forms/HorizontalFormElement"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { + Flow, + FlowsApi, + FlowsInstancesListRequest, + RedirectStage, + RedirectStageModeEnum, + StagesApi, +} from "@goauthentik/api"; + +@customElement("ak-stage-redirect-form") +export class RedirectStageForm extends BaseStageForm { + @property({ type: String }) + mode: string = RedirectStageModeEnum.Static; + + loadInstance(pk: string): Promise { + return new StagesApi(DEFAULT_CONFIG) + .stagesRedirectRetrieve({ + stageUuid: pk, + }) + .then((stage) => { + this.mode = stage.mode ?? RedirectStageModeEnum.Static; + return stage; + }); + } + + async send(data: RedirectStage): Promise { + if (this.instance) { + return new StagesApi(DEFAULT_CONFIG).stagesRedirectUpdate({ + stageUuid: this.instance.pk || "", + redirectStageRequest: data, + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesRedirectCreate({ + redirectStageRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + ${msg("Redirect the user to another flow, potentially with all gathered context")} + + + + + + ${msg("Stage-specific settings")} +

+ + + + + +

+ ${msg("Redirect the user to a static URL.")} +

+
+ + => { + const args: FlowsInstancesListRequest = { + ordering: "slug", + }; + if (query !== undefined) { + args.search = query; + } + const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList( + args, + ); + return flows.results; + }} + .renderElement=${(flow: Flow): string => RenderFlowOption(flow)} + .renderDescription=${(flow: Flow): TemplateResult => html`${flow.name}`} + .value=${(flow: Flow | undefined): string | undefined => flow?.pk} + .selected=${(flow: Flow): boolean => + this.instance?.targetFlow === flow.pk} + blankable + > + +

${msg("Redirect the user to a Flow.")}

+
+ + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-stage-redirect-form": RedirectStageForm; + } +} diff --git a/website/docs/add-secure-apps/flows-stages/flow/context/index.md b/website/docs/add-secure-apps/flows-stages/flow/context/index.md index ec79f018e2..852a941932 100644 --- a/website/docs/add-secure-apps/flows-stages/flow/context/index.md +++ b/website/docs/add-secure-apps/flows-stages/flow/context/index.md @@ -12,6 +12,8 @@ For example, in the Identification Stage (part of the default login flow), you c Any data can be stored in the flow context, however there are some reserved keys in the context dictionary that are used by authentik stages. +To manage flow context on a more granular level, see [Setting flow context keys](../../../../customize/policies/expression/managing_flow_context_keys.md). + ## Context dictionary and reserved keys This section describes the data (the context) that are used in authentik, and provides a list of keys, what they are used for and when they are set. @@ -68,11 +70,15 @@ When a flow is executed by an Outpost (for example the [LDAP](../../../providers #### `is_sso` (boolean) -Set to `True` when the flow is executed from an "SSO" context. For example, this is set when a flow is used during the authentication or enrollment via an external source, and if a flow is executed to authorize access to an application. +This key is set to `True` when the flow is executed from an "SSO" context. For example, this is set when a flow is used during the authentication or enrollment via an external source, and if a flow is executed to authorize access to an application. #### `is_restored` (Token object) -Set when a flow execution is continued from a token. This happens for example when an [Email stage](../../stages/email/index.mdx) is used and the user clicks on the link within the email. The token object contains the key that was used to restore the flow execution. +This key is set when a flow execution is continued from a token. This happens for example when an [Email stage](../../stages/email/index.mdx) is used and the user clicks on the link within the email. The token object contains the key that was used to restore the flow execution. + +#### `is_redirected` (Flow object) + +This key is set when the current flow was reached through a [Redirect stage](../../stages/redirect/index.md) in Flow mode. ### Stage-specific keys @@ -189,3 +195,11 @@ Optionally override the email address that the email will be sent to. If not set ##### `pending_user_identifier` (string) If _Show matched user_ is disabled, this key will be set to the user identifier entered by the user in the identification stage. + +#### Redirect stage + +##### `redirect_stage_target` (string) + +[Set this key](../../../../customize/policies/expression/managing_flow_context_keys.md) in an Expression Policy to override [Redirect stage](../../stages/redirect/index.md) to force it to redirect to a certain URL or flow. This is useful when a flow requires that the redirection target be decided dynamically. + +Use the format `ak-flow://{slug}` to use the Redirect stage in Flow mode. Any other format will result in the Redirect stage running in Static mode. 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 496188da6d..3b8949a72d 100644 --- a/website/docs/add-secure-apps/flows-stages/flow/index.md +++ b/website/docs/add-secure-apps/flows-stages/flow/index.md @@ -44,7 +44,7 @@ To create a flow, follow these steps: 2. In the Admin interface, navigate to **Flows and Stages -> Flows**. 3. Click **Create**, define the flow using the [configuration settings](#flow-configuration-options) described below, and then click **Finish**. -After creating the flow, you can then [bind specific stages](../stages/index.md#bind-a-stage-to-a-flow) to the flow and [bind policies](../../../customize/policies/working_with_policies/working_with_policies.md) to the flow to further customize the user's log in and authentication process. +After creating the flow, you can then [bind specific stages](../stages/index.md#bind-a-stage-to-a-flow) to the flow and [bind policies](../../../customize/policies/working_with_policies.md) to the flow to further customize the user's log in and authentication process. To determine which flow should be used, authentik will first check which default authentication flow is configured in the active [**Brand**](../../../customize/brands.md). If no default is configured there, the policies in all flows with the matching designation are checked, and the first flow with matching policies sorted by `slug` will be used. @@ -66,7 +66,7 @@ import Defaultflowlist from "../flow/flow_list/\_defaultflowlist.mdx"; -**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. +**Authentication**: Using this option, you can configure whether the the flow requires initial authentication or not, whether the user must be a superuser, if the flow can only be started after being redirected by a [Redirect stage](../stages/redirect/index.md), or if the flow requires an outpost. **Behavior settings**: diff --git a/website/docs/add-secure-apps/flows-stages/stages/index.md b/website/docs/add-secure-apps/flows-stages/stages/index.md index e3ff640bb8..e30981cf85 100644 --- a/website/docs/add-secure-apps/flows-stages/stages/index.md +++ b/website/docs/add-secure-apps/flows-stages/stages/index.md @@ -43,7 +43,7 @@ To create a stage, follow these steps: 2. In the Admin interface, navigate to **Flows and Stages -> Stages**. 3. Click **Create**, define the flow using the configuration settings, and then click **Finish**. -After creating the stage, you can then [bind the stage to a flow](#bind-a-stage-to-a-flow) or [bind a policy to the stage](../../../customize/policies/working_with_policies/working_with_policies.md) (the policy determines whether or not the stage will be implemented in the flow). +After creating the stage, you can then [bind the stage to a flow](#bind-a-stage-to-a-flow) or [bind a policy to the stage](../../../customize/policies/working_with_policies.md) (the policy determines whether or not the stage will be implemented in the flow). ## Bind a stage to a flow diff --git a/website/docs/add-secure-apps/flows-stages/stages/redirect/index.md b/website/docs/add-secure-apps/flows-stages/stages/redirect/index.md new file mode 100644 index 0000000000..4da84e8024 --- /dev/null +++ b/website/docs/add-secure-apps/flows-stages/stages/redirect/index.md @@ -0,0 +1,17 @@ +--- +title: Redirect stage +--- + +This stage's main purpose is to redirect the user to a new Flow while keeping flow context. For convenience, it can also redirect the user to a static URL. + +## Redirect stage modes + +### Static mode + +When the user reaches this stage, they are redirected to a static URL. + +### Flow mode + +When the user reaches this stage, they are redirected to a specified flow, retaining all [flow context](../../flow/context). + +Optionally, untoggle the "Keep flow context" switch. If this is untoggled, all flow context is cleared with the exception of the [is_redirected](../../flow/context#is_redirected-flow-object) key. diff --git a/website/docs/customize/policies/expression/managing_flow_context_keys.md b/website/docs/customize/policies/expression/managing_flow_context_keys.md new file mode 100644 index 0000000000..16a7528026 --- /dev/null +++ b/website/docs/customize/policies/expression/managing_flow_context_keys.md @@ -0,0 +1,17 @@ +--- +title: Managing flow context keys +--- + +[Flow context](../../../add-secure-apps/flows-stages/flow/context/index.md) can be managed in [Expression policies](../expression.mdx) via the `context['flow_plan'].context` variable. + +Here's an example of setting a key in an Expression policy: + +```python +context['flow_plan'].context['redirect_stage_target'] = 'ak-flow://redirected-authentication-flow' +``` + +And here's an example of removing that key: + +```python +context['flow_plan'].context.pop('redirect_stage_target', None) +``` diff --git a/website/docs/customize/policies/working_with_policies/unique_email.md b/website/docs/customize/policies/expression/unique_email.md similarity index 100% rename from website/docs/customize/policies/working_with_policies/unique_email.md rename to website/docs/customize/policies/expression/unique_email.md diff --git a/website/docs/customize/policies/working_with_policies/whitelist_email.md b/website/docs/customize/policies/expression/whitelist_email.md similarity index 100% rename from website/docs/customize/policies/working_with_policies/whitelist_email.md rename to website/docs/customize/policies/expression/whitelist_email.md diff --git a/website/docs/customize/policies/index.md b/website/docs/customize/policies/index.md index c9b7fa2b62..cfd56dfcbd 100644 --- a/website/docs/customize/policies/index.md +++ b/website/docs/customize/policies/index.md @@ -8,7 +8,7 @@ In effect, policies determine whether or not a specific stage is applied to a fl For example, you can create a policy that, for certain users, skips over a stage that prompts for MFA input. Or, you can define a policy that allows users to access a login flow only if the policy criteria are met. See below for other policies, including the reputation policy and an events-driven policy to manage notifications. -For instructions about creating and binding policies to flows and stages, refer to ["Working with policies](./working_with_policies/working_with_policies.md)". +For instructions about creating and binding policies to flows and stages, refer to ["Working with policies](./working_with_policies.md)". ## Standard policies diff --git a/website/docs/customize/policies/working_with_policies/working_with_policies.md b/website/docs/customize/policies/working_with_policies.md similarity index 86% rename from website/docs/customize/policies/working_with_policies/working_with_policies.md rename to website/docs/customize/policies/working_with_policies.md index 8df05b0f6e..dd5d559d57 100644 --- a/website/docs/customize/policies/working_with_policies/working_with_policies.md +++ b/website/docs/customize/policies/working_with_policies.md @@ -2,11 +2,11 @@ title: Working with policies --- -For an overview of policies, refer to our documentation on [Policies](../index.md). +For an overview of policies, refer to our documentation on [Policies](./index.md). -authentik provides several [standard policy types](../index.md#standard-policies), which can be configured for your specific needs. +authentik provides several [standard policy types](./index.md#standard-policies), which can be configured for your specific needs. -We also document how to use a policy to [whitelist email domains](./whitelist_email.md) and to [ensure unique email addresses](./unique_email.md). +We also document how to use a policy to [whitelist email domains](./expression/whitelist_email.md) and to [ensure unique email addresses](./expression/unique_email.md). ## Create a policy @@ -19,7 +19,7 @@ To create a new policy, follow these steps: ## Bind a policy to a flow or stage -After creating the policy, you can bind it to either a [flow](../../../add-secure-apps/flows-stages/flow/index.md) or to a [stage](../../../add-secure-apps/flows-stages/stages/index.md). +After creating the policy, you can bind it to either a [flow](../../add-secure-apps/flows-stages/flow/index.md) or to a [stage](../../add-secure-apps/flows-stages/stages/index.md). :::info Bindings are instantiated objects themselves, and conceptually can be considered as the "connector" between the policy and the stage or flow. This is why you might read about "binding a binding", because technically, a binding is "spliced" into another binding, in order to intercept and enforce the criteria defined in the policy. You can edit bindings on a flow's **Stage Bindings** tab. diff --git a/website/docs/users-sources/user/user_basic_operations.md b/website/docs/users-sources/user/user_basic_operations.md index d7806472db..882fc56959 100644 --- a/website/docs/users-sources/user/user_basic_operations.md +++ b/website/docs/users-sources/user/user_basic_operations.md @@ -4,7 +4,7 @@ title: Manage users The following topics are for the basic management of users: how to create, modify, delete or deactivate users, and using a recovery email. -[Policies](../../customize/policies/index.md) can be used to further manage how users are authenticated. For example, by default authentik does not require email addresses be unique, but you can use a policy to [enforce unique email addresses](../../customize/policies/working_with_policies/unique_email.md). +[Policies](../../customize/policies/index.md) can be used to further manage how users are authenticated. For example, by default authentik does not require email addresses be unique, but you can use a policy to [enforce unique email addresses](../../customize/policies/expression/unique_email.md). ### Create a user diff --git a/website/netlify.toml b/website/netlify.toml index 5e1ada1e41..a7a160d266 100644 --- a/website/netlify.toml +++ b/website/netlify.toml @@ -463,13 +463,25 @@ [[redirects]] from = "/docs/policies/working_with_policies/unique_email" - to = "/docs/customize/policies/working_with_policies/unique_email" + to = "/docs/customize/policies/expression/unique_email" + status = 302 + force = true + +[[redirects]] + from = "/docs/customize/policies/working_with_policies/unique_email" + to = "/docs/customize/policies/expression/unique_email" status = 302 force = true [[redirects]] from = "/docs/policies/working_with_policies/whitelist_email" - to = "/docs/customize/policies/working_with_policies/whitelist_email" + to = "/docs/customize/policies/expression/whitelist_email" + status = 302 + force = true + +[[redirects]] + from = "/docs/customize/policies/working_with_policies/whitelist_email" + to = "/docs/customize/policies/expression/whitelist_email" status = 302 force = true diff --git a/website/sidebars.js b/website/sidebars.js index 5ed075e426..6d36b9a2a5 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -297,6 +297,7 @@ export default { "add-secure-apps/flows-stages/stages/invitation/index", "add-secure-apps/flows-stages/stages/password/index", "add-secure-apps/flows-stages/stages/prompt/index", + "add-secure-apps/flows-stages/stages/redirect/index", "add-secure-apps/flows-stages/stages/source/index", "add-secure-apps/flows-stages/stages/user_delete", "add-secure-apps/flows-stages/stages/user_login/index", @@ -352,19 +353,20 @@ export default { id: "customize/policies/index", }, items: [ + "customize/policies/working_with_policies", { type: "category", - label: "Working with Policies", + label: "Expression Policies", link: { type: "doc", - id: "customize/policies/working_with_policies/working_with_policies", + id: "customize/policies/expression", }, items: [ - "customize/policies/working_with_policies/unique_email", - "customize/policies/working_with_policies/whitelist_email", + "customize/policies/expression/unique_email", + "customize/policies/expression/whitelist_email", + "customize/policies/expression/managing_flow_context_keys", ], }, - "customize/policies/expression", ], }, {