enterprise/stages/source: configurable failure action (#24963)

* enterprise/stages/source: configurable failure action

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

* rework to exception

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

* Revert "rework to exception"

This reverts commit 6b18fb6f10.

* Reapply "rework to exception"

This reverts commit 6f6ae67f99.

* actually nah fix it

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

* Revert "actually nah fix it"

This reverts commit a8e1d0e83d.

* Revert "Reapply "rework to exception""

This reverts commit 70d001a255.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-08-12 12:56:30 +01:00
committed by GitHub
parent 395d8b1207
commit e5336c757f
16 changed files with 593 additions and 14 deletions

View File

@@ -1,5 +1,6 @@
"""Source decision helper"""
from dataclasses import dataclass
from typing import Any
from django.contrib import messages
@@ -18,7 +19,7 @@ from authentik.core.models import (
UserSourceConnection,
)
from authentik.core.sources.mapper import SourceMapper
from authentik.core.sources.matcher import Action, SourceMatcher
from authentik.core.sources.matcher import Action, MatchFailure, MatchFailureReason, SourceMatcher
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostSourceStage,
@@ -47,11 +48,31 @@ from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
LOGGER = get_logger()
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
PLAN_CONTEXT_SOURCE_MATCH_FAILURE = "goauthentik.io/core/sources/matching_failure"
PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG = "goauthentik.io/core/sources/match_failure_config"
SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages"
SESSION_KEY_SOURCE_FLOW_CONTEXT = "authentik/flows/source_flow_context"
SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec
def clear_source_flow_session(request: HttpRequest) -> None:
"""Clear state used to return from a source flow."""
for key in (
SESSION_KEY_OVERRIDE_FLOW_TOKEN,
SESSION_KEY_SOURCE_FLOW_CONTEXT,
SESSION_KEY_SOURCE_FLOW_STAGES,
):
request.session.pop(key, None)
@dataclass(frozen=True)
class MatchFailureConfig:
reasons: list[MatchFailureReason]
source_pk: str
stage_pk: str | None
class MessageStage(StageView):
"""Show a pre-configured message after the flow is done"""
@@ -167,6 +188,10 @@ class SourceFlowManager:
if action == Action.ENROLL:
self._logger.debug("Handling enrollment of new user")
return self.handle_enroll(connection)
if action == Action.DENY and self.matcher.failure:
response = self.handle_match_failure(self.matcher.failure)
if response:
return response
except FlowNonApplicableException as exc:
self._logger.warning("Flow non applicable", exc=exc)
return self.error_handler(exc)
@@ -181,6 +206,43 @@ class SourceFlowManager:
)
return self.error_handler(error)
def handle_match_failure(self, failure: MatchFailure) -> HttpResponse | None:
"""Resume an opted-in Source Stage after a configured matching failure."""
session_token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
if not session_token:
return None
try:
session_token.refresh_from_db()
except FlowToken.DoesNotExist:
clear_source_flow_session(self.request)
return None
if session_token.is_expired:
session_token.expire_action()
clear_source_flow_session(self.request)
return None
plan = session_token.plan
resume_config: MatchFailureConfig | None = plan.context.get(
PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG
)
current_stage = plan.bindings[0].stage if plan.bindings else None
if not resume_config or not current_stage:
return None
if (
failure.reason not in resume_config.reasons
or resume_config.source_pk != str(self.source.pk)
or resume_config.stage_pk != str(current_stage.pk)
or getattr(current_stage, "source_id", None) != self.source.pk
):
return None
plan.context.pop(PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG, None)
plan.context.update(self.policy_context)
plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE] = failure
plan.context[PLAN_CONTEXT_IS_RESTORED] = session_token
response = plan.to_redirect(self.request, session_token.flow)
session_token.delete()
return response
def error_handler(self, error: Exception) -> HttpResponse:
"""Handle any errors by returning an access denied stage"""
response = AccessDeniedResponse(self.request)

View File

