stages: source stage (#8330)

* stages: source stage

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

* include stage name in dummy stage

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

* use data instead of instance for login button

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

* make mostly work

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

* fix ident stage

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

* make it work

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

* pass more data

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

* fix flow inspector not always loading

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

* fix dark theme for stepper

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

* fix inspector styling

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

* don't skip source stage unless returning

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

* auto open flow inspector when debug

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

* fix lint

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

* fixup

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

* fix lint

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

* fix validation

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

* include raw saml response in flow context

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

* add some tests

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

* move

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

* add docs

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

* Apply suggestions from code review

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

* fix import

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
Jens L
2024-03-14 19:46:27 +01:00
committed by GitHub
parent 5805ac83f7
commit fdcc1dcb36
34 changed files with 1107 additions and 21 deletions

View File

@ -617,6 +617,9 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
"""Get serializer for this model"""
raise NotImplementedError
def __str__(self) -> str:
return f"User-source connection (user={self.user.username}, source={self.source.slug})"
class Meta:
unique_together = (("user", "source"),)

View File

@ -16,8 +16,9 @@ from authentik.core.models import Source, SourceUserMatchingModes, User, UserSou
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage
from authentik.flows.planner import (
PLAN_CONTEXT_IS_RESTORED,
PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_REDIRECT,
PLAN_CONTEXT_SOURCE,
@ -35,6 +36,8 @@ from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec
class Action(Enum):
"""Actions that can be decided based on the request
@ -222,22 +225,43 @@ class SourceFlowManager:
**kwargs,
) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor"""
# Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
kwargs.update(
{
# Since we authenticate the user by their token, they have no backend set
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT,
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_REDIRECT: final_redirect,
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
}
)
kwargs.update(self.policy_context)
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(kwargs)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
for stage in stages:
plan.append_stage(stage)
self.request.session[SESSION_KEY_PLAN] = plan
flow_slug = token.flow.slug
token.delete()
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow_slug,
)
# Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if PLAN_CONTEXT_REDIRECT not in kwargs:
kwargs[PLAN_CONTEXT_REDIRECT] = final_redirect
if not flow:
return bad_request_message(
self.request,

View File

@ -15,6 +15,7 @@ CELERY_BEAT_SCHEDULE = {
TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.source",
]
MIDDLEWARE = ["authentik.enterprise.middleware.EnterpriseMiddleware"]

View File

View File

@ -0,0 +1,38 @@
"""Source Stage API Views"""
from rest_framework.exceptions import ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import Source
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.source.models import SourceStage
from authentik.flows.api.stages import StageSerializer
class SourceStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""SourceStage Serializer"""
def validate_source(self, _source: Source) -> Source:
"""Ensure configured source supports web-based login"""
source = Source.objects.filter(pk=_source.pk).select_subclasses().first()
if not source:
raise ValidationError("Invalid source")
login_button = source.ui_login_button(self.context["request"])
if not login_button:
raise ValidationError("Invalid source selected, only web-based sources are supported.")
return source
class Meta:
model = SourceStage
fields = StageSerializer.Meta.fields + ["source", "resume_timeout"]
class SourceStageViewSet(UsedByMixin, ModelViewSet):
"""SourceStage Viewset"""
queryset = SourceStage.objects.all()
serializer_class = SourceStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]

View File

@ -0,0 +1,12 @@
"""authentik stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageSourceConfig(EnterpriseConfig):
"""authentik source stage config"""
name = "authentik.enterprise.stages.source"
label = "authentik_stages_source"
verbose_name = "authentik Enterprise.Stages.Source"
default = True

View File

@ -0,0 +1,53 @@
# Generated by Django 5.0.2 on 2024-02-25 20:44
import authentik.lib.utils.time
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_core", "0033_alter_user_options"),
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.CreateModel(
name="SourceStage",
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",
),
),
(
"resume_timeout",
models.TextField(
default="minutes=10",
help_text="Amount of time a user can take to return from the source to continue the flow (Format: hours=-1;minutes=-2;seconds=-3)",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.source"
),
),
],
options={
"verbose_name": "Source Stage",
"verbose_name_plural": "Source Stages",
},
bases=("authentik_flows.stage",),
),
]

