mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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 commit6b18fb6f10. * Reapply "rework to exception" This reverts commit6f6ae67f99. * actually nah fix it Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Revert "actually nah fix it" This reverts commita8e1d0e83d. * Revert "Reapply "rework to exception"" This reverts commit70d001a255. --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
59
packages/client-ts/src/models/ResumeOnMatchFailuresEnum.ts
generated
Normal file
59
packages/client-ts/src/models/ResumeOnMatchFailuresEnum.ts
generated
Normal 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;
|
||||
}
|
||||
23
packages/client-ts/src/models/SourceStage.ts
generated
23
packages/client-ts/src/models/SourceStage.ts
generated
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
24
packages/client-ts/src/models/SourceStageRequest.ts
generated
24
packages/client-ts/src/models/SourceStageRequest.ts
generated
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -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";
|
||||
|
||||
19
schema.yml
19
schema.yml
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user