@@ -4,7 +4,9 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from structlog import get_logger
from authentik.core.models import (
@@ -27,6 +29,21 @@ class Action(Enum):
DENY = "deny"
class MatchFailureReason(models.TextChoices):
"""Reason source matching could not determine an action."""
MISSING_PROPERTY = "missing_property", _("Missing property")
@dataclass(frozen=True)
class MatchFailure:
"""Details about a source matching failure."""
reason: MatchFailureReason
property: str
source_slug: str
@dataclass
class MatchableProperty:
property: str
@@ -44,6 +61,7 @@ class SourceMatcher:
self.source = source
self.user_connection_type = user_connection_type
self.group_connection_type = group_connection_type
self.failure: MatchFailure | None = None
self._logger = get_logger().bind(source=self.source)
def get_action(
@@ -53,6 +71,7 @@ class SourceMatcher:
identifier: str,
properties: dict[str, Any | dict[str, Any]],
) -> tuple[Action, UserSourceConnection | GroupSourceConnection | None]:
self.failure = None
connection_type = None
matching_mode = None
identifier_matching_mode = None
@@ -67,13 +86,12 @@ class SourceMatcher:
if not connection_type or not matching_mode or not identifier_matching_mode:
return Action.DENY, None
new_connection = connection_type(source=self.source, identifier=identifier)
existing_connections = connection_type.objects.filter(
source=self.source, identifier=identifier
)
if existing_connections.exists():
return Action.AUTH, existing_connections.first()
new_connection = connection_type(source=self.source, identifier=identifier)
# No connection exists, but we match on identifier, so enroll
if matching_mode == identifier_matching_mode:
# We don't save the connection here cause it doesn't have a user/group assigned yet
@@ -85,6 +103,11 @@ class SourceMatcher:
property = matchable_property.property
if matching_mode in [matchable_property.link_mode, matchable_property.deny_mode]:
if not properties.get(property, None):
self.failure = MatchFailure(
reason=MatchFailureReason.MISSING_PROPERTY,
property=property,
source_slug=self.source.slug,
)
self._logger.warning(
"Refusing to use none property", identifier=identifier, property=property
)

View File

@@ -7,6 +7,7 @@ from guardian.shortcuts import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action
from authentik.core.sources.matcher import MatchFailureReason
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import RequestFactory, create_test_flow
from authentik.flows.planner import FlowPlan
@@ -126,6 +127,12 @@ class TestSourceFlowManager(TestCase):
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
failure = flow_manager.matcher.failure
self.assertIsNotNone(failure)
self.assertEqual(failure.reason, MatchFailureReason.MISSING_PROPERTY)
self.assertEqual(failure.property, "email")
self.assertEqual(failure.source_slug, self.source.slug)
self.assertFalse(UserOAuthSourceConnection.objects.exists())
flow_manager.get_flow()
# With email
flow_manager = OAuthSourceFlowManager(
@@ -141,6 +148,7 @@ class TestSourceFlowManager(TestCase):
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
self.assertIsNone(flow_manager.matcher.failure)
flow_manager.get_flow()
def test_unauthenticated_enroll_username(self):
@@ -158,6 +166,10 @@ class TestSourceFlowManager(TestCase):
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
failure = flow_manager.matcher.failure
self.assertIsNotNone(failure)
self.assertEqual(failure.reason, MatchFailureReason.MISSING_PROPERTY)
self.assertEqual(failure.property, "username")
flow_manager.get_flow()
# With username
flow_manager = OAuthSourceFlowManager(
@@ -205,6 +217,7 @@ class TestSourceFlowManager(TestCase):
)
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.DENY)
self.assertIsNone(flow_manager.matcher.failure)
flow_manager.get_flow()
def test_unauthenticated_enroll_link_non_existent(self):

View File

@@ -28,7 +28,11 @@ class SourceStageSerializer(EnterpriseRequiredMixin, StageSerializer):
class Meta:
model = SourceStage
fields = StageSerializer.Meta.fields + ["source", "resume_timeout"]
fields = StageSerializer.Meta.fields + [
"source",
"resume_timeout",
"resume_on_match_failures",
]
class SourceStageViewSet(UsedByMixin, ModelViewSet):
@@ -36,6 +40,6 @@ class SourceStageViewSet(UsedByMixin, ModelViewSet):
queryset = SourceStage.objects.all()
serializer_class = SourceStageSerializer
filterset_fields = "__all__"
filterset_fields = ["stage_uuid", "name", "source", "resume_timeout"]
ordering = ["name"]
search_fields = ["name"]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-08-10 19:02
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_source", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="sourcestage",
name="resume_on_match_failures",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(choices=[("missing_property", "Missing property")]),
blank=True,
default=list,
help_text="Source matching failure reasons for which the flow should resume.",
size=None,
),
),
]

