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 index 9276628ec..0b276a79c 100644 --- 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 index c56236e93..ab2434e5b 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -26,7 +26,11 @@ from authentik.core.types import UILoginButton, UserSettingSerializer 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/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py index 4b4326f67..5c485ea68 100644 --- a/authentik/core/tests/test_applications_api.py +++ b/authentik/core/tests/test_applications_api.py @@ -37,6 +37,22 @@ class TestApplicationsAPI(APITestCase): 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") diff --git a/authentik/lib/models.py b/authentik/lib/models.py index 3d9e67442..24054d9e2 100644 --- a/authentik/lib/models.py +++ b/authentik/lib/models.py @@ -74,3 +74,21 @@ class DomainlessURLValidator(URLValidator): 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)