Repository: goauthentik/authentik

Issue:
Add dynamic dropdowns to Promt Stage
I have written a custom Promt Stage which reminds me if i forgot to enable my time tracking on login. One feature missing is to select the Project i am working on.
It would be awesome if the promt stage would have a additional dropdown type which can be filled dynamicly (for example with the context dict). 


Discussion:

Resolving patch:
diff --git a/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
new file mode 100644
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
@@ -0,0 +1,65 @@
+# Generated by Django 4.1.7 on 2023-03-03 17:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("authentik_stages_prompt", "0009_prompt_name"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="prompt",
+            name="placeholder",
+            field=models.TextField(
+                blank=True,
+                help_text="When creating a Radio Button Group or Dropdown, enable interpreting as expression and return a list to return multiple choices.",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="prompt",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("text", "Text: Simple Text input"),
+                    ("text_area", "Text area: Multiline Text Input."),
+                    (
+                        "text_read_only",
+                        "Text (read-only): Simple Text input, but cannot be edited.",
+                    ),
+                    (
+                        "text_area_read_only",
+                        "Text area (read-only): Multiline Text input, but cannot be edited.",
+                    ),
+                    (
+                        "username",
+                        "Username: Same as Text input, but checks for and prevents duplicate usernames.",
+                    ),
+                    ("email", "Email: Text field with Email type."),
+                    (
+                        "password",
+                        "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
+                    ),
+                    ("number", "Number"),
+                    ("checkbox", "Checkbox"),
+                    (
+                        "radio-button-group",
+                        "Fixed choice field rendered as a group of radio buttons.",
+                    ),
+                    ("dropdown", "Fixed choice field rendered as a dropdown."),
+                    ("date", "Date"),
+                    ("date-time", "Date Time"),
+                    (
+                        "file",
+                        "File: File upload for arbitrary files. File content will be available in flow context as data-URI",
+                    ),
+                    ("separator", "Separator: Static Separator Line"),
+                    ("hidden", "Hidden: Hidden field, can be used to insert data into form."),
+                    ("static", "Static: Static value, displayed as-is."),
+                    ("ak-locale", "authentik: Selection of locales authentik supports"),
+                ],
+                max_length=100,
+            ),
+        ),
+    ]
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
--- a/authentik/stages/prompt/models.py
+++ b/authentik/stages/prompt/models.py
@@ -11,6 +11,7 @@
 from rest_framework.fields import (
     BooleanField,
     CharField,
+    ChoiceField,
     DateField,
     DateTimeField,
     EmailField,
@@ -38,10 +39,17 @@ class FieldTypes(models.TextChoices):
 
     # Simple text field
     TEXT = "text", _("Text: Simple Text input")
+    # Long text field
+    TEXT_AREA = "text_area", _("Text area: Multiline Text Input.")
     # Simple text field
     TEXT_READ_ONLY = "text_read_only", _(
         "Text (read-only): Simple Text input, but cannot be edited."
     )
+    # Long text field
+    TEXT_AREA_READ_ONLY = "text_area_read_only", _(
+        "Text area (read-only): Multiline Text input, but cannot be edited."
+    )
+
     # Same as text, but has autocomplete for password managers
     USERNAME = (
         "username",
@@ -58,6 +66,10 @@ class FieldTypes(models.TextChoices):
     )
     NUMBER = "number"
     CHECKBOX = "checkbox"
+    RADIO_BUTTON_GROUP = "radio-button-group", _(
+        "Fixed choice field rendered as a group of radio buttons."
+    )
+    DROPDOWN = "dropdown", _("Fixed choice field rendered as a dropdown.")
     DATE = "date"
     DATE_TIME = "date-time"
 
@@ -76,6 +88,9 @@ class FieldTypes(models.TextChoices):
     AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
 
 
+CHOICE_FIELDS = (FieldTypes.RADIO_BUTTON_GROUP, FieldTypes.DROPDOWN)
+
+
 class InlineFileField(CharField):
     """Field for inline data-URI base64 encoded files"""
 
@@ -102,7 +117,13 @@ class Prompt(SerializerModel):
     label = models.TextField()
     type = models.CharField(max_length=100, choices=FieldTypes.choices)
     required = models.BooleanField(default=True)
-    placeholder = models.TextField(blank=True)
+    placeholder = models.TextField(
+        blank=True,
+        help_text=_(
+            "When creating a Radio Button Group or Dropdown, enable interpreting as "
+            "expression and return a list to return multiple choices."
+        ),
+    )
     sub_text = models.TextField(blank=True, default="")
 
     order = models.IntegerField(default=0)
@@ -115,8 +136,46 @@ def serializer(self) -> Type[BaseSerializer]:
 
         return PromptSerializer
 
+    def get_choices(
+        self, prompt_context: dict, user: User, request: HttpRequest
+    ) -> Optional[tuple[dict[str, Any]]]:
+        """Get fully interpolated list of choices"""
+        if self.type not in CHOICE_FIELDS:
+            return None
+
+        raw_choices = self.placeholder
+
+        if self.field_key in prompt_context:
+            raw_choices = prompt_context[self.field_key]
+        elif self.placeholder_expression:
+            evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context)
+            try:
+                raw_choices = evaluator.evaluate(self.placeholder)
+            except Exception as exc:  # pylint:disable=broad-except
+                LOGGER.warning(
+                    "failed to evaluate prompt choices",
+                    exc=PropertyMappingExpressionException(str(exc)),
+                )
+
+        if isinstance(raw_choices, (list, tuple, set)):
+            choices = raw_choices
+        else:
+            choices = [raw_choices]
+
+        if len(choices) == 0:
+            LOGGER.warning("failed to get prompt choices", choices=choices, input=raw_choices)
+
+        return tuple(choices)
+
     def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str:
         """Get fully interpolated placeholder"""