View File

@@ -1,10 +1,12 @@
"""Source stage models"""
from django.contrib.postgres.fields import ArrayField
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.core.sources.matcher import MatchFailureReason
from authentik.flows.models import Stage
from authentik.lib.utils.time import timedelta_string_validator
@@ -24,6 +26,13 @@ class SourceStage(Stage):
),
)
resume_on_match_failures = ArrayField(
models.TextField(choices=MatchFailureReason.choices),
default=list,
blank=True,
help_text=_("Source matching failure reasons for which the flow should resume."),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.source.api import SourceStageSerializer

View File

@@ -10,9 +10,13 @@ from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Source, User
from authentik.core.sources.flow_manager import (
PLAN_CONTEXT_SOURCE_MATCH_FAILURE,
PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG,
SESSION_KEY_OVERRIDE_FLOW_TOKEN,
SESSION_KEY_SOURCE_FLOW_CONTEXT,
SESSION_KEY_SOURCE_FLOW_STAGES,
MatchFailureConfig,
clear_source_flow_session,
)
from authentik.core.types import UILoginButton
from authentik.enterprise.stages.source.models import SourceStage
@@ -46,11 +50,23 @@ class SourceStageView(ChallengeStageView):
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]
clear_source_flow_session(self.request)
self.executor.plan.context.pop(PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG, None)
return self.executor.stage_ok()
return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> Challenge:
current_stage: SourceStage = self.executor.current_stage
self.executor.plan.context.pop(PLAN_CONTEXT_SOURCE_MATCH_FAILURE, None)
self.executor.plan.context.pop(PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG, None)
if current_stage.resume_on_match_failures:
self.executor.plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG] = (
MatchFailureConfig(
reasons=current_stage.resume_on_match_failures,
source_pk=str(current_stage.source_id),
stage_pk=str(current_stage.pk),
)
)
resume_token = self.create_flow_token()
self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token
self.request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)]
@@ -96,9 +112,22 @@ class SourceStageFinal(StageView):
def dispatch(self, *args, **kwargs):
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
if not token:
clear_source_flow_session(self.request)
return self.executor.stage_invalid("Flow token is invalid or expired")
try:
token.refresh_from_db()
except FlowToken.DoesNotExist:
clear_source_flow_session(self.request)
return self.executor.stage_invalid("Flow token is invalid or expired")
if token.is_expired:
token.expire_action()
clear_source_flow_session(self.request)
return self.executor.stage_invalid("Flow token is invalid or expired")
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context.update(self.executor.plan.context)
plan.context.pop(PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG, None)
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
response = plan.to_redirect(self.request, token.flow)
token.delete()

View File

