diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 574729b4e..64c4a6163 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -25,14 +25,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run pylint run: pipenv run pylint authentik tests lifecycle @@ -43,14 +43,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run black run: pipenv run black --check authentik tests lifecycle @@ -61,14 +61,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run isort run: pipenv run isort --check authentik tests lifecycle @@ -79,14 +79,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run bandit run: pipenv run bandit -r authentik tests lifecycle @@ -113,14 +113,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run migrations run: pipenv run python -m lifecycle.migrate @@ -138,14 +138,14 @@ jobs: # Copy current, latest config to local cp authentik/lib/default.yml local.env.yml git checkout $(git describe --abbrev=0 --match 'version/*') - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - name: run migrations to stable run: pipenv run python -m lifecycle.migrate @@ -168,14 +168,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - uses: testspace-com/setup-testspace@v1 with: @@ -197,14 +197,14 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: scripts/ci_prepare.sh - uses: testspace-com/setup-testspace@v1 with: @@ -236,14 +236,14 @@ jobs: - uses: testspace-com/setup-testspace@v1 with: domain: ${{github.repository_owner}} - # - id: cache-pipenv - # uses: actions/cache@v2.1.6 - # with: - # path: ~/.local/share/virtualenvs - # key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} - name: prepare - # env: - # INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} run: | scripts/ci_prepare.sh docker-compose -f tests/e2e/ci.docker-compose.yml up -d diff --git a/authentik/api/v3/urls.py b/authentik/api/v3/urls.py index 7cfa389b3..3bb833a98 100644 --- a/authentik/api/v3/urls.py +++ b/authentik/api/v3/urls.py @@ -30,7 +30,8 @@ from authentik.events.api.notification_transport import NotificationTransportVie from authentik.flows.api.bindings import FlowStageBindingViewSet from authentik.flows.api.flows import FlowViewSet from authentik.flows.api.stages import StageViewSet -from authentik.flows.views import FlowExecutorView +from authentik.flows.views.executor import FlowExecutorView +from authentik.flows.views.inspector import FlowInspectorView from authentik.outposts.api.outposts import OutpostViewSet from authentik.outposts.api.service_connections import ( DockerServiceConnectionViewSet, @@ -228,6 +229,11 @@ urlpatterns = ( FlowExecutorView.as_view(), name="flow-executor", ), + path( + "flows/inspector//", + FlowInspectorView.as_view(), + name="flow-inspector", + ), path("sentry/", SentryTunnelView.as_view(), name="sentry"), path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"), ] diff --git a/authentik/core/auth.py b/authentik/core/auth.py index 63f95433b..e8fc7eef9 100644 --- a/authentik/core/auth.py +++ b/authentik/core/auth.py @@ -8,7 +8,7 @@ from django.http.request import HttpRequest from authentik.core.models import Token, TokenIntents, User from authentik.events.utils import cleanse_dict, sanitize_dict from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index f91fa4b70..71e4366ed 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -22,7 +22,7 @@ from authentik.flows.planner import ( PLAN_CONTEXT_SSO, FlowPlanner, ) -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.lib.utils.urls import redirect_with_qs from authentik.policies.utils import delete_none_keys from authentik.stages.password import BACKEND_INBUILT diff --git a/authentik/core/templates/if/flow.html b/authentik/core/templates/if/flow.html index 3b6260b39..f7d164325 100644 --- a/authentik/core/templates/if/flow.html +++ b/authentik/core/templates/if/flow.html @@ -5,7 +5,7 @@ {% block head_before %} {{ block.super }} -{% if flow.compatibility_mode %} +{% if flow.compatibility_mode and not inspector %} {% endif %} {% endblock %} diff --git a/authentik/core/tests/test_token_auth.py b/authentik/core/tests/test_token_auth.py index f7b285bbc..399e81d13 100644 --- a/authentik/core/tests/test_token_auth.py +++ b/authentik/core/tests/test_token_auth.py @@ -4,7 +4,7 @@ from django.test import TestCase from authentik.core.auth import TokenBackend from authentik.core.models import Token, TokenIntents, User from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.tests.utils import get_request diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py index 3590ba685..d851c79e5 100644 --- a/authentik/core/views/interface.py +++ b/authentik/core/views/interface.py @@ -14,4 +14,5 @@ class FlowInterfaceView(TemplateView): def get_context_data(self, **kwargs: Any) -> dict[str, Any]: kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) + kwargs["inspector"] = "inspector" in self.request.GET return super().get_context_data(**kwargs) diff --git a/authentik/events/signals.py b/authentik/events/signals.py index b659f2b42..10d9b9e08 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -12,7 +12,7 @@ from authentik.core.signals import password_changed from authentik.events.models import Event, EventAction from authentik.events.tasks import event_notification_handler from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.invitation.models import Invitation from authentik.stages.invitation.signals import invitation_used from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 602cc4654..27cb0b84a 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -32,7 +32,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cach from authentik.flows.transfer.common import DataclassEncoder from authentik.flows.transfer.exporter import FlowExporter from authentik.flows.transfer.importer import FlowImporter -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.views import bad_request_message LOGGER = get_logger() diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index be26a8104..b0050d3d6 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -18,7 +18,7 @@ from authentik.flows.challenge import ( ) from authentik.flows.models import InvalidResponseAction from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER -from authentik.flows.views import FlowExecutorView +from authentik.flows.views.executor import FlowExecutorView PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" LOGGER = get_logger() diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_executor.py similarity index 97% rename from authentik/flows/tests/test_views.py rename to authentik/flows/tests/test_executor.py index e12f78c30..913915883 100644 --- a/authentik/flows/tests/test_views.py +++ b/authentik/flows/tests/test_executor.py @@ -14,7 +14,7 @@ from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction from authentik.flows.planner import FlowPlan, FlowPlanner from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView +from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView from authentik.lib.config import CONFIG from authentik.policies.dummy.models import DummyPolicy from authentik.policies.models import PolicyBinding @@ -38,13 +38,13 @@ TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) class TestFlowExecutor(APITestCase): - """Test views logic""" + """Test executor""" def setUp(self): self.request_factory = RequestFactory() @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_existing_plan_diff_flow(self): @@ -62,7 +62,7 @@ class TestFlowExecutor(APITestCase): session.save() cancel_mock = MagicMock() - with patch("authentik.flows.views.FlowExecutorView.cancel", cancel_mock): + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", cancel_mock): response = self.client.get( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) @@ -70,7 +70,7 @@ class TestFlowExecutor(APITestCase): self.assertEqual(cancel_mock.call_count, 2) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) @patch( @@ -105,7 +105,7 @@ class TestFlowExecutor(APITestCase): ) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_invalid_empty_flow(self): @@ -124,7 +124,7 @@ class TestFlowExecutor(APITestCase): self.assertEqual(response.url, reverse("authentik_core:root-redirect")) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_invalid_flow_redirect(self): @@ -175,7 +175,7 @@ class TestFlowExecutor(APITestCase): self.assertEqual(len(plan.bindings), 1) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_reevaluate_remove_last(self): diff --git a/authentik/flows/tests/test_inspector.py b/authentik/flows/tests/test_inspector.py new file mode 100644 index 000000000..e54490ea4 --- /dev/null +++ b/authentik/flows/tests/test_inspector.py @@ -0,0 +1,92 @@ +"""Flow inspector tests""" + +from json import loads + +from django.test.client import RequestFactory +from django.urls.base import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction +from authentik.stages.dummy.models import DummyStage +from authentik.stages.identification.models import IdentificationStage, UserFields + + +class TestFlowInspector(APITestCase): + """Test inspector""" + + def setUp(self): + self.request_factory = RequestFactory() + self.admin = User.objects.get(username="akadmin") + self.client.force_login(self.admin) + + def test(self): + """test inspector""" + flow = Flow.objects.create( + name="test-full", + slug="test-full", + designation=FlowDesignation.AUTHENTICATION, + ) + + # Stage 1 is an identification stage + ident_stage = IdentificationStage.objects.create( + name="ident", + user_fields=[UserFields.USERNAME], + ) + FlowStageBinding.objects.create( + target=flow, + stage=ident_stage, + order=1, + invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT, + ) + FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1 + ) + + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + self.assertJSONEqual( + res.content, + { + "component": "ak-stage-identification", + "flow_info": { + "background": flow.background_url, + "cancel_url": reverse("authentik_flows:cancel"), + "title": "", + }, + "type": ChallengeTypes.NATIVE.value, + "password_fields": False, + "primary_action": "Log in", + "sources": [], + "user_fields": ["username"], + }, + ) + + ins = self.client.get( + reverse("authentik_api:flow-inspector", kwargs={"flow_slug": flow.slug}), + ) + content = loads(ins.content) + self.assertEqual(content["is_completed"], False) + self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "ident") + self.assertEqual( + content["current_plan"]["next_planned_stage"]["stage_obj"]["name"], "dummy2" + ) + + self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {"uid_field": "akadmin"}, + follow=True, + ) + + ins = self.client.get( + reverse("authentik_api:flow-inspector", kwargs={"flow_slug": flow.slug}), + ) + content = loads(ins.content) + self.assertEqual(content["is_completed"], False) + self.assertEqual(content["plans"][0]["current_stage"]["stage_obj"]["name"], "ident") + self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "dummy2") + self.assertEqual( + content["current_plan"]["plan_context"]["pending_user"]["username"], "akadmin" + ) diff --git a/authentik/flows/tests/test_stage_views.py b/authentik/flows/tests/test_stage_views.py index b623f8ef5..5ebbe2fc2 100644 --- a/authentik/flows/tests/test_stage_views.py +++ b/authentik/flows/tests/test_stage_views.py @@ -4,7 +4,7 @@ from typing import Callable, Type from django.test import RequestFactory, TestCase from authentik.flows.stage import StageView -from authentik.flows.views import FlowExecutorView +from authentik.flows.views.executor import FlowExecutorView from authentik.lib.utils.reflection import all_subclasses diff --git a/authentik/flows/tests/test_views_helper.py b/authentik/flows/tests/test_views_helper.py index 235cf49df..53322aa18 100644 --- a/authentik/flows/tests/test_views_helper.py +++ b/authentik/flows/tests/test_views_helper.py @@ -4,7 +4,7 @@ from django.urls import reverse from authentik.flows.models import Flow, FlowDesignation from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN class TestHelperView(TestCase): diff --git a/authentik/flows/urls.py b/authentik/flows/urls.py index bc15b6b39..3c5bed0bc 100644 --- a/authentik/flows/urls.py +++ b/authentik/flows/urls.py @@ -2,7 +2,7 @@ from django.urls import path from authentik.flows.models import FlowDesignation -from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow +from authentik.flows.views.executor import CancelView, ConfigureFlowInitView, ToDefaultFlow urlpatterns = [ path( diff --git a/authentik/flows/views/__init__.py b/authentik/flows/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/flows/views.py b/authentik/flows/views/executor.py similarity index 98% rename from authentik/flows/views.py rename to authentik/flows/views/executor.py index adb1b0fb8..a1a4691ae 100644 --- a/authentik/flows/views.py +++ b/authentik/flows/views/executor.py @@ -1,4 +1,5 @@ """authentik multi-stage authentication engine""" +from copy import deepcopy from traceback import format_tb from typing import Any, Optional @@ -52,6 +53,7 @@ NEXT_ARG_NAME = "next" SESSION_KEY_PLAN = "authentik_flows_plan" SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" SESSION_KEY_GET = "authentik_flows_get" +SESSION_KEY_HISTORY = "authentik_flows_history" def challenge_types(): @@ -140,6 +142,7 @@ class FlowExecutorView(APIView): # Don't check session again as we've either already loaded the plan or we need to plan if not self.plan: + request.session[SESSION_KEY_HISTORY] = [] self._logger.debug("f(exec): No active Plan found, initiating planner") try: self.plan = self._initiate_plan() @@ -321,6 +324,7 @@ class FlowExecutorView(APIView): "f(exec): Stage ok", stage_class=class_to_path(self.current_stage_view.__class__), ) + self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan)) self.plan.pop() self.request.session[SESSION_KEY_PLAN] = self.plan if self.plan.bindings: @@ -368,6 +372,10 @@ class FlowExecutorView(APIView): SESSION_KEY_APPLICATION_PRE, SESSION_KEY_PLAN, SESSION_KEY_GET, + # We don't delete the history on purpose, as a user might + # still be inspecting it. + # It's only deleted on a fresh executions + # SESSION_KEY_HISTORY, ] for key in keys_to_delete: if key in self.request.session: diff --git a/authentik/flows/views/inspector.py b/authentik/flows/views/inspector.py new file mode 100644 index 000000000..d0d7647fb --- /dev/null +++ b/authentik/flows/views/inspector.py @@ -0,0 +1,119 @@ +"""Flow Inspector""" +from hashlib import sha256 +from typing import Any + +from django.conf import settings +from django.http.request import HttpRequest +from django.http.response import HttpResponse +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 drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.fields import BooleanField, ListField, SerializerMethodField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from structlog.stdlib import BoundLogger, get_logger + +from authentik.core.api.utils import PassiveSerializer +from authentik.events.utils import sanitize_dict +from authentik.flows.api.bindings import FlowStageBindingSerializer +from authentik.flows.models import Flow +from authentik.flows.planner import FlowPlan +from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN + + +class FlowInspectorPlanSerializer(PassiveSerializer): + """Serializer for an active FlowPlan""" + + current_stage = SerializerMethodField() + next_planned_stage = SerializerMethodField(required=False) + plan_context = SerializerMethodField() + session_id = SerializerMethodField() + + def get_current_stage(self, plan: FlowPlan) -> FlowStageBindingSerializer: + """Get the current stage""" + return FlowStageBindingSerializer(instance=plan.bindings[0]).data + + def get_next_planned_stage(self, plan: FlowPlan) -> FlowStageBindingSerializer: + """Get the next planned stage""" + if len(plan.bindings) < 2: + return FlowStageBindingSerializer().data + return FlowStageBindingSerializer(instance=plan.bindings[1]).data + + def get_plan_context(self, plan: FlowPlan) -> dict[str, Any]: + """Get the plan's context, sanitized""" + return sanitize_dict(plan.context) + + # pylint: disable=unused-argument + def get_session_id(self, plan: FlowPlan) -> str: + """Get a unique session ID""" + request: Request = self.context["request"] + return sha256( + f"{request._request.session.session_key}-{settings.SECRET_KEY}".encode("ascii") + ).hexdigest() + + +class FlowInspectionSerializer(PassiveSerializer): + """Serializer for inspect endpoint""" + + plans = ListField(child=FlowInspectorPlanSerializer()) + current_plan = FlowInspectorPlanSerializer(required=False) + is_completed = BooleanField() + + +@method_decorator(xframe_options_sameorigin, name="dispatch") +class FlowInspectorView(APIView): + """Flow inspector API""" + + permission_classes = [IsAdminUser] + + flow: Flow + _logger: BoundLogger + + def setup(self, request: HttpRequest, flow_slug: str): + super().setup(request, flow_slug=flow_slug) + self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) + self._logger = get_logger().bind(flow_slug=flow_slug) + + # pylint: disable=unused-argument, too-many-return-statements + def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: + if SESSION_KEY_HISTORY not in self.request.session: + return HttpResponse(status=400) + return super().dispatch(request, flow_slug=flow_slug) + + @extend_schema( + responses={ + 200: FlowInspectionSerializer(), + 400: OpenApiResponse( + description="No flow plan in session." + ), # This error can be raised by the email stage + }, + request=OpenApiTypes.NONE, + operation_id="flows_inspector_get", + ) + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Get current flow state and record it""" + plans = [] + for plan in request.session[SESSION_KEY_HISTORY]: + plan_serializer = FlowInspectorPlanSerializer( + instance=plan, context={"request": request} + ) + plans.append(plan_serializer.data) + is_completed = False + if SESSION_KEY_PLAN in request.session: + current_plan: FlowPlan = request.session[SESSION_KEY_PLAN] + else: + current_plan = request.session[SESSION_KEY_HISTORY][-1] + is_completed = True + current_serializer = FlowInspectorPlanSerializer( + instance=current_plan, context={"request": request} + ) + response = { + "plans": plans, + "current_plan": current_serializer.data, + "is_completed": is_completed, + } + return Response(response) diff --git a/authentik/policies/views.py b/authentik/policies/views.py index 138e79639..c45cc9943 100644 --- a/authentik/policies/views.py +++ b/authentik/policies/views.py @@ -10,7 +10,7 @@ from django.views.generic.base import View from structlog.stdlib import get_logger from authentik.core.models import Application, Provider, User -from authentik.flows.views import SESSION_KEY_APPLICATION_PRE +from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE from authentik.lib.sentry import SentryIgnoredException from authentik.policies.denied import AccessDeniedResponse from authentik.policies.engine import PolicyEngine diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index cd9ee752b..6cfd5c8cf 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -23,7 +23,7 @@ from authentik.flows.planner import ( FlowPlanner, ) from authentik.flows.stage import StageView -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.views import bad_request_message diff --git a/authentik/providers/saml/views/sso.py b/authentik/providers/saml/views/sso.py index 664173637..3e470b087 100644 --- a/authentik/providers/saml/views/sso.py +++ b/authentik/providers/saml/views/sso.py @@ -13,7 +13,7 @@ from authentik.core.models import Application from authentik.events.models import Event, EventAction from authentik.flows.models import in_memory_stage from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner -from authentik.flows.views import SESSION_KEY_PLAN +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 diff --git a/authentik/sources/plex/api/source.py b/authentik/sources/plex/api/source.py index fb56c178c..e83863423 100644 --- a/authentik/sources/plex/api/source.py +++ b/authentik/sources/plex/api/source.py @@ -17,7 +17,7 @@ from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import RedirectChallenge -from authentik.flows.views import to_stage_response +from authentik.flows.views.executor import to_stage_response from authentik.sources.plex.models import PlexSource from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py index 6f8702c95..53a28560a 100644 --- a/authentik/sources/saml/processors/response.py +++ b/authentik/sources/saml/processors/response.py @@ -18,7 +18,7 @@ from authentik.flows.planner import ( PLAN_CONTEXT_SSO, FlowPlanner, ) -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.lib.utils.urls import redirect_with_qs from authentik.policies.utils import delete_none_keys from authentik.sources.saml.exceptions import ( diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py index 3afc75ab1..2219b7a1d 100644 --- a/authentik/sources/saml/views.py +++ b/authentik/sources/saml/views.py @@ -22,7 +22,7 @@ from authentik.flows.planner import ( FlowPlanner, ) from authentik.flows.stage import ChallengeStageView -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.views import bad_request_message from authentik.providers.saml.utils.encoding import nice64 diff --git a/authentik/stages/authenticator_duo/stage.py b/authentik/stages/authenticator_duo/stage.py index d8ea49165..bd94fad85 100644 --- a/authentik/stages/authenticator_duo/stage.py +++ b/authentik/stages/authenticator_duo/stage.py @@ -12,7 +12,7 @@ from authentik.flows.challenge import ( ) from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView -from authentik.flows.views import InvalidStageError +from authentik.flows.views.executor import InvalidStageError from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice LOGGER = get_logger() diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py index 21673dfd7..a2def4666 100644 --- a/authentik/stages/captcha/tests.py +++ b/authentik/stages/captcha/tests.py @@ -8,7 +8,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.captcha.models import CaptchaStage # https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py index 25f99d30a..3d8522c45 100644 --- a/authentik/stages/consent/tests.py +++ b/authentik/stages/consent/tests.py @@ -11,7 +11,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent diff --git a/authentik/stages/deny/tests.py b/authentik/stages/deny/tests.py index d2d0c072f..2a7278ba7 100644 --- a/authentik/stages/deny/tests.py +++ b/authentik/stages/deny/tests.py @@ -8,7 +8,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.deny.models import DenyStage diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py index 77f29e0a0..151767e8d 100644 --- a/authentik/stages/email/stage.py +++ b/authentik/stages/email/stage.py @@ -16,7 +16,7 @@ from authentik.core.models import Token from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView -from authentik.flows.views import SESSION_KEY_GET +from authentik.flows.views.executor import SESSION_KEY_GET from authentik.stages.email.models import EmailStage from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage diff --git a/authentik/stages/email/tests/test_sending.py b/authentik/stages/email/tests/test_sending.py index 131451a54..997940a8a 100644 --- a/authentik/stages/email/tests/test_sending.py +++ b/authentik/stages/email/tests/test_sending.py @@ -12,7 +12,7 @@ from authentik.events.models import Event, EventAction from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.email.models import EmailStage diff --git a/authentik/stages/email/tests/test_stage.py b/authentik/stages/email/tests/test_stage.py index c67ce8881..2a5294777 100644 --- a/authentik/stages/email/tests/test_stage.py +++ b/authentik/stages/email/tests/test_stage.py @@ -12,7 +12,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.email.models import EmailStage from authentik.stages.email.stage import QS_KEY_TOKEN @@ -90,7 +90,7 @@ class TestEmailStage(APITestCase): session.save() token: Token = Token.objects.get(user=self.user) - with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): # Call the executor shell to preseed the session url = reverse( "authentik_api:flow-executor", diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 5af873bf8..9ded9a247 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -18,7 +18,7 @@ from authentik.core.models import Application, Source, User from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView -from authentik.flows.views import SESSION_KEY_APPLICATION_PRE, challenge_types +from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, challenge_types from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.signals import identification_failed from authentik.stages.password.stage import authenticate diff --git a/authentik/stages/invitation/stage.py b/authentik/stages/invitation/stage.py index eb3b77fe8..a68ae94bd 100644 --- a/authentik/stages/invitation/stage.py +++ b/authentik/stages/invitation/stage.py @@ -9,7 +9,7 @@ from structlog.stdlib import get_logger from authentik.flows.models import in_memory_stage from authentik.flows.stage import StageView -from authentik.flows.views import SESSION_KEY_GET +from authentik.flows.views.executor import SESSION_KEY_GET from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.signals import invitation_used from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index e58487272..456bdc867 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -12,8 +12,8 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.stage import ( INVITATION_TOKEN_KEY, @@ -40,7 +40,7 @@ class TestUserLoginStage(APITestCase): self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_without_invitation_fail(self): @@ -108,7 +108,7 @@ class TestUserLoginStage(APITestCase): data = {"foo": "bar"} invite = Invitation.objects.create(created_by=get_anonymous_user(), fixed_data=data) - with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex}) response = self.client.get(base_url + f"?query={args}") @@ -140,7 +140,7 @@ class TestUserLoginStage(APITestCase): session[SESSION_KEY_PLAN] = plan session.save() - with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) response = self.client.get(base_url, follow=True) diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py index 9cb640dda..abb37e310 100644 --- a/authentik/stages/password/tests.py +++ b/authentik/stages/password/tests.py @@ -11,8 +11,8 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_key from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.models import PasswordStage @@ -39,7 +39,7 @@ class TestPasswordStage(APITestCase): self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_without_user(self): @@ -153,7 +153,7 @@ class TestPasswordStage(APITestCase): self.assertNotIn(SESSION_KEY_PLAN, self.client.session) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) @patch( diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py index a294e1432..c55a7caee 100644 --- a/authentik/stages/prompt/tests.py +++ b/authentik/stages/prompt/tests.py @@ -11,7 +11,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.policies.expression.models import ExpressionPolicy from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse @@ -161,7 +161,7 @@ class TestPromptStage(APITestCase): challenge_response = self.test_valid_challenge_with_policy() - with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): response = self.client.post( reverse( "authentik_api:flow-executor", diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py index e70b80552..4813fda06 100644 --- a/authentik/stages/user_delete/tests.py +++ b/authentik/stages/user_delete/tests.py @@ -10,8 +10,8 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.user_delete.models import UserDeleteStage @@ -32,7 +32,7 @@ class TestUserDeleteStage(APITestCase): self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_no_user(self): diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 0940324a6..680a7e437 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -11,8 +11,8 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.user_login.models import UserLoginStage @@ -81,7 +81,7 @@ class TestUserLoginStage(APITestCase): self.assertEqual(list(self.client.session.keys()), []) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_without_user(self): diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py index c13e90cba..80cd00db1 100644 --- a/authentik/stages/user_logout/tests.py +++ b/authentik/stages/user_logout/tests.py @@ -8,7 +8,7 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.user_logout.models import UserLogoutStage diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index 0f078f73a..eb951be14 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -13,8 +13,8 @@ from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from authentik.flows.views import SESSION_KEY_PLAN +from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.user_write.models import UserWriteStage @@ -112,7 +112,7 @@ class TestUserWriteStage(APITestCase): self.assertNotIn("some_ignored_attribute", user_qs.first().attributes) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_without_data(self): @@ -142,7 +142,7 @@ class TestUserWriteStage(APITestCase): ) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_blank_username(self): @@ -177,7 +177,7 @@ class TestUserWriteStage(APITestCase): ) @patch( - "authentik.flows.views.to_stage_response", + "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, ) def test_duplicate_data(self): diff --git a/schema.yml b/schema.yml index 04ce2d395..55922dec1 100644 --- a/schema.yml +++ b/schema.yml @@ -4743,6 +4743,31 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /flows/inspector/{flow_slug}/: + get: + operationId: flows_inspector_get + description: Get current flow state and record it + parameters: + - in: path + name: flow_slug + schema: + type: string + required: true + tags: + - flows + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FlowInspection' + description: '' + '400': + description: No flow plan in session. + '403': + $ref: '#/components/schemas/GenericError' /flows/instances/: get: operationId: flows_instances_list @@ -20273,6 +20298,45 @@ components: readOnly: true required: - diagram + FlowInspection: + type: object + description: Serializer for inspect endpoint + properties: + plans: + type: array + items: + $ref: '#/components/schemas/FlowInspectorPlan' + current_plan: + $ref: '#/components/schemas/FlowInspectorPlan' + is_completed: + type: boolean + required: + - is_completed + - plans + FlowInspectorPlan: + type: object + description: Serializer for an active FlowPlan + properties: + current_stage: + allOf: + - $ref: '#/components/schemas/FlowStageBinding' + readOnly: true + next_planned_stage: + allOf: + - $ref: '#/components/schemas/FlowStageBinding' + readOnly: true + plan_context: + type: object + additionalProperties: {} + readOnly: true + session_id: + type: string + readOnly: true + required: + - current_stage + - next_planned_stage + - plan_context + - session_id FlowRequest: type: object description: Flow Serializer diff --git a/web/src/constants.ts b/web/src/constants.ts index d880b9fc3..cae2bab7e 100644 --- a/web/src/constants.ts +++ b/web/src/constants.ts @@ -14,6 +14,7 @@ export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle"; export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-toggle"; export const EVENT_API_DRAWER_REFRESH = "ak-api-drawer-refresh"; export const EVENT_WS_MESSAGE = "ak-ws-message"; +export const EVENT_FLOW_ADVANCE = "ak-flow-advance"; export const WS_MSG_TYPE_MESSAGE = "message"; export const WS_MSG_TYPE_REFRESH = "refresh"; diff --git a/web/src/elements/Expand.ts b/web/src/elements/Expand.ts index f5f4c4ae2..aa1c6a524 100644 --- a/web/src/elements/Expand.ts +++ b/web/src/elements/Expand.ts @@ -11,10 +11,10 @@ export class Expand extends LitElement { expanded = false; @property() - textOpen = "Show less"; + textOpen = t`Show less`; @property() - textClosed = "Show more"; + textClosed = t`Show more`; static get styles(): CSSResult[] { return [PFExpandableSection]; diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index 01b755c1a..384975048 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -8,6 +8,7 @@ import { until } from "lit/directives/until"; import AKGlobal from "../authentik.css"; import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFList from "@patternfly/patternfly/components/List/list.css"; import PFLogin from "@patternfly/patternfly/components/Login/login.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css"; @@ -26,12 +27,14 @@ import { import { DEFAULT_CONFIG, tenant } from "../api/Config"; import { configureSentry } from "../api/Sentry"; import { WebsocketClient } from "../common/ws"; -import { TITLE_DEFAULT } from "../constants"; +import { EVENT_FLOW_ADVANCE, TITLE_DEFAULT } from "../constants"; import "../elements/LoadingOverlay"; import { DefaultTenant } from "../elements/sidebar/SidebarBrand"; import { first } from "../utils"; +import "./FlowInspector"; import "./access_denied/FlowAccessDenied"; import "./sources/plex/PlexLoginInit"; +import "./stages/RedirectStage"; import "./stages/authenticator_duo/AuthenticatorDuoStage"; import "./stages/authenticator_static/AuthenticatorStaticStage"; import "./stages/authenticator_totp/AuthenticatorTOTPStage"; @@ -59,7 +62,9 @@ export class FlowExecutor extends LitElement implements StageHost { // Assign the location as soon as we get the challenge and *not* in the render function // as the render function might be called multiple times, which will navigate multiple // times and can invalidate oauth codes - if (value?.type === ChallengeChoices.Redirect) { + // Also only auto-redirect when the inspector is open, so that a user can inspect the + // redirect in the inspector + if (value?.type === ChallengeChoices.Redirect && !this.inspectorOpen) { console.debug( "authentik/flows: redirecting to url from server", (value as RedirectChallenge).to, @@ -86,10 +91,14 @@ export class FlowExecutor extends LitElement implements StageHost { @property({ attribute: false }) tenant?: CurrentTenant; + @property({ attribute: false }) + inspectorOpen: boolean; + ws: WebsocketClient; static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFButton, PFTitle, PFList, PFBackgroundImage, AKGlobal].concat(css` + return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage, AKGlobal] + .concat(css` .ak-hidden { display: none; } @@ -100,6 +109,9 @@ export class FlowExecutor extends LitElement implements StageHost { font-family: monospace; overflow-x: scroll; } + .pf-c-drawer__content { + background-color: transparent; + } `); } @@ -107,6 +119,7 @@ export class FlowExecutor extends LitElement implements StageHost { super(); this.ws = new WebsocketClient(); this.flowSlug = window.location.pathname.split("/")[3]; + this.inspectorOpen = window.location.search.includes("inspector"); } setBackground(url: string): void { @@ -130,6 +143,14 @@ export class FlowExecutor extends LitElement implements StageHost { flowChallengeResponseRequest: payload, }) .then((data) => { + if (this.inspectorOpen) { + window.dispatchEvent( + new CustomEvent(EVENT_FLOW_ADVANCE, { + bubbles: true, + composed: true, + }), + ); + } this.challenge = data; }) .catch((e: Error | Response) => { @@ -150,6 +171,14 @@ export class FlowExecutor extends LitElement implements StageHost { query: window.location.search.substring(1), }) .then((challenge) => { + if (this.inspectorOpen) { + window.dispatchEvent( + new CustomEvent(EVENT_FLOW_ADVANCE, { + bubbles: true, + composed: true, + }), + ); + } this.challenge = challenge; // Only set background on first update, flow won't change throughout execution if (this.challenge?.flowInfo?.background) { @@ -199,6 +228,13 @@ export class FlowExecutor extends LitElement implements StageHost { } switch (this.challenge.type) { case ChallengeChoices.Redirect: + if (this.inspectorOpen) { + return html` + `; + } return html` `; case ChallengeChoices.Shell: @@ -333,50 +369,74 @@ export class FlowExecutor extends LitElement implements StageHost { -
-
`; } diff --git a/web/src/flows/FlowInspector.ts b/web/src/flows/FlowInspector.ts new file mode 100644 index 000000000..83d055bca --- /dev/null +++ b/web/src/flows/FlowInspector.ts @@ -0,0 +1,297 @@ +import { t } from "@lingui/macro"; + +import { css, CSSResult, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; + +import AKGlobal from "../authentik.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFNotificationDrawer from "@patternfly/patternfly/components/NotificationDrawer/notification-drawer.css"; +import PFProgressStepper from "@patternfly/patternfly/components/ProgressStepper/progress-stepper.css"; +import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { FlowInspection, FlowsApi, Stage } from "@goauthentik/api"; + +import { DEFAULT_CONFIG } from "../api/Config"; +import { EVENT_FLOW_ADVANCE } from "../constants"; +import "../elements/Expand"; + +@customElement("ak-flow-inspector") +export class FlowInspector extends LitElement { + flowSlug: string; + + @property({ attribute: false }) + state?: FlowInspection; + + @property({ attribute: false }) + error?: Response; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFStack, + PFCard, + PFNotificationDrawer, + PFDescriptionList, + PFProgressStepper, + AKGlobal, + css` + code.break { + word-break: break-all; + } + `, + ]; + } + + constructor() { + super(); + this.flowSlug = window.location.pathname.split("/")[3]; + window.addEventListener(EVENT_FLOW_ADVANCE, this.advanceHandler as EventListener); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener(EVENT_FLOW_ADVANCE, this.advanceHandler as EventListener); + } + + advanceHandler = (): void => { + new FlowsApi(DEFAULT_CONFIG) + .flowsInspectorGet({ + flowSlug: this.flowSlug, + }) + .then((state) => { + this.state = state; + }) + .catch((exc) => { + this.error = exc; + }); + }; + + // getStage return a stage without flowSet, for brevity + getStage(stage?: Stage): unknown { + if (!stage) { + return stage; + } + delete stage.flowSet; + return stage; + } + + renderAccessDenied(): TemplateResult { + return html`
+
+
+
+