View File

@ -0,0 +1,45 @@
"""Source stage models"""
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 Stage
from authentik.lib.utils.time import timedelta_string_validator
class SourceStage(Stage):
"""Suspend the current flow execution and send the user to a source,
after which this flow execution is resumed."""
source = models.ForeignKey("authentik_core.Source", on_delete=models.CASCADE)
resume_timeout = models.TextField(
default="minutes=10",
validators=[timedelta_string_validator],
help_text=_(
"Amount of time a user can take to return from the source to continue the flow "
"(Format: hours=-1;minutes=-2;seconds=-3)"
),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.source.api import SourceStageSerializer
return SourceStageSerializer
@property
def view(self) -> type[View]:
from authentik.enterprise.stages.source.stage import SourceStageView
return SourceStageView
@property
def component(self) -> str:
return "ak-stage-source-form"
class Meta:
verbose_name = _("Source Stage")
verbose_name_plural = _("Source Stages")

View File

@ -0,0 +1,79 @@
"""Source stage logic"""
from typing import Any
from uuid import uuid4
from django.http import HttpRequest, HttpResponse
from django.utils.text import slugify
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Source, User
from authentik.core.sources.flow_manager import SESSION_KEY_OVERRIDE_FLOW_TOKEN
from authentik.core.types import UILoginButton
from authentik.enterprise.stages.source.models import SourceStage
from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED
from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.time import timedelta_from_string
PLAN_CONTEXT_RESUME_TOKEN = "resume_token" # nosec
class SourceStageView(ChallengeStageView):
"""Suspend the current flow execution and send the user to a source,
after which this flow execution is resumed."""
login_button: UILoginButton
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
current_stage: SourceStage = self.executor.current_stage
source: Source = (
Source.objects.filter(pk=current_stage.source_id).select_subclasses().first()
)
if not source:
self.logger.warning("Source does not exist")
return self.executor.stage_invalid("Source does not exist")
self.login_button = source.ui_login_button(self.request)
if not self.login_button:
self.logger.warning("Source does not have a UI login button")
return self.executor.stage_invalid("Invalid source")
restore_token = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED)
override_token = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
if restore_token and override_token and restore_token.pk == override_token.pk:
del self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN]
return self.executor.stage_ok()
return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> Challenge:
resume_token = self.create_flow_token()
self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token
return self.login_button.challenge
def create_flow_token(self) -> FlowToken:
"""Save the current flow state in a token that can be used to resume this flow"""
pending_user: User = self.get_pending_user()
if pending_user.is_anonymous:
pending_user = get_anonymous_user()
current_stage: SourceStage = self.executor.current_stage
identifier = slugify(f"ak-source-stage-{current_stage.name}-{str(uuid4())}")
# Don't check for validity here, we only care if the token exists
tokens = FlowToken.objects.filter(identifier=identifier)
valid_delta = timedelta_from_string(current_stage.resume_timeout)
if not tokens.exists():
return FlowToken.objects.create(
expires=now() + valid_delta,
user=pending_user,
identifier=identifier,
flow=self.executor.flow,
_plan=FlowToken.pickle(self.executor.plan),
)
token = tokens.first()
# Check if token is expired and rotate key if so
if token.is_expired:
token.expire_action()
return token
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@ -0,0 +1,99 @@
"""Source stage tests"""
from django.urls import reverse
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.stages.source.models import SourceStage
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.sources.saml.models import SAMLSource
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.models import PasswordStage
from authentik.stages.user_login.models import UserLoginStage
class TestSourceStage(FlowTestCase):
"""Source stage tests"""
def setUp(self):
self.source = SAMLSource.objects.create(
slug=generate_id(),
issuer="authentik",
allow_idp_initiated=True,
pre_authentication_flow=create_test_flow(),
)
def test_source_success(self):
"""Test"""
user = create_test_user()
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
stage = SourceStage.objects.create(name=generate_id(), source=self.source)
FlowStageBinding.objects.create(
target=flow,
stage=IdentificationStage.objects.create(
name=generate_id(),
user_fields=[UserFields.USERNAME],
),
order=0,
)
FlowStageBinding.objects.create(
target=flow,
stage=PasswordStage.objects.create(name=generate_id(), backends=[BACKEND_INBUILT]),
order=5,
)
FlowStageBinding.objects.create(target=flow, stage=stage, order=10)
FlowStageBinding.objects.create(
target=flow,
stage=UserLoginStage.objects.create(
name=generate_id(),
),
order=15,
)
# Get user identification stage
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(response, flow, component="ak-stage-identification")
# Send username
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
data={"uid_field": user.username},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(response, flow, component="ak-stage-password")
# Send password
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
data={"password": user.username},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(
response,
reverse("authentik_sources_saml:login", kwargs={"source_slug": self.source.slug}),
)
# Hijack flow plan so we don't have to emulate the source
flow_token = FlowToken.objects.filter(
identifier__startswith=f"ak-source-stage-{stage.name.lower()}"
).first()
self.assertIsNotNone(flow_token)
session = self.client.session
plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
session[SESSION_KEY_PLAN] = plan
session.save()
# Pretend we've just returned from the source
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))