@@ -1,16 +1,46 @@
"""Source stage tests"""
from django.urls import reverse
from datetime import timedelta
from authentik.core.tests.utils import create_test_flow, create_test_user
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes
from authentik.core.sources.flow_manager import (
PLAN_CONTEXT_SOURCE_MATCH_FAILURE,
PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG,
SESSION_KEY_OVERRIDE_FLOW_TOKEN,
SESSION_KEY_SOURCE_FLOW_CONTEXT,
SESSION_KEY_SOURCE_FLOW_STAGES,
MatchFailureConfig,
)
from authentik.core.sources.matcher import MatchFailure, MatchFailureReason
from authentik.core.tests.utils import RequestFactory, create_test_flow, create_test_user
from authentik.enterprise.stages.source.models import SourceStage
from authentik.enterprise.stages.source.stage import SourceStageFinal
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
from authentik.flows.models import (
Flow,
FlowDesignation,
FlowStageBinding,
FlowToken,
in_memory_stage,
)
from authentik.flows.planner import (
PLAN_CONTEXT_IS_REDIRECTED,
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.policies.denied import AccessDeniedResponse
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
from authentik.sources.saml.models import SAMLSource
from authentik.sources.saml.processors.response import SAMLSourceFlowManager
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.models import PasswordStage
@@ -21,6 +51,7 @@ class TestSourceStage(FlowTestCase):
"""Source stage tests"""
def setUp(self):
self.request_factory = RequestFactory()
self.source = SAMLSource.objects.create(
slug=generate_id(),
issuer_override="authentik",
@@ -28,11 +59,169 @@ class TestSourceStage(FlowTestCase):
pre_authentication_flow=create_test_flow(),
)
def test_source_success(self):
"""Test"""
def create_source_plan(self, source, resume_on_match_failures: list[str]):
"""Create a suspended Source Stage plan for a source."""
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
stage = SourceStage.objects.create(
name=generate_id(),
source=source,
resume_on_match_failures=resume_on_match_failures,
)
binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=10)
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append(binding)
if resume_on_match_failures:
plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG] = MatchFailureConfig(
reasons=resume_on_match_failures,
source_pk=str(source.pk),
stage_pk=str(stage.pk),
)
token = FlowToken.objects.create(
expires=now() + timedelta(minutes=30),
user=get_anonymous_user(),
identifier=generate_id(),
flow=flow,
_plan=FlowToken.pickle(plan),
)
request = self.request_factory.get("/", user=AnonymousUser())
request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = token
request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)]
request.session[SESSION_KEY_SOURCE_FLOW_CONTEXT] = {
PLAN_CONTEXT_IS_REDIRECTED: flow,
}
request.session.save()
return request, token
def create_oauth_source_plan(self, resume: bool):
"""Create a suspended Source Stage plan for an OAuth source."""
source = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
provider_type="openidconnect",
authorization_url="",
profile_url="",
consumer_key="",
user_matching_mode=SourceUserMatchingModes.EMAIL_LINK,
)
reasons = [MatchFailureReason.MISSING_PROPERTY] if resume else []
request, token = self.create_source_plan(source, reasons)
return source, request, token
def test_missing_match_property_resume(self):
"""Missing OIDC properties can resume an opted-in parent flow."""
source, request, token = self.create_oauth_source_plan(resume=True)
userinfo = {"sub": generate_id()}
manager = OAuthSourceFlowManager(
source,
request,
userinfo["sub"],
{"info": userinfo},
{"oauth_userinfo": userinfo},
)
response = manager.get_flow()
self.assertEqual(response.status_code, 302)
self.assertFalse(FlowToken.objects.filter(pk=token.pk).exists())
self.assertFalse(UserOAuthSourceConnection.objects.filter(source=source).exists())
restored_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(restored_plan.context["oauth_userinfo"], userinfo)
self.assertEqual(
restored_plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE],
MatchFailure(
reason=MatchFailureReason.MISSING_PROPERTY,
property="email",
source_slug=source.slug,
),
)
self.assertNotIn(
PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG,
restored_plan.context,
)
request.session.save()
self.client.cookies[settings.SESSION_COOKIE_NAME] = request.session.session_key
with self.assertFlowFinishes():
self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": token.flow.slug})
)
session = self.client.session
self.assertNotIn(SESSION_KEY_OVERRIDE_FLOW_TOKEN, session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_STAGES, session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_CONTEXT, session)
def test_missing_match_property_default_denies(self):
"""Missing OIDC properties retain the existing behavior by default."""
source, request, token = self.create_oauth_source_plan(resume=False)
userinfo = {"sub": generate_id()}
manager = OAuthSourceFlowManager(
source,
request,
userinfo["sub"],
{"info": userinfo},
{"oauth_userinfo": userinfo},
)
response = manager.get_flow()
self.assertIsInstance(response, AccessDeniedResponse)
self.assertTrue(FlowToken.objects.filter(pk=token.pk).exists())
self.assertFalse(UserOAuthSourceConnection.objects.filter(source=source).exists())
def test_missing_match_property_expired_token_denies(self):
"""Missing OIDC properties cannot resume an expired parent flow."""
source, request, token = self.create_oauth_source_plan(resume=True)
token.expires = now() - timedelta(seconds=1)
token.save(update_fields=("expires",))
userinfo = {"sub": generate_id()}
manager = OAuthSourceFlowManager(
source,
request,
userinfo["sub"],
{"info": userinfo},
{"oauth_userinfo": userinfo},
)
response = manager.get_flow()
self.assertIsInstance(response, AccessDeniedResponse)
self.assertFalse(FlowToken.objects.filter(pk=token.pk).exists())
self.assertFalse(UserOAuthSourceConnection.objects.filter(source=source).exists())
self.assertNotIn(SESSION_KEY_OVERRIDE_FLOW_TOKEN, request.session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_STAGES, request.session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_CONTEXT, request.session)
def test_saml_missing_match_property_resume(self):
"""SAML sources use the generic matching failure resume behavior."""
request, token = self.create_source_plan(self.source, [MatchFailureReason.MISSING_PROPERTY])
manager = SAMLSourceFlowManager.__new__(SAMLSourceFlowManager)
manager.source = self.source
manager.request = request
manager.policy_context = {"saml_response": b"<Response />"}
failure = MatchFailure(
reason=MatchFailureReason.MISSING_PROPERTY,
property="email",
source_slug=self.source.slug,
)
response = manager.handle_match_failure(failure)
self.assertEqual(response.status_code, 302)
self.assertFalse(FlowToken.objects.filter(pk=token.pk).exists())
restored_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(restored_plan.context["saml_response"], b"<Response />")
self.assertEqual(restored_plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE], failure)
def run_to_source_redirect(self) -> tuple[Flow, SourceStage, FlowToken]:
"""Run a flow with a bound Source Stage up to the point where the user is sent
to the source, and return the token that can resume it."""
user = create_test_user()
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
stage = SourceStage.objects.create(name=generate_id(), source=self.source)
stage = SourceStage.objects.create(
name=generate_id(),
source=self.source,
resume_on_match_failures=[MatchFailureReason.MISSING_PROPERTY],
)
FlowStageBinding.objects.create(
target=flow,
stage=IdentificationStage.objects.create(
@@ -84,11 +273,24 @@ class TestSourceStage(FlowTestCase):
final_redirect=False,
)
# 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)
return flow, stage, flow_token
def test_source_success(self):
"""Test"""
flow, stage, flow_token = self.run_to_source_redirect()
self.assertEqual(
flow_token.plan.context[PLAN_CONTEXT_SOURCE_MATCH_FAILURE_CONFIG],
MatchFailureConfig(
reasons=[MatchFailureReason.MISSING_PROPERTY],
source_pk=str(self.source.pk),
stage_pk=str(stage.pk),
),
)
# Hijack flow plan so we don't have to emulate the source
session = self.client.session
plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
@@ -107,3 +309,25 @@ class TestSourceStage(FlowTestCase):
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertEqual(ff().context["foo"], "bar")
def test_source_expired_token(self):
"""Test returning from the source after the resume token expired"""
flow, _, flow_token = self.run_to_source_redirect()
flow_token.expires = now() - timedelta(seconds=1)
flow_token.save(update_fields=("expires",))
# Hijack flow plan so we don't have to emulate the source
session = self.client.session
plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
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})
)
self.assertStageResponse(response, component="ak-stage-access-denied")
session = self.client.session
self.assertNotIn(SESSION_KEY_OVERRIDE_FLOW_TOKEN, session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_STAGES, session)
self.assertNotIn(SESSION_KEY_SOURCE_FLOW_CONTEXT, session)

