Repository: goauthentik/authentik

Issue:
Application Launch URL issue
**Describe the bug**
According to the docs: https://goauthentik.io/docs/applications#appearance I can set dynamic Launch URL with placeholders. This is not working for subdomains.

**To Reproduce**
Steps to reproduce the behaviour:
1. Create a new application
2. Set basic Application settings
3. Go to the UI settings -> Launch URL
4. Set Launch URL to something like: https://%(username)s.my.domain.local/some
5. See the error

**Expected behavior**
I have ability to set Application URL placeholder for subdomain part.

**Version and Deployment (please complete the following information):**
 - authentik version: 2023.2.2
 - Deployment: helm

**Additional context**
Add any other context about the problem here.



Discussion:
It seems like we need to patch it here: https://github.com/goauthentik/authentik/blob/20e971f5ce166fba837c21907f66f6aa5c4d7690/authentik/lib/models.py#L59

Resolving patch:
diff --git a/authentik/core/migrations/0018_auto_20210330_1345_squashed_0028_alter_token_intent.py b/authentik/core/migrations/0018_auto_20210330_1345_squashed_0028_alter_token_intent.py
--- a/authentik/core/migrations/0018_auto_20210330_1345_squashed_0028_alter_token_intent.py
+++ b/authentik/core/migrations/0018_auto_20210330_1345_squashed_0028_alter_token_intent.py
@@ -182,7 +182,9 @@ class Migration(migrations.Migration):
             model_name="application",
             name="meta_launch_url",
             field=models.TextField(
-                blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()]
+                blank=True,
+                default="",
+                validators=[authentik.lib.models.DomainlessFormattedURLValidator()],
             ),
         ),
         migrations.RunPython(
diff --git a/authentik/core/models.py b/authentik/core/models.py
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -26,7 +26,11 @@
 from authentik.lib.avatars import get_avatar
 from authentik.lib.config import CONFIG
 from authentik.lib.generators import generate_id
-from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
+from authentik.lib.models import (
+    CreatedUpdatedModel,
+    DomainlessFormattedURLValidator,
+    SerializerModel,
+)
 from authentik.lib.utils.http import get_client_ip
 from authentik.policies.models import PolicyBindingModel
 
@@ -291,7 +295,7 @@ class Application(SerializerModel, PolicyBindingModel):
     )
 
     meta_launch_url = models.TextField(
-        default="", blank=True, validators=[DomainlessURLValidator()]
+        default="", blank=True, validators=[DomainlessFormattedURLValidator()]
     )
 
     open_in_new_tab = models.BooleanField(
diff --git a/authentik/lib/models.py b/authentik/lib/models.py
--- a/authentik/lib/models.py
+++ b/authentik/lib/models.py
@@ -74,3 +74,21 @@ def __call__(self, value: str):
         if scheme not in self.schemes:
             value = "default" + value
         super().__call__(value)
+
+
+class DomainlessFormattedURLValidator(DomainlessURLValidator):
+    """URL validator which allows for python format strings"""
+
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+        self.host_re = r"([%\(\)a-zA-Z])+" + self.domain_re + self.domain_re
+        self.regex = _lazy_re_compile(
+            r"^(?:[a-z0-9.+-]*)://"  # scheme is validated separately
+            r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?"  # user:pass authentication
+            r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")"
+            r"(?::\d{2,5})?"  # port
+            r"(?:[/?#][^\s]*)?"  # resource path
+            r"\Z",
+            re.IGNORECASE,
+        )
+        self.schemes = ["http", "https", "blank"] + list(self.schemes)


Test patch:
diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py
--- a/authentik/core/tests/test_applications_api.py
+++ b/authentik/core/tests/test_applications_api.py
@@ -37,6 +37,22 @@ def setUp(self) -> None:
             order=0,
         )
 
+    def test_formatted_launch_url(self):
+        """Test formatted launch URL"""
+        self.client.force_login(self.user)
+        self.assertEqual(
+            self.client.patch(
+                reverse("authentik_api:application-detail", kwargs={"slug": self.allowed.slug}),
+                {"meta_launch_url": "https://%(username)s.test.goauthentik.io/%(username)s"},
+            ).status_code,
+            200,
+        )
+        self.allowed.refresh_from_db()
+        self.assertEqual(
+            self.allowed.get_launch_url(self.user),
+            f"https://{self.user.username}.test.goauthentik.io/{self.user.username}",
+        )
+
     def test_set_icon(self):
         """Test set_icon"""
         file = ContentFile(b"text", "name")