View File

@ -0,0 +1,5 @@
"""API URLs"""
from authentik.enterprise.stages.source.api import SourceStageViewSet
api_urlpatterns = [("stages/source", SourceStageViewSet)]

View File

@ -13,6 +13,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.types import UserSettingSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.flows.api.flows import FlowSetSerializer
from authentik.flows.models import ConfigurableStage, Stage
from authentik.lib.utils.reflection import all_subclasses
@ -75,6 +76,7 @@ class StageViewSet(
"description": subclass.__doc__,
"component": subclass().component,
"model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
}
)
data = sorted(data, key=lambda x: x["name"])

View File

@ -469,7 +469,7 @@ class FlowExecutorView(APIView):
class CancelView(View):
"""View which canels the currently active plan"""
"""View which cancels the currently active plan"""
def get(self, request: HttpRequest) -> HttpResponse:
"""View which canels the currently active plan"""

View File

@ -47,7 +47,7 @@ class SourceType:
def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge:
"""Allow types to return custom challenges"""
return RedirectChallenge(
instance={
data={
"type": ChallengeTypes.REDIRECT.value,
"to": reverse(
"authentik_sources_oauth:oauth-client-login",

View File

@ -54,7 +54,7 @@ class OAuthCallback(OAuthClientMixin, View):
raw_profile=exc.doc,
).from_http(self.request)
return self.handle_login_failure("Could not retrieve profile.")
identifier = self.get_user_id(raw_info)
identifier = self.get_user_id(info=raw_info)
if identifier is None:
return self.handle_login_failure("Could not determine id.")
# Get or create access record
@ -67,6 +67,7 @@ class OAuthCallback(OAuthClientMixin, View):
)
sfm.policy_context = {"oauth_userinfo": raw_info}
return sfm.get_flow(
raw_info=raw_info,
access_token=self.token.get("access_token"),
)
@ -116,6 +117,7 @@ class OAuthSourceFlowManager(SourceFlowManager):
self,
connection: UserOAuthSourceConnection,
access_token: str | None = None,
**_,
) -> UserOAuthSourceConnection:
"""Set the access_token on the connection"""
connection.access_token = access_token

View File

@ -190,7 +190,7 @@ class SAMLSource(Source):
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
return UILoginButton(
challenge=RedirectChallenge(
instance={
data={
"type": ChallengeTypes.REDIRECT.value,
"to": reverse(
"authentik_sources_saml:login",

View File

@ -234,12 +234,14 @@ class ResponseProcessor:
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
return self._handle_name_id_transient()
return SAMLSourceFlowManager(
flow_manager = SAMLSourceFlowManager(
self._source,
self._http_request,
name_id.text,
delete_none_values(self.get_attributes()),
)
flow_manager.policy_context["saml_response"] = self._root
return flow_manager
class SAMLSourceFlowManager(SourceFlowManager):

View File

@ -12,6 +12,7 @@ class DummyChallenge(Challenge):
"""Dummy challenge"""
component = CharField(default="ak-stage-dummy")
name = CharField()
class DummyChallengeResponse(ChallengeResponse):
@ -35,5 +36,6 @@ class DummyStageView(ChallengeStageView):
data={
"type": ChallengeTypes.NATIVE.value,
"title": self.executor.current_stage.name,
"name": self.executor.current_stage.name,
}
)

View File

@ -237,7 +237,9 @@ class IdentificationStageView(ChallengeStageView):
ui_login_button = source.ui_login_button(self.request)
if ui_login_button:
button = asdict(ui_login_button)
button["challenge"] = ui_login_button.challenge.data
source_challenge = ui_login_button.challenge
source_challenge.is_valid()
button["challenge"] = source_challenge.data
ui_sources.append(button)
challenge.initial_data["sources"] = ui_sources
return challenge

View File

@ -2594,6 +2594,43 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_source.sourcestage"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_source.sourcestage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_source.sourcestage"
}
}
},
{
"type": "object",
"required": [
@ -3257,6 +3294,7 @@
"authentik.enterprise",
"authentik.enterprise.audit",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.source",
"authentik.events"
],
"title": "App",
@ -3338,6 +3376,7 @@
"authentik_providers_rac.racprovider",
"authentik_providers_rac.endpoint",
"authentik_providers_rac.racpropertymapping",
"authentik_stages_source.sourcestage",
"authentik_events.event",
"authentik_events.notificationtransport",
"authentik_events.notification",
@ -8018,6 +8057,109 @@
},
"required": []
},
"model_authentik_stages_source.sourcestage": {
"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"
},
"source": {
"type": "integer",
"title": "Source"
},
"resume_timeout": {
"type": "string",
"minLength": 1,
"title": "Resume timeout",
"description": "Amount of time a user can take to return from the source to continue the flow (Format: hours=-1;minutes=-2;seconds=-3)"
}
},
"required": []
},
"model_authentik_events.event": {
"type": "object",
"properties": {

View File

@ -18512,6 +18512,7 @@ paths:
- authentik_stages_password.passwordstage
- authentik_stages_prompt.prompt
- authentik_stages_prompt.promptstage
- authentik_stages_source.sourcestage
- authentik_stages_user_delete.userdeletestage
- authentik_stages_user_login.userloginstage
- authentik_stages_user_logout.userlogoutstage
@ -18587,6 +18588,7 @@ paths:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -18800,6 +18802,7 @@ paths:
- authentik_stages_password.passwordstage
- authentik_stages_prompt.prompt
- authentik_stages_prompt.promptstage
- authentik_stages_source.sourcestage
- authentik_stages_user_delete.userdeletestage
- authentik_stages_user_login.userloginstage
- authentik_stages_user_logout.userlogoutstage
@ -18875,6 +18878,7 @@ paths:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -27872,6 +27876,289 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/source/:
get:
operationId: stages_source_list
description: SourceStage 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
- in: query
name: resume_timeout
schema:
type: string
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: source
schema:
type: string
format: uuid
- in: query
name: stage_uuid
schema:
type: string
format: uuid
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedSourceStageList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: stages_source_create
description: SourceStage Viewset
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStageRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/source/{stage_uuid}/:
get:
operationId: stages_source_retrieve
description: SourceStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Source Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: stages_source_update
description: SourceStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Source Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStageRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: stages_source_partial_update
description: SourceStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Source Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedSourceStageRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SourceStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: stages_source_destroy
description: SourceStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Source 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/source/{stage_uuid}/used_by/:
get:
operationId: stages_source_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 Source 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/user_delete/:
get:
operationId: stages_user_delete_list
@ -29642,6 +29929,7 @@ components:
- authentik.enterprise
- authentik.enterprise.audit
- authentik.enterprise.providers.rac
- authentik.enterprise.stages.source
- authentik.events
type: string
description: |-
@ -29696,6 +29984,7 @@ components:
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
* `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source
* `authentik.events` - authentik Events
AppleChallengeResponseRequest:
type: object
@ -32057,7 +32346,10 @@ components:
type: array
items:
$ref: '#/components/schemas/ErrorDetail'
name:
type: string
required:
- name
- type
DummyChallengeResponseRequest:
type: object
@ -32739,6 +33031,7 @@ components:
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
* `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source
* `authentik.events` - authentik Events
model:
allOf:
@ -32816,6 +33109,7 @@ components:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -32938,6 +33232,7 @@ components:
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
* `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source
* `authentik.events` - authentik Events
model:
allOf:
@ -33015,6 +33310,7 @@ components:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -35330,6 +35626,7 @@ components:
- authentik_providers_rac.racprovider
- authentik_providers_rac.endpoint
- authentik_providers_rac.racpropertymapping
- authentik_stages_source.sourcestage
- authentik_events.event
- authentik_events.notificationtransport
- authentik_events.notification
@ -35406,6 +35703,7 @@ components:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -37340,6 +37638,18 @@ components:
required:
- pagination
- results
PaginatedSourceStageList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/SourceStage'
required:
- pagination
- results
PaginatedStageList:
type: object
properties:
@ -38583,6 +38893,7 @@ components:
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
* `authentik.enterprise.stages.source` - authentik Enterprise.Stages.Source
* `authentik.events` - authentik Events
model:
allOf:
@ -38660,6 +38971,7 @@ components:
* `authentik_providers_rac.racprovider` - RAC Provider
* `authentik_providers_rac.endpoint` - RAC Endpoint
* `authentik_providers_rac.racpropertymapping` - RAC Property Mapping
* `authentik_stages_source.sourcestage` - Source Stage
* `authentik_events.event` - Event
* `authentik_events.notificationtransport` - Notification Transport
* `authentik_events.notification` - Notification
@ -40242,6 +40554,25 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
PatchedSourceStageRequest:
type: object
description: SourceStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
source:
type: string
format: uuid
resume_timeout:
type: string
minLength: 1
description: 'Amount of time a user can take to return from the source to
continue the flow (Format: hours=-1;minutes=-2;seconds=-3)'
PatchedStaticDeviceRequest:
type: object
description: Serializer for static authenticator devices
@ -43586,6 +43917,74 @@ components:
required:
- name
- slug
SourceStage:
type: object
description: SourceStage 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'
source:
type: string
format: uuid
resume_timeout:
type: string
description: 'Amount of time a user can take to return from the source to
continue the flow (Format: hours=-1;minutes=-2;seconds=-3)'
required:
- component
- meta_model_name
- name
- pk
- source
- verbose_name
- verbose_name_plural
SourceStageRequest:
type: object
description: SourceStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
source:
type: string
format: uuid
resume_timeout:
type: string
minLength: 1
description: 'Amount of time a user can take to return from the source to
continue the flow (Format: hours=-1;minutes=-2;seconds=-3)'
required:
- name
- source
SourceType:
type: object
description: Serializer for SourceType

View File

@ -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/source/SourceStageForm";
import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm";
import "@goauthentik/admin/stages/user_login/UserLoginStageForm";
import "@goauthentik/admin/stages/user_logout/UserLogoutStageForm";

View File

@ -1,3 +1,4 @@
import "@goauthentik/admin/common/ak-license-notice";
import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
@ -14,12 +15,14 @@ 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/source/SourceStageForm";
import "@goauthentik/admin/stages/user_delete/UserDeleteStageForm";
import "@goauthentik/admin/stages/user_login/UserLoginStageForm";
import "@goauthentik/admin/stages/user_logout/UserLogoutStageForm";
import "@goauthentik/admin/stages/user_write/UserWriteStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider";
import "@goauthentik/elements/forms/ProxyForm";
import "@goauthentik/elements/wizard/FormWizardPage";
import { FormWizardPage } from "@goauthentik/elements/wizard/FormWizardPage";
@ -28,7 +31,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { CSSResult, TemplateResult, html } from "lit";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -39,7 +42,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { FlowStageBinding, Stage, StagesApi, TypeCreate } from "@goauthentik/api";
@customElement("ak-stage-wizard-initial")
export class InitialStageWizardPage extends WizardPage {
export class InitialStageWizardPage extends WithLicenseSummary(WizardPage) {
@property({ attribute: false })
stageTypes: TypeCreate[] = [];
sidebarLabel = () => msg("Select type");
@ -62,6 +65,7 @@ export class InitialStageWizardPage extends WizardPage {
render(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.stageTypes.map((type) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
@ -82,11 +86,15 @@ export class InitialStageWizardPage extends WizardPage {
);
this.host.isValid = true;
}}
?disabled=${requiresEnterprise}
/>
<label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`}
>${type.name}</label
>
<span class="pf-c-radio__description">${type.description}</span>
<span class="pf-c-radio__description">${type.description}${
requiresEnterprise ? html`<ak-license-notice></ak-license-notice>` : nothing
}</span>
</span>
</div>`;
})}
</form>`;

View File

@ -0,0 +1,99 @@
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect/index";
import "@goauthentik/elements/utils/TimeDeltaHelp";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
Source,
SourceStage,
SourcesAllListRequest,
SourcesApi,
StagesApi,
} from "@goauthentik/api";
@customElement("ak-stage-source-form")
export class SourceStageForm extends BaseStageForm<SourceStage> {
loadInstance(pk: string): Promise<SourceStage> {
return new StagesApi(DEFAULT_CONFIG).stagesSourceRetrieve({
stageUuid: pk,
});
}
async send(data: SourceStage): Promise<SourceStage> {
if (this.instance) {
return new StagesApi(DEFAULT_CONFIG).stagesSourceUpdate({
stageUuid: this.instance.pk || "",
sourceStageRequest: data,
});
} else {
return new StagesApi(DEFAULT_CONFIG).stagesSourceCreate({
sourceStageRequest: data,
});
}
}
renderForm(): TemplateResult {
return html`
<span> ${msg("TODO.")} </span>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name || "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Source")} ?required=${true} name="source">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Source[]> => {
const args: SourcesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const users = await new SourcesApi(DEFAULT_CONFIG).sourcesAllList(args);
return users.results;
}}
.renderElement=${(source: Source): string => {
return source.name;
}}
.renderDescription=${(source: Source): TemplateResult => {
return html`${source.verboseName}`;
}}
.value=${(source: Source | undefined): string | undefined => {
return source?.pk;
}}
.selected=${(source: Source): boolean => {
return source.pk === this.instance?.source;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Resume timeout")}
?required=${true}
name="resumeTimeout"
>
<input
type="text"
value="${ifDefined(this.instance?.resumeTimeout || "minutes=10")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Amount of time a user can take to return from the source to continue the flow.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
`;
}
}