View File

@@ -10064,6 +10064,18 @@
"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)"
},
"resume_on_match_failures": {
"type": "array",
"items": {
"type": "string",
"enum": [
"missing_property"
],
"title": "Resume on match failures"
},
"title": "Resume on match failures",
"description": "Source matching failure reasons for which the flow should resume."
}
},
"required": []

View File

@@ -12,6 +12,12 @@
* Do not edit the class manually.
*/
import type { ResumeOnMatchFailuresEnum } from "./ResumeOnMatchFailuresEnum";
import {
ResumeOnMatchFailuresEnumFromJSON,
ResumeOnMatchFailuresEnumToJSON,
} from "./ResumeOnMatchFailuresEnum";
/**
* SourceStage Serializer
* @export
@@ -36,6 +42,12 @@ export interface PatchedSourceStageRequest {
* @memberof PatchedSourceStageRequest
*/
resumeTimeout?: string;
/**
* Source matching failure reasons for which the flow should resume.
* @type {Array<ResumeOnMatchFailuresEnum>}
* @memberof PatchedSourceStageRequest
*/
resumeOnMatchFailures?: Array<ResumeOnMatchFailuresEnum>;
}
/**
@@ -62,6 +74,12 @@ export function PatchedSourceStageRequestFromJSONTyped(
name: json["name"] == null ? undefined : json["name"],
source: json["source"] == null ? undefined : json["source"],
resumeTimeout: json["resume_timeout"] == null ? undefined : json["resume_timeout"],
resumeOnMatchFailures:
json["resume_on_match_failures"] == null
? undefined
: (json["resume_on_match_failures"] as Array<any>).map(
ResumeOnMatchFailuresEnumFromJSON,
),
};
}
@@ -81,5 +99,11 @@ export function PatchedSourceStageRequestToJSONTyped(
name: value["name"],
source: value["source"],
resume_timeout: value["resumeTimeout"],
resume_on_match_failures:
value["resumeOnMatchFailures"] == null
? undefined
: (value["resumeOnMatchFailures"] as Array<any>).map(
ResumeOnMatchFailuresEnumToJSON,
),
};
}

View File

@@ -0,0 +1,59 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.11.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
*/
export const ResumeOnMatchFailuresEnum = {
MissingProperty: "missing_property",
UnknownDefaultOpenApi: "11184809",
} as const;
export type ResumeOnMatchFailuresEnum =
(typeof ResumeOnMatchFailuresEnum)[keyof typeof ResumeOnMatchFailuresEnum];
export function instanceOfResumeOnMatchFailuresEnum(value: any): boolean {
for (const key in ResumeOnMatchFailuresEnum) {
if (Object.prototype.hasOwnProperty.call(ResumeOnMatchFailuresEnum, key)) {
if (
ResumeOnMatchFailuresEnum[key as keyof typeof ResumeOnMatchFailuresEnum] === value
) {
return true;
}
}
}
return false;
}
export function ResumeOnMatchFailuresEnumFromJSON(json: any): ResumeOnMatchFailuresEnum {
return ResumeOnMatchFailuresEnumFromJSONTyped(json, false);
}
export function ResumeOnMatchFailuresEnumFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): ResumeOnMatchFailuresEnum {
return json as ResumeOnMatchFailuresEnum;
}
export function ResumeOnMatchFailuresEnumToJSON(value?: ResumeOnMatchFailuresEnum | null): any {
return value as any;
}
export function ResumeOnMatchFailuresEnumToJSONTyped(
value: any,
ignoreDiscriminator: boolean,
): ResumeOnMatchFailuresEnum {
return value as ResumeOnMatchFailuresEnum;
}