${t`Flow inspector`}

+
+
+
+
+
+
+
${this.error?.statusText}
+
+
+
+
+
+
`; + } + + render(): TemplateResult { + if (this.error) { + return this.renderAccessDenied(); + } + if (!this.state) { + return html` `; + } + return html`
+
+
+
+

${t`Flow inspector`}

+
+
+
+
+
+
+
+
${t`Next stage`}
+
+
+
+
+
+ ${t`Stage name`} +
+
+
+ ${this.state.currentPlan?.nextPlannedStage + ?.stageObj?.name || "-"} +
+
+
+
+
+ ${t`Stage kind`} +
+
+
+ ${this.state.currentPlan?.nextPlannedStage + ?.stageObj?.verboseName || "-"} +
+
+
+
+
+ ${t`Stage object`} +
+
+ ${this.state.isCompleted + ? html`
+ ${t`This flow is completed.`} +
` + : html` +
+${JSON.stringify(this.getStage(this.state.currentPlan?.nextPlannedStage?.stageObj), null, 4)}
+
`} +
+
+
+
+
+
+
+
+
+
${t`Plan history`}
+
+
+
    + ${this.state.plans.map((plan) => { + return html`
  1. +
    + + + +
    +
    +
    + ${plan.currentStage.stageObj?.name} +
    +
    + ${plan.currentStage.stageObj?.verboseName} +
    +
    +
  2. `; + })} + ${this.state.currentPlan?.currentStage && + !this.state.isCompleted + ? html`
  3. +
    + + + +
    +
    +
    + ${this.state.currentPlan?.currentStage + ?.stageObj?.name} +
    +
    + ${this.state.currentPlan?.currentStage + ?.stageObj?.verboseName} +
    +
    +
  4. ` + : html``} + ${this.state.currentPlan?.nextPlannedStage && + !this.state.isCompleted + ? html`
  5. +
    + +
    +
    +
    + ${this.state.currentPlan.nextPlannedStage + .stageObj?.name} +
    +
    + ${this.state.currentPlan?.nextPlannedStage + ?.stageObj?.verboseName} +
    +
    +
  6. ` + : html``} +
+
+
+
+
+
+
+
${t`Current plan cntext`}
+
+
+
+${JSON.stringify(this.state.currentPlan?.planContext, null, 4)}
+
+
+
+
+
+
+
${t`Session ID`}
+
+
+ ${this.state.currentPlan?.sessionId} +
+
+
+
+
+
+
`; + } +} diff --git a/web/src/flows/stages/RedirectStage.ts b/web/src/flows/stages/RedirectStage.ts new file mode 100644 index 000000000..be4dedc8b --- /dev/null +++ b/web/src/flows/stages/RedirectStage.ts @@ -0,0 +1,56 @@ +import { t } from "@lingui/macro"; + +import { CSSResult, html, TemplateResult } from "lit"; +import { customElement } from "lit/decorators"; + +import AKGlobal from "../../authentik.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { FlowChallengeResponseRequest, RedirectChallenge } from "@goauthentik/api"; + +import { BaseStage } from "./base"; + +@customElement("ak-stage-redirect") +export class RedirectStage extends BaseStage { + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFButton, PFFormControl, PFTitle, AKGlobal]; + } + + renderURL(): string { + if (!this.challenge.to.includes("://")) { + return window.location.origin + this.challenge.to; + } + return this.challenge.to; + } + + render(): TemplateResult { + return html` + +
+ +
`; + } +} diff --git a/web/src/locales/en.po b/web/src/locales/en.po index 63bdcc62f..0b08efe72 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -1151,6 +1151,10 @@ msgstr "Created {0}" msgid "Creation Date" msgstr "Creation Date" +#: src/flows/FlowInspector.ts +msgid "Current plan cntext" +msgstr "Current plan cntext" + #: src/pages/applications/ApplicationForm.ts #: src/pages/flows/FlowForm.ts msgid "Currently set to:" @@ -1626,6 +1630,10 @@ msgstr "Execute" msgid "Execute flow" msgstr "Execute flow" +#: src/pages/flows/FlowViewPage.ts +msgid "Execute with inspector" +msgstr "Execute with inspector" + #: src/pages/policies/expression/ExpressionPolicyForm.ts msgid "Executes the python snippet to determine whether to allow or deny a request." msgstr "Executes the python snippet to determine whether to allow or deny a request." @@ -1793,6 +1801,11 @@ msgstr "Flow" msgid "Flow Overview" msgstr "Flow Overview" +#: src/flows/FlowInspector.ts +#: src/flows/FlowInspector.ts +msgid "Flow inspector" +msgstr "Flow inspector" + #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/plex/PlexSourceForm.ts #: src/pages/sources/saml/SAMLSourceForm.ts @@ -1860,6 +1873,10 @@ msgstr "Flows" msgid "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." msgstr "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." +#: src/flows/stages/RedirectStage.ts +msgid "Follow redirect" +msgstr "Follow redirect" + #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts msgid "Force the user to configure an authenticator" msgstr "Force the user to configure an authenticator" @@ -2350,6 +2367,7 @@ msgstr "Load servers" #: src/elements/table/Table.ts #: src/flows/FlowExecutor.ts #: src/flows/FlowExecutor.ts +#: src/flows/FlowInspector.ts #: src/flows/access_denied/FlowAccessDenied.ts #: src/flows/stages/authenticator_duo/AuthenticatorDuoStage.ts #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts @@ -2714,6 +2732,10 @@ msgstr "New version available!" msgid "Newly created users are added to this group, if a group is selected." msgstr "Newly created users are added to this group, if a group is selected." +#: src/flows/FlowInspector.ts +msgid "Next stage" +msgstr "Next stage" + #: src/elements/oauth/UserRefreshList.ts #: src/pages/applications/ApplicationCheckAccessForm.ts #: src/pages/crypto/CertificateKeyPairListPage.ts @@ -3079,6 +3101,10 @@ msgstr "Persistent" msgid "Placeholder" msgstr "Placeholder" +#: src/flows/FlowInspector.ts +msgid "Plan history" +msgstr "Plan history" + #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts #: src/flows/stages/authenticator_validate/AuthenticatorValidateStageCode.ts msgid "Please enter your TOTP Code" @@ -3381,6 +3407,7 @@ msgstr "Recovery keys" msgid "Recovery link cannot be emailed, user has no email address saved." msgstr "Recovery link cannot be emailed, user has no email address saved." +#: src/flows/stages/RedirectStage.ts #: src/pages/providers/saml/SAMLProviderForm.ts msgid "Redirect" msgstr "Redirect" @@ -3748,6 +3775,10 @@ msgstr "Service Provider Binding" #~ msgid "Session" #~ msgstr "Session" +#: src/flows/FlowInspector.ts +msgid "Session ID" +msgstr "Session ID" + #: src/pages/stages/user_login/UserLoginStageForm.ts msgid "Session duration" msgstr "Session duration" @@ -3794,10 +3825,18 @@ msgstr "Severity" msgid "Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable." msgstr "Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable." +#: src/elements/Expand.ts +msgid "Show less" +msgstr "Show less" + #: src/pages/stages/identification/IdentificationStageForm.ts msgid "Show matched user" msgstr "Show matched user" +#: src/elements/Expand.ts +msgid "Show more" +msgstr "Show more" + #: src/pages/flows/FlowForm.ts msgid "Shown as the Title in Flow pages." msgstr "Shown as the Title in Flow pages." @@ -3897,6 +3936,18 @@ msgstr "Stage Configuration" msgid "Stage binding(s)" msgstr "Stage binding(s)" +#: src/flows/FlowInspector.ts +msgid "Stage kind" +msgstr "Stage kind" + +#: src/flows/FlowInspector.ts +msgid "Stage name" +msgstr "Stage name" + +#: src/flows/FlowInspector.ts +msgid "Stage object" +msgstr "Stage object" + #: src/pages/flows/BoundStagesList.ts msgid "Stage type" msgstr "Stage type" @@ -4505,6 +4556,10 @@ msgstr "" msgid "These policies control which users can access this application." msgstr "These policies control which users can access this application." +#: src/flows/FlowInspector.ts +msgid "This flow is completed." +msgstr "This flow is completed." + #: src/pages/providers/proxy/ProxyProviderForm.ts msgid "This provider will behave like a transparent reverse-proxy, except requests must be authenticated. If your upstream application uses HTTPS, make sure to connect to the outpost using HTTPS as well." msgstr "This provider will behave like a transparent reverse-proxy, except requests must be authenticated. If your upstream application uses HTTPS, make sure to connect to the outpost using HTTPS as well." @@ -5276,6 +5331,10 @@ msgstr "Yes" msgid "You can only select providers that match the type of the outpost." msgstr "You can only select providers that match the type of the outpost." +#: src/flows/stages/RedirectStage.ts +msgid "You're about to be redirect to the following URL." +msgstr "You're about to be redirect to the following URL." + #: src/interfaces/AdminInterface.ts msgid "You're currently impersonating {0}. Click to stop." msgstr "You're currently impersonating {0}. Click to stop." diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 2ef83d86f..2b84df80b 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -1145,6 +1145,10 @@ msgstr "" msgid "Creation Date" msgstr "" +#: src/flows/FlowInspector.ts +msgid "Current plan cntext" +msgstr "" + #: src/pages/applications/ApplicationForm.ts #: src/pages/flows/FlowForm.ts msgid "Currently set to:" @@ -1618,6 +1622,10 @@ msgstr "" msgid "Execute flow" msgstr "" +#: src/pages/flows/FlowViewPage.ts +msgid "Execute with inspector" +msgstr "" + #: src/pages/policies/expression/ExpressionPolicyForm.ts msgid "Executes the python snippet to determine whether to allow or deny a request." msgstr "" @@ -1785,6 +1793,11 @@ msgstr "" msgid "Flow Overview" msgstr "" +#: src/flows/FlowInspector.ts +#: src/flows/FlowInspector.ts +msgid "Flow inspector" +msgstr "" + #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/plex/PlexSourceForm.ts #: src/pages/sources/saml/SAMLSourceForm.ts @@ -1852,6 +1865,10 @@ msgstr "" msgid "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." msgstr "" +#: src/flows/stages/RedirectStage.ts +msgid "Follow redirect" +msgstr "" + #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts msgid "Force the user to configure an authenticator" msgstr "" @@ -2342,6 +2359,7 @@ msgstr "" #: src/elements/table/Table.ts #: src/flows/FlowExecutor.ts #: src/flows/FlowExecutor.ts +#: src/flows/FlowInspector.ts #: src/flows/access_denied/FlowAccessDenied.ts #: src/flows/stages/authenticator_duo/AuthenticatorDuoStage.ts #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts @@ -2706,6 +2724,10 @@ msgstr "" msgid "Newly created users are added to this group, if a group is selected." msgstr "" +#: src/flows/FlowInspector.ts +msgid "Next stage" +msgstr "" + #: src/elements/oauth/UserRefreshList.ts #: src/pages/applications/ApplicationCheckAccessForm.ts #: src/pages/crypto/CertificateKeyPairListPage.ts @@ -3071,6 +3093,10 @@ msgstr "" msgid "Placeholder" msgstr "" +#: src/flows/FlowInspector.ts +msgid "Plan history" +msgstr "" + #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts #: src/flows/stages/authenticator_validate/AuthenticatorValidateStageCode.ts msgid "Please enter your TOTP Code" @@ -3373,6 +3399,7 @@ msgstr "" msgid "Recovery link cannot be emailed, user has no email address saved." msgstr "" +#: src/flows/stages/RedirectStage.ts #: src/pages/providers/saml/SAMLProviderForm.ts msgid "Redirect" msgstr "" @@ -3740,6 +3767,10 @@ msgstr "" #~ msgid "Session" #~ msgstr "" +#: src/flows/FlowInspector.ts +msgid "Session ID" +msgstr "" + #: src/pages/stages/user_login/UserLoginStageForm.ts msgid "Session duration" msgstr "" @@ -3786,10 +3817,18 @@ msgstr "" msgid "Show arbitrary input fields to the user, for example during enrollment. Data is saved in the flow context under the 'prompt_data' variable." msgstr "" +#: src/elements/Expand.ts +msgid "Show less" +msgstr "" + #: src/pages/stages/identification/IdentificationStageForm.ts msgid "Show matched user" msgstr "" +#: src/elements/Expand.ts +msgid "Show more" +msgstr "" + #: src/pages/flows/FlowForm.ts msgid "Shown as the Title in Flow pages." msgstr "" @@ -3889,6 +3928,18 @@ msgstr "" msgid "Stage binding(s)" msgstr "" +#: src/flows/FlowInspector.ts +msgid "Stage kind" +msgstr "" + +#: src/flows/FlowInspector.ts +msgid "Stage name" +msgstr "" + +#: src/flows/FlowInspector.ts +msgid "Stage object" +msgstr "" + #: src/pages/flows/BoundStagesList.ts msgid "Stage type" msgstr "" @@ -4490,6 +4541,10 @@ msgstr "" msgid "These policies control which users can access this application." msgstr "" +#: src/flows/FlowInspector.ts +msgid "This flow is completed." +msgstr "" + #: src/pages/providers/proxy/ProxyProviderForm.ts msgid "This provider will behave like a transparent reverse-proxy, except requests must be authenticated. If your upstream application uses HTTPS, make sure to connect to the outpost using HTTPS as well." msgstr "" @@ -5259,6 +5314,10 @@ msgstr "" msgid "You can only select providers that match the type of the outpost." msgstr "" +#: src/flows/stages/RedirectStage.ts +msgid "You're about to be redirect to the following URL." +msgstr "" + #: src/interfaces/AdminInterface.ts msgid "You're currently impersonating {0}. Click to stop." msgstr "" diff --git a/web/src/pages/flows/FlowListPage.ts b/web/src/pages/flows/FlowListPage.ts index 4217ef373..7c614db29 100644 --- a/web/src/pages/flows/FlowListPage.ts +++ b/web/src/pages/flows/FlowListPage.ts @@ -104,7 +104,9 @@ export class FlowListPage extends TablePage { slug: item.slug, }) .then((link) => { - window.open(`${link.link}?next=/%23${window.location.href}`); + window.open( + `${link.link}?inspector&next=/%23${window.location.href}`, + ); }); }} > diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts index bac0131cd..e9198c766 100644 --- a/web/src/pages/flows/FlowViewPage.ts +++ b/web/src/pages/flows/FlowViewPage.ts @@ -107,6 +107,21 @@ export class FlowViewPage extends LitElement { > ${t`Execute`} +