Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions authentik/tenants/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def backfill_base_url(self):
"""Backfill base_url when it hasn't been set yet. Sources: AUTHENTIK_WEB__BASE_URL config
value, then the embedded outpost's configured host. When neither is available, warn that
the base URL must be set before it becomes required in a future release."""
from django.core.exceptions import ValidationError

from authentik.events.models import Event
from authentik.outposts.models import Outpost
from authentik.tenants.models import Tenant
Expand All @@ -56,6 +58,14 @@ def backfill_base_url(self):
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
if outpost:
base_url = normalize_base_url(outpost.config.authentik_host)
if base_url:
try:
Tenant._meta.get_field("base_url").run_validators(base_url)
except ValidationError:
self.logger.warning(
"Discarding invalid base_url", base_url=base_url, tenant=tenant.schema_name
)
base_url = ""
if not base_url: # No source available
if Setup.get(tenant=tenant): # Only nag instances that have finished setup
self.logger.warning("Base URL is not configured", tenant=tenant.schema_name)
Expand Down
30 changes: 30 additions & 0 deletions authentik/tenants/migrations/0009_alter_tenant_base_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.2.17 on 2026-08-28 14:55

import authentik.lib.models
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_tenants", "0008_tenant_base_url"),
]

operations = [
migrations.AlterField(
model_name="tenant",
name="base_url",
field=models.CharField(
blank=True,
default="",
help_text="Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company",
max_length=200,
validators=[
authentik.lib.models.DomainlessURLValidator(
message="Enter a valid URL, for example https://authentik.company",
schemes=("http", "https"),
)
],
),
),
]
15 changes: 13 additions & 2 deletions authentik/tenants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
from structlog.stdlib import get_logger

from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import (
DomainlessURLValidator,
InternallyManagedMixin,
SerializerModel,
)
from authentik.lib.utils.time import timedelta_string_validator

LOGGER = get_logger()
Expand Down Expand Up @@ -58,9 +62,16 @@ class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel):
help_text=_("Configure how authentik should show avatars for users."),
default="gravatar,initials",
)
base_url = models.URLField(
base_url = models.CharField(
max_length=200,
default="",
blank=True,
validators=[
DomainlessURLValidator(
schemes=("http", "https"),
message=_("Enter a valid URL, for example https://authentik.company"),
)
],
help_text=_(
"Configure the base URL under which this authentik instance is "
"reachable, e.g. https://authentik.company"
Expand Down
15 changes: 15 additions & 0 deletions authentik/tenants/tests/test_base_url_backfill.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ def test_backfill_normalizes_trailing_slash(self):
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.base_url, "https://outpost.example.com")

@patch_flag(Setup, True)
@reconcile_app("authentik_outposts")
def test_backfill_discards_invalid_outpost_host(self):
"""An outpost host the settings API would reject is discarded rather than written,
since the backfill's `.update()` skips field validation"""
outpost = Outpost.objects.get(managed=MANAGED_OUTPOST)
outpost.config = OutpostConfig(authentik_host="outpost.example.com")
outpost.save()
with capture_logs() as logs:
apps.get_app_config("authentik_tenants").backfill_base_url()
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.base_url, "")
self.assertTrue(any("Discarding invalid base_url" in log.event for log in logs))
self.assertTrue(any("Base URL is not configured" in log.event for log in logs))

def test_backfill_no_outpost(self):
"""With no embedded outpost (e.g. disable_embedded_outpost) and no config value,
base_url is left empty and the backfill does not error"""
Expand Down
37 changes: 37 additions & 0 deletions authentik/tenants/tests/test_base_url_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ def test_settings_rejects_invalid(self):
)
self.assertEqual(response.status_code, 400)

def test_settings_accepts_internal_hostname(self):
"""A hostname without a public-suffix shaped last label is accepted."""
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={"base_url": "https://auth.svr001"},
)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.base_url, "https://auth.svr001")