View File

@@ -14,6 +14,11 @@
import type { FlowSet } from "./FlowSet";
import { FlowSetFromJSON } from "./FlowSet";
import type { ResumeOnMatchFailuresEnum } from "./ResumeOnMatchFailuresEnum";
import {
ResumeOnMatchFailuresEnumFromJSON,
ResumeOnMatchFailuresEnumToJSON,
} from "./ResumeOnMatchFailuresEnum";
/**
* SourceStage Serializer
@@ -75,6 +80,12 @@ export interface SourceStage {
* @memberof SourceStage
*/
resumeTimeout?: string;
/**
* Source matching failure reasons for which the flow should resume.
* @type {Array<ResumeOnMatchFailuresEnum>}
* @memberof SourceStage
*/
resumeOnMatchFailures?: Array<ResumeOnMatchFailuresEnum>;
}
/**
@@ -134,6 +145,12 @@ export function SourceStageFromJSONTyped(json: any, ignoreDiscriminator: boolean
flowSet: (json["flow_set"] as Array<any>).map(FlowSetFromJSON),
source: json["source"],
resumeTimeout: json["resume_timeout"] == null ? undefined : json["resume_timeout"],
resumeOnMatchFailures:
json["resume_on_match_failures"] == null
? undefined
: (json["resume_on_match_failures"] as Array<any>).map(
ResumeOnMatchFailuresEnumFromJSON,
),
};
}
@@ -156,5 +173,11 @@ export function SourceStageToJSONTyped(
name: value["name"],
source: value["source"],
resume_timeout: value["resumeTimeout"],
resume_on_match_failures:
value["resumeOnMatchFailures"] == null
? undefined
: (value["resumeOnMatchFailures"] as Array<any>).map(
ResumeOnMatchFailuresEnumToJSON,
),
};
}

View File

@@ -12,6 +12,12 @@
* Do not edit the class manually.
*/
import type { ResumeOnMatchFailuresEnum } from "./ResumeOnMatchFailuresEnum";
import {
ResumeOnMatchFailuresEnumFromJSON,
ResumeOnMatchFailuresEnumToJSON,
} from "./ResumeOnMatchFailuresEnum";
/**
* SourceStage Serializer
* @export
@@ -36,6 +42,12 @@ export interface SourceStageRequest {
* @memberof SourceStageRequest
*/
resumeTimeout?: string;
/**
* Source matching failure reasons for which the flow should resume.
* @type {Array<ResumeOnMatchFailuresEnum>}
* @memberof SourceStageRequest
*/
resumeOnMatchFailures?: Array<ResumeOnMatchFailuresEnum>;
}
/**
@@ -62,6 +74,12 @@ export function SourceStageRequestFromJSONTyped(
name: json["name"],
source: json["source"],
resumeTimeout: json["resume_timeout"] == null ? undefined : json["resume_timeout"],
resumeOnMatchFailures:
json["resume_on_match_failures"] == null
? undefined
: (json["resume_on_match_failures"] as Array<any>).map(
ResumeOnMatchFailuresEnumFromJSON,
),
};
}
@@ -81,5 +99,11 @@ export function SourceStageRequestToJSONTyped(
name: value["name"],
source: value["source"],
resume_timeout: value["resumeTimeout"],
resume_on_match_failures:
value["resumeOnMatchFailures"] == null
? undefined
: (value["resumeOnMatchFailures"] as Array<any>).map(
ResumeOnMatchFailuresEnumToJSON,
),
};
}

View File

@@ -756,6 +756,7 @@ export * from "./RequestRuleChildBindingRequest";
export * from "./RequestRuleRequest";
export * from "./RequestStatus";
export * from "./RequestableTarget";
export * from "./ResumeOnMatchFailuresEnum";
export * from "./Review";
export * from "./ReviewRequest";
export * from "./Role";

View File

@@ -53812,6 +53812,11 @@ components:
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)'
resume_on_match_failures:
type: array
items:
$ref: '#/components/schemas/ResumeOnMatchFailuresEnum'
description: Source matching failure reasons for which the flow should resume.
PatchedStaticDeviceRequest:
type: object
description: Serializer for static authenticator devices
@@ -56692,6 +56697,10 @@ components:
- pbm_uuid
- verbose_name
- verbose_name_plural
ResumeOnMatchFailuresEnum:
enum:
- missing_property
type: string
Review:
type: object
description: |-
@@ -59186,6 +59195,11 @@ components:
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)'
resume_on_match_failures:
type: array
items:
$ref: '#/components/schemas/ResumeOnMatchFailuresEnum'
description: Source matching failure reasons for which the flow should resume.
required:
- component
- flow_set
@@ -59210,6 +59224,11 @@ components:
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)'
resume_on_match_failures:
type: array
items:
$ref: '#/components/schemas/ResumeOnMatchFailuresEnum'
description: Source matching failure reasons for which the flow should resume.
required:
- name
- source

View File

@@ -1,3 +1,4 @@
import "#elements/ak-checkbox-group/ak-checkbox-group";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
import "#elements/utils/TimeDeltaHelp";
@@ -7,6 +8,7 @@ import { aki } from "#common/api/client";
import { BaseStageForm } from "#admin/stages/BaseStageForm";
import {
ResumeOnMatchFailuresEnum,
Source,
SourcesAllListRequest,
SourcesApi,
@@ -71,6 +73,32 @@ export class SourceStageForm extends BaseStageForm<SourceStage> {
>
</ak-search-select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Resume on matching failures", {
id: "stages.source.resume-on-match-failures.label",
})}
name="resumeOnMatchFailures"
>
<p class="pf-c-form__helper-text">
${msg(
"Resume this flow for the selected source matching failures. No source connection is created.",
{
id: "stages.source.resume-on-match-failures.description",
},
)}
</p>
<ak-checkbox-group
.options=${[
{
name: ResumeOnMatchFailuresEnum.MissingProperty,
label: msg("Missing property", {
id: "stages.source.match-failure.missing-property.label",
}),
},
]}
.value=${this.instance?.resumeOnMatchFailures ?? []}
></ak-checkbox-group>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Resume timeout")}
required