+        if self.type in CHOICE_FIELDS:
+            # Make sure to return a valid choice as placeholder
+            choices = self.get_choices(prompt_context, user, request)
+            if not choices:
+                return ""
+            return choices[0]
+
         if self.field_key in prompt_context:
             # We don't want to parse this as an expression since a user will
             # be able to control the input
@@ -133,16 +192,16 @@ def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest
                 )
         return self.placeholder
 
-    def field(self, default: Optional[Any]) -> CharField:
-        """Get field type for Challenge and response"""
+    def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
+        """Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
         field_class = CharField
         kwargs = {
             "required": self.required,
         }
-        if self.type == FieldTypes.TEXT:
+        if self.type in (FieldTypes.TEXT, FieldTypes.TEXT_AREA):
             kwargs["trim_whitespace"] = False
             kwargs["allow_blank"] = not self.required
-        if self.type == FieldTypes.TEXT_READ_ONLY:
+        if self.type in (FieldTypes.TEXT_READ_ONLY, FieldTypes.TEXT_AREA_READ_ONLY):
             field_class = ReadOnlyField
             # required can't be set for ReadOnlyField
             kwargs["required"] = False
@@ -154,13 +213,15 @@ def field(self, default: Optional[Any]) -> CharField:
         if self.type == FieldTypes.CHECKBOX:
             field_class = BooleanField
             kwargs["required"] = False
+        if self.type in CHOICE_FIELDS:
+            field_class = ChoiceField
+            kwargs["choices"] = choices or []
         if self.type == FieldTypes.DATE:
             field_class = DateField
         if self.type == FieldTypes.DATE_TIME:
             field_class = DateTimeField
         if self.type == FieldTypes.FILE:
             field_class = InlineFileField
-
         if self.type == FieldTypes.SEPARATOR:
             kwargs["required"] = False
             kwargs["label"] = ""
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -7,7 +7,14 @@
 from django.http import HttpRequest, HttpResponse
 from django.http.request import QueryDict
 from django.utils.translation import gettext_lazy as _
-from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, empty
+from rest_framework.fields import (
+    BooleanField,
+    CharField,
+    ChoiceField,
+    IntegerField,
+    ListField,
+    empty,
+)
 from rest_framework.serializers import ValidationError
 
 from authentik.core.api.utils import PassiveSerializer
@@ -33,6 +40,7 @@ class StagePromptSerializer(PassiveSerializer):
     placeholder = CharField(allow_blank=True)
     order = IntegerField()
     sub_text = CharField(allow_blank=True)
+    choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True)
 
 
 class PromptChallenge(Challenge):
@@ -65,10 +73,13 @@ def __init__(self, *args, **kwargs):
         fields = list(self.stage_instance.fields.all())
         for field in fields:
             field: Prompt
+            choices = field.get_choices(
+                plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
+            )
             current = field.get_placeholder(
                 plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
             )
-            self.fields[field.field_key] = field.field(current)
+            self.fields[field.field_key] = field.field(current, choices)
             # Special handling for fields with username type
             # these check for existing users with the same username
             if field.type == FieldTypes.USERNAME:
@@ -99,7 +110,12 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
         # Check if we have any static or hidden fields, and ensure they
         # still have the same value
         static_hidden_fields: QuerySet[Prompt] = self.stage_instance.fields.filter(
-            type__in=[FieldTypes.HIDDEN, FieldTypes.STATIC, FieldTypes.TEXT_READ_ONLY]
+            type__in=[
+                FieldTypes.HIDDEN,
+                FieldTypes.STATIC,
+                FieldTypes.TEXT_READ_ONLY,
+                FieldTypes.TEXT_AREA_READ_ONLY,
+            ]
         )
         for static_hidden in static_hidden_fields:
             field = self.fields[static_hidden.field_key]
@@ -180,8 +196,15 @@ def get_challenge(self, *args, **kwargs) -> Challenge:
         context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
         for field in fields:
             data = StagePromptSerializer(field).data
-            data["placeholder"] = field.get_placeholder(
-                context_prompt, self.get_pending_user(), self.request
+            # Ensure all choices and placeholders are str, as otherwise further in
+            # we can fail serializer validation if we return some types such as bool
+            choices = field.get_choices(context_prompt, self.get_pending_user(), self.request)
+            if choices:
+                data["choices"] = [str(choice) for choice in choices]
+            else:
+                data["choices"] = None
+            data["placeholder"] = str(
+                field.get_placeholder(context_prompt, self.get_pending_user(), self.request)
             )
             serializers.append(data)
         challenge = PromptChallenge(


Test patch:
diff --git a/authentik/policies/password/tests/test_flows.py b/authentik/policies/password/tests/test_flows.py
--- a/authentik/policies/password/tests/test_flows.py
+++ b/authentik/policies/password/tests/test_flows.py
@@ -54,6 +54,7 @@ def test_prompt_data(self):
             component="ak-stage-prompt",
             fields=[
                 {
+                    "choices": None,
                     "field_key": "password",
                     "label": "PASSWORD_LABEL",
                     "order": 0,
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -45,6 +45,14 @@ def setUp(self):
             required=True,
             placeholder="TEXT_PLACEHOLDER",
         )
+        text_area_prompt = Prompt.objects.create(
+            name=generate_id(),
+            field_key="text_area_prompt",
+            label="TEXT_AREA_LABEL",
+            type=FieldTypes.TEXT_AREA,
+            required=True,
+            placeholder="TEXT_AREA_PLACEHOLDER",
+        )
         email_prompt = Prompt.objects.create(
             name=generate_id(),
             field_key="email_prompt",
@@ -91,6 +99,19 @@ def setUp(self):
             required=True,
             placeholder="static",
         )
+        radio_button_group = Prompt.objects.create(
+            name=generate_id(),
+            field_key="radio_button_group",
+            type=FieldTypes.RADIO_BUTTON_GROUP,
+            required=True,
+            placeholder="test",
+        )
+        dropdown = Prompt.objects.create(
+            name=generate_id(),
+            field_key="dropdown",
+            type=FieldTypes.DROPDOWN,
+            required=True,
+        )
         self.stage = PromptStage.objects.create(name="prompt-stage")
         self.stage.fields.set(
             [
@@ -102,18 +123,23 @@ def setUp(self):
                 number_prompt,
                 hidden_prompt,
                 static_prompt,
+                radio_button_group,
+                dropdown,
             ]
         )
 
         self.prompt_data = {
             username_prompt.field_key: "test-username",
             text_prompt.field_key: "test-input",
+            text_area_prompt.field_key: "test-area-input",
             email_prompt.field_key: "test@test.test",
             password_prompt.field_key: "test",
             password2_prompt.field_key: "test",
             number_prompt.field_key: 3,
             hidden_prompt.field_key: hidden_prompt.placeholder,
             static_prompt.field_key: static_prompt.placeholder,
+            radio_button_group.field_key: radio_button_group.placeholder,
+            dropdown.field_key: "",
         }
 
         self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
@@ -251,6 +277,34 @@ def test_invalid_username(self):
             {"username_prompt": [ErrorDetail(string="Username is already taken.", code="invalid")]},
         )
 
+    def test_invalid_choice_field(self):
+        """Test invalid choice field value"""
+        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+        self.prompt_data["radio_button_group"] = "some invalid choice"
+        self.prompt_data["dropdown"] = "another invalid choice"
+        challenge_response = PromptChallengeResponse(
+            None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
+        )
+        self.assertEqual(challenge_response.is_valid(), False)
+        self.assertEqual(
+            challenge_response.errors,
+            {
+                "radio_button_group": [
+                    ErrorDetail(
+                        string=f"\"{self.prompt_data['radio_button_group']}\" "
+                        "is not a valid choice.",
+                        code="invalid_choice",
+                    )
+                ],
+                "dropdown": [
+                    ErrorDetail(
+                        string=f"\"{self.prompt_data['dropdown']}\" is not a valid choice.",
+                        code="invalid_choice",
+                    )
+                ],
+            },
+        )
+
     def test_static_hidden_overwrite(self):
         """Test that static and hidden fields ignore any value sent to them"""
         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
@@ -289,6 +343,113 @@ def test_prompt_placeholder(self):
             prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
         )
 
+    def test_choice_prompts_placeholders(self):
+        """Test placeholders and expression of choice fields"""
+        context = {"foo": generate_id()}
+
+        # No choices - unusable (in the sense it creates an unsubmittable form)
+        # but valid behaviour
+        prompt: Prompt = Prompt(
+            field_key="fixed_choice_prompt_expression",
+            label="LABEL",
+            type=FieldTypes.RADIO_BUTTON_GROUP,
+            placeholder="return []",
+            placeholder_expression=True,
+        )
+        self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
+        self.assertEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
+        context["fixed_choice_prompt_expression"] = generate_id()
+        self.assertEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")),
+            context["fixed_choice_prompt_expression"],
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            (context["fixed_choice_prompt_expression"],),
+        )
+        self.assertNotEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
+        self.assertNotEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
+
+        del context["fixed_choice_prompt_expression"]
+
+        # Single choice
+        prompt: Prompt = Prompt(
+            field_key="fixed_choice_prompt_expression",
+            label="LABEL",
+            type=FieldTypes.RADIO_BUTTON_GROUP,
+            placeholder="return prompt_context['foo']",
+            placeholder_expression=True,
+        )
+        self.assertEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
+        )
+        context["fixed_choice_prompt_expression"] = generate_id()
+        self.assertEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")),
+            context["fixed_choice_prompt_expression"],
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            (context["fixed_choice_prompt_expression"],),
+        )
+        self.assertNotEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+        )
+        self.assertNotEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
+        )
+
+        del context["fixed_choice_prompt_expression"]
+
+        # Multi choice
+        prompt: Prompt = Prompt(
+            field_key="fixed_choice_prompt_expression",
+            label="LABEL",
+            type=FieldTypes.DROPDOWN,
+            placeholder="return [prompt_context['foo'], True, 'text']",
+            placeholder_expression=True,
+        )
+        self.assertEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            (context["foo"], True, "text"),
+        )
+        context["fixed_choice_prompt_expression"] = tuple(["text", generate_id(), 2])
+        self.assertEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")),
+            "text",
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            context["fixed_choice_prompt_expression"],
+        )
+        self.assertNotEqual(
+            prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+        )
+        self.assertNotEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            (context["foo"], True, "text"),
+        )
+
+    def test_choices_are_none_for_non_choice_fields(self):
+        """Test choices are None for non choice fields"""
+        context = {}
+        prompt: Prompt = Prompt(
+            field_key="text_prompt_expression",
+            label="TEXT_LABEL",
+            type=FieldTypes.TEXT,
+            placeholder="choice",
+        )
+        self.assertEqual(
+            prompt.get_choices(context, self.user, self.factory.get("/")),
+            None,
+        )
+
     def test_prompt_placeholder_error(self):
         """Test placeholder and expression"""
         context = {}