def test_settings_saves_with_internal_hostname_stored(self):
"""An unrelated setting can still be saved."""
self.tenant.base_url = "https://auth.svr001"
self.tenant.save()
current = self.client.get(reverse("authentik_api:tenant_settings")).json()
response = self.client.put(
reverse("authentik_api:tenant_settings"),
data={**current, "avatars": "initials"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.avatars, "initials")
self.assertEqual(self.tenant.base_url, "https://auth.svr001")

def test_settings_accepts_empty(self):
"""The field can be cleared, which means no base URL is configured"""
self.tenant.base_url = "https://auth.svr001"
self.tenant.save()
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={"base_url": ""},
)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.base_url, "")

def test_settings_normalizes_trailing_slash(self):
"""A trailing slash is stripped when saving through the settings API"""
response = self.client.patch(
Expand Down
49 changes: 49 additions & 0 deletions authentik/tenants/tests/test_validate_base_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Tests for the base_url field validators"""

from django.core.exceptions import ValidationError
from django.test import SimpleTestCase

from authentik.tenants.models import Tenant


class TestValidateBaseURL(SimpleTestCase):
"""base_url accepts an http or https URL whose host needs no domain part"""

def test_validate(self):
field = Tenant._meta.get_field("base_url")
cases = {
"https://authentik.company": True,
"http://authentik.company": True,
"HTTPS://authentik.company": True,
"https://authentik.company/authentik": True,
# Hostnames Django's own URLValidator rejects
"https://auth.svr001": True,
"https://auth": True,
"https://auth.s1": True,
"http://localhost:9000": True,
"https://192.168.1.5:9443": True,
"https://[fd00::1]:9443": True,
# Not a URL at all.
"authentik.company": False,
"//authentik.company": False,
"not a url": False,
"https://": False,
"http://": False,
# Only http and https.
"ftp://authentik.company": False,
"javascript:alert(1)": False,
# A host that is not a host.
"http:///nohost": False,
"https://.": False,
"https://auth svr001": False,
"https://my_host.example.com": False,
"https://auth.svr001\nBcc: someone@example.com": False,
"https://auth.svr001\tfoo": False,
}
for value, valid in cases.items():
with self.subTest(value=value):
if valid:
field.run_validators(value)
continue
with self.assertRaises(ValidationError):
field.run_validators(value)
13 changes: 6 additions & 7 deletions blueprints/default/flow-oobe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,17 @@ entries:
model: authentik_policies_expression.expressionpolicy
- attrs:
expression: |
# Validate the base URL entered during setup
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from authentik.tenants.models import Tenant
from authentik.tenants.utils import normalize_base_url

base_url = ((request.context.get("prompt_data") or {}).get("base_url") or "").strip()
# Empty is handled by the field's own `required` flag; only check non-empty values.
base_url = normalize_base_url((request.context.get("prompt_data") or {}).get("base_url"))
if not base_url:
return True
try:
URLValidator()(base_url)
except ValidationError:
ak_message("Enter a valid URL, for example https://authentik.company")
Tenant._meta.get_field("base_url").run_validators(base_url)
except ValidationError as exc:
ak_message(exc.messages[0])
return False
return True
id: policy-default-oobe-base-url-valid
Expand Down
6 changes: 3 additions & 3 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53741,9 +53741,9 @@ components:
description: Configure how authentik should show avatars for users.
base_url:
type: string
format: uri
description: Configure the base URL under which this authentik instance
is reachable, e.g. https://authentik.company
format: uri
maxLength: 200
default_user_change_name:
type: boolean
Expand Down Expand Up @@ -58833,9 +58833,9 @@ components:
description: Configure how authentik should show avatars for users.
base_url:
type: string
format: uri
description: Configure the base URL under which this authentik instance
is reachable, e.g. https://authentik.company
format: uri
maxLength: 200
default_user_change_name:
type: boolean
Expand Down Expand Up @@ -58921,9 +58921,9 @@ components:
description: Configure how authentik should show avatars for users.
base_url:
type: string
format: uri
description: Configure the base URL under which this authentik instance
is reachable, e.g. https://authentik.company
format: uri
maxLength: 200
default_user_change_name:
type: boolean
Expand Down
Loading