View File

@ -329,3 +329,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
.pf-c-tree-view__content:focus-within {
--pf-c-tree-view__node--hover--BackgroundColor: var(--ak-dark-background-light-ish);
}
/* stepper */
.pf-c-progress-stepper__step-title {
--pf-c-progress-stepper__step-title--Color: var(--ak-dark-foreground);
}

View File

@ -32,6 +32,7 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
CapabilitiesEnum,
ChallengeChoices,
ChallengeTypes,
ContextualFlowInfo,
@ -162,7 +163,7 @@ export class FlowExecutor extends Interface implements StageHost {
super();
this.ws = new WebsocketClient();
if (window.location.search.includes("inspector")) {
this.inspectorOpen = !this.inspectorOpen;
this.inspectorOpen = true;
}
this.addEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, () => {
this.inspectorOpen = !this.inspectorOpen;
@ -213,6 +214,9 @@ export class FlowExecutor extends Interface implements StageHost {
async firstUpdated(): Promise<void> {
configureSentry();
if (this.config?.capabilities.includes(CapabilitiesEnum.CanDebug)) {
this.inspectorOpen = true;
}
this.loading = true;
try {
const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorGet({

View File

@ -37,6 +37,10 @@ export class FlowInspector extends AKElement {
PFDescriptionList,
PFProgressStepper,
css`
.pf-c-drawer__body {
min-height: 100vh;
max-height: 100vh;
}
code.break {
word-break: break-all;
}
@ -45,9 +49,6 @@ export class FlowInspector extends AKElement {
overflow-x: hidden;
white-space: break-spaces;
}
.pf-c-notification-drawer__body {
overflow-x: hidden;
}
`,
];
}
@ -113,6 +114,7 @@ export class FlowInspector extends AKElement {
return this.renderAccessDenied();
}
if (!this.state) {
this.advanceHandler();
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`;
}

View File

@ -2,7 +2,7 @@ import "@goauthentik/elements/EmptyState";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
@ -36,6 +36,7 @@ export class DummyStage extends BaseStage<DummyChallenge, DummyChallengeResponse
this.submitForm(e);
}}
>
<p>${msg(str`Stage name: ${this.challenge.name}`)}</p>
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")}

View File

@ -8457,6 +8457,9 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s6ecfc18dbfeedd76">
<source>Select one of the options below to continue.</source>
<target>Sélectionner une des options suivantes pour continuer.</target>
</trans-unit>
<trans-unit id="s4fe832d815563329">
<source>TODO.</source>
</trans-unit>
</body>
</file>

View File

@ -53,6 +53,10 @@ Flows are an ordered sequence of stages. These flows can be used to define how a
A stage represents a single verification or logic step. They are used to authenticate users, enroll users, and more. These stages can optionally be applied to a flow via policies.
#### Dynamic in-memory stage
Certain use cases within authentik add steps that are run as part of a flow. These steps are a special type of stage called the "Dynamic in-memory" stage, as they are added to flows dynamically when required, only exist in memory, and are thus not configurable by administrators.
See [Flows](../flow/index.md)
### Property Mappings

View File

@ -0,0 +1,49 @@
---
title: Source stage
---
<span class="badge badge--primary">Enterprise</span>
<span class="badge badge--info">authentik 2024.4+</span>
---
The source stage injects an [OAuth](../../../../integrations/sources/oauth/) or [SAML](../../../../integrations/sources/saml/) Source into the flow execution. This allows for additional user verification, or to dynamically access different sources for different user identifiers (username, email address, etc).
```mermaid
sequenceDiagram
participant u as User
participant ak as authentik
participant eidp as External IDP
u->>ak: User initiates flow
ak->>u: User reaches Source Stage
u->>eidp: User is redirected to external IDP
eidp->>ak: User has authenticated with external IDP
alt User is connected to external IDP (auth)
ak->>u: Source's authentication flow is started
u->>ak: User finishes source's authentication flow
else User has not been connected to external IDP (enroll)
ak->>u: Source's enrollment flow is started
u->>ak: User finishes source's enrollment flow
end
ak->>u: Execution of the previous flow is resumed
```
### Considerations
It is very important that the configured source's authentication and enrollment flows (when set; they can be left unselected to prevent authentication or enrollment with the source) do **not** have a [User login stage](../user_login/index.md) bound to them.
This is because the Source stage works by appending a [dynamic in-memory](../../../core/terminology.md#dynamic-in-memory-stage) stage to the source's flow, so having a [User login stage](../user_login/index.md) bound will cause the source's flow to not resume the original flow it was started from, and instead directly authenticating the pending user.
### Options
#### `source`
The source the user is redirected to. Must be a web-based source, such as [OAuth](../../../../integrations/sources/oauth/) or [SAML](../../../../integrations/sources/saml/). Sources like [LDAP](../../../../integrations/sources/ldap/) are _not_ compatible.
#### `resume_timeout`
Because the execution of the current flow is suspended before the user is redirected to the configured source, this option configures how long the suspended flow is saved. If this timeout is exceeded, upon return from the configured source, the suspended flow will restart from the beginning.

View File

@ -204,6 +204,7 @@ const docsSidebar = {
"flow/stages/invitation/index",
"flow/stages/password/index",
"flow/stages/prompt/index",
"flow/stages/source/index",
"flow/stages/user_delete",
"flow/stages/user_login/index",
"flow/stages/user_logout",