From 20072146febed5d49842abd3c5199f30dfebab9d Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin Date: Fri, 7 Aug 2026 18:06:40 +0300 Subject: [PATCH 1/4] events: use absolute urls (constructed using base_url from the tenant) when sending notifications outside (e.g. via email) --- authentik/events/models.py | 16 ++- authentik/events/tests/test_transports.py | 117 ++++++++++++++++++ .../tenants/tests/test_build_absolute_url.py | 45 +++++++ authentik/tenants/utils.py | 17 +++ .../configuration/configuration.mdx | 2 - website/docs/sys-mgmt/events/transports.md | 12 ++ website/docs/sys-mgmt/settings.md | 2 +- 7 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 authentik/tenants/tests/test_build_absolute_url.py diff --git a/authentik/events/models.py b/authentik/events/models.py index 7b3df96d4316..4010610a2921 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -55,7 +55,7 @@ from authentik.stages.email.utils import TemplateEmailMessage from authentik.tasks.models import TasksModel from authentik.tenants.models import Tenant -from authentik.tenants.utils import get_current_tenant +from authentik.tenants.utils import build_absolute_url, get_current_tenant LOGGER = get_logger() DISCORD_FIELD_LIMIT = 25 @@ -517,6 +517,8 @@ def send_webhook_slack(self, notification: Notification) -> list[str]: # https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html if len(fields) >= DISCORD_FIELD_LIMIT: continue + if key == "hyperlink": + value = build_absolute_url(value) # noqa: PLW2901 fields.append({"title": key[:256], "value": value[:1024]}) body = { "username": "authentik", @@ -594,7 +596,7 @@ def send_email(self, notification: Notification) -> list[str]: ) if notification.hyperlink: context["link"] = { - "target": notification.hyperlink, + "target": notification.hyperlink_absolute, "label": notification.hyperlink_label, } if notification.event: @@ -602,6 +604,8 @@ def send_email(self, notification: Notification) -> list[str]: for key, value in notification.event.context.items(): if not isinstance(value, str): continue + if key == "hyperlink": + value = build_absolute_url(value) # noqa: PLW2901 context["key_value"][key] = value else: context["title"] += notification.body[:NOTIFICATION_SUMMARY_LENGTH] @@ -657,6 +661,14 @@ class Notification(SerializerModel): seen = models.BooleanField(default=False) user = models.ForeignKey(User, on_delete=models.CASCADE) + @property + def hyperlink_absolute(self) -> str | None: + """The hyperlink resolved against the tenant's configured base URL, for use + outside of the authentik UI where relative URLs do not resolve""" + if not self.hyperlink: + return self.hyperlink + return build_absolute_url(self.hyperlink) + @property def serializer(self) -> type[Serializer]: from authentik.events.api.notifications import NotificationSerializer diff --git a/authentik/events/tests/test_transports.py b/authentik/events/tests/test_transports.py index eb1d7e2c725d..cd8c3d2aa250 100644 --- a/authentik/events/tests/test_transports.py +++ b/authentik/events/tests/test_transports.py @@ -1,5 +1,6 @@ """transport tests""" +from json import loads from unittest.mock import PropertyMock, patch from django.core import mail @@ -22,6 +23,7 @@ ) from authentik.lib.generators import generate_id from authentik.stages.email.models import get_template_choices +from authentik.tenants.utils import get_current_tenant class TestEventTransports(TestCase): @@ -38,6 +40,25 @@ def setUp(self) -> None: user=self.user, ) + def set_base_url(self, value: str): + tenant = get_current_tenant() + tenant.base_url = value + tenant.save() + + def notification_with_hyperlink(self, hyperlink: str) -> Notification: + event = Event.new("foo", "testing", hyperlink=hyperlink, hyperlink_label="Open").set_user( + self.user + ) + event.save() + return Notification.objects.create( + severity=NotificationSeverity.ALERT, + body="foo", + event=event, + user=self.user, + hyperlink=event.hyperlink, + hyperlink_label=event.hyperlink_label, + ) + def test_transport_webhook(self): """Test webhook transport""" transport: NotificationTransport = NotificationTransport.objects.create( @@ -230,6 +251,102 @@ def test_transport_email_custom_subject_prefix(self): self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, "[CUSTOM] custom_foo") + def test_transport_email_relative_hyperlink(self): + """Test email transport resolving a relative hyperlink against the base URL""" + self.set_base_url("https://authentik.company") + notification = self.notification_with_hyperlink("/if/admin/#/foo") + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.EMAIL, + ) + with patch( + "authentik.stages.email.models.EmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + transport.send(notification) + self.assertEqual(len(mail.outbox), 1) + html = mail.outbox[0].alternatives[0][0] + self.assertIn('href="https://authentik.company/if/admin/#/foo"', html) + self.assertNotIn('href="/if/admin/#/foo"', html) + self.assertIn("https://authentik.company/if/admin/#/foo", mail.outbox[0].body) + self.assertEqual(notification.hyperlink, "/if/admin/#/foo") + + def test_transport_email_relative_hyperlink_no_base_url(self): + """Test email transport with a relative hyperlink and no base URL configured""" + self.set_base_url("") + notification = self.notification_with_hyperlink("/if/admin/#/foo") + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.EMAIL, + ) + with patch( + "authentik.stages.email.models.EmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + transport.send(notification) + self.assertEqual(len(mail.outbox), 1) + self.assertIn('href="/if/admin/#/foo"', mail.outbox[0].alternatives[0][0]) + + def test_transport_email_absolute_hyperlink(self): + """Test email transport with an already absolute hyperlink""" + self.set_base_url("https://authentik.company") + notification = self.notification_with_hyperlink("https://files.example.com/export.csv") + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.EMAIL, + ) + with patch( + "authentik.stages.email.models.EmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + transport.send(notification) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + 'href="https://files.example.com/export.csv"', mail.outbox[0].alternatives[0][0] + ) + + def test_transport_webhook_slack_relative_hyperlink(self): + """Test slack webhook transport resolving a relative hyperlink from the event context""" + self.set_base_url("https://authentik.company") + notification = self.notification_with_hyperlink("/if/admin/#/foo") + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.WEBHOOK_SLACK, + webhook_url="http://localhost:1234/test", + ) + with Mocker() as mocker: + mocker.post("http://localhost:1234/test") + transport.send(notification) + self.assertEqual(mocker.call_count, 1) + fields = loads(mocker.request_history[0].body)["attachments"][0]["fields"] + self.assertIn( + {"title": "hyperlink", "value": "https://authentik.company/if/admin/#/foo"}, + fields, + ) + + def test_transport_webhook_mapping_hyperlink(self): + """Test webhook transport mappings accessing the absolute hyperlink""" + self.set_base_url("https://authentik.company") + notification = self.notification_with_hyperlink("/if/admin/#/foo") + mapping_body = NotificationWebhookMapping.objects.create( + name=generate_id(), expression="""return {"link": notification.hyperlink_absolute}""" + ) + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.WEBHOOK, + webhook_url="http://localhost:1234/test", + webhook_mapping_body=mapping_body, + ) + with Mocker() as mocker: + mocker.post("http://localhost:1234/test") + transport.send(notification) + self.assertEqual(mocker.call_count, 1) + self.assertJSONEqual( + mocker.request_history[0].body.decode(), + {"link": "https://authentik.company/if/admin/#/foo"}, + ) + self.assertEqual(notification.hyperlink, "/if/admin/#/foo") + def test_transport_email_validation(self): """Test email transport template validation""" diff --git a/authentik/tenants/tests/test_build_absolute_url.py b/authentik/tenants/tests/test_build_absolute_url.py new file mode 100644 index 000000000000..47251e119c64 --- /dev/null +++ b/authentik/tenants/tests/test_build_absolute_url.py @@ -0,0 +1,45 @@ +"""Tests for the build_absolute_url helper""" + +from django.test import TestCase + +from authentik.tenants.utils import build_absolute_url, get_current_tenant + + +class TestBuildAbsoluteURL(TestCase): + """build_absolute_url resolves relative URLs against the tenant's base URL""" + + def set_base_url(self, value: str): + tenant = get_current_tenant() + tenant.base_url = value + tenant.save() + + def test_relative(self): + """Relative URLs are prefixed with the configured base URL""" + self.set_base_url("https://authentik.company") + cases = { + "/if/admin/#/core/applications/app": ( + "https://authentik.company/if/admin/#/core/applications/app" + ), + "if/user/": "https://authentik.company/if/user/", + "": "", + } + for value, expected in cases.items(): + with self.subTest(value=value): + self.assertEqual(build_absolute_url(value), expected) + + def test_absolute_unchanged(self): + """URLs that already have a scheme or host are returned unchanged""" + self.set_base_url("https://authentik.company") + for value in [ + "https://files.example.com/export.csv", + "http://localhost:9000/if/admin/", + "//cdn.example.com/asset", + "mailto:admin@authentik.company", + ]: + with self.subTest(value=value): + self.assertEqual(build_absolute_url(value), value) + + def test_no_base_url(self): + """Without a configured base URL relative URLs are returned unchanged""" + self.set_base_url("") + self.assertEqual(build_absolute_url("/if/admin/"), "/if/admin/") diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index 85a3eda9387b..25306c27d9e8 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,5 +1,7 @@ """Tenant utils""" +from urllib.parse import urlsplit + from django.db import connection from django_tenants.utils import get_public_schema_name @@ -31,3 +33,18 @@ def get_unique_identifier() -> str: def normalize_base_url(value: str | None) -> str: """Normalize a configured base URL: strip whitespace and trailing slashes.""" return (value or "").strip().rstrip("/") + + +def build_absolute_url(url: str) -> str: + """Make a relative URL absolute by prepending the current tenant's configured base URL. + URLs that already have a scheme or host, and any URL when no base URL is configured, + are returned unchanged.""" + if not url: + return url + parsed = urlsplit(url) + if parsed.scheme or parsed.netloc: + return url + base_url = get_current_tenant(only=["base_url"]).base_url + if not base_url: + return url + return f"{base_url}/{url.lstrip('/')}" diff --git a/website/docs/install-config/configuration/configuration.mdx b/website/docs/install-config/configuration/configuration.mdx index 064e6b1bc5c4..8b3e9e70ac8d 100644 --- a/website/docs/install-config/configuration/configuration.mdx +++ b/website/docs/install-config/configuration/configuration.mdx @@ -837,8 +837,6 @@ Set this to the scheme and host only, without a path. All of authentik is served Defaults to an empty string. -This setting is currently unused until it can be marked as required, starting from authentik version 2026.11. - ### `AUTHENTIK_WEB__TIMEOUT_HTTP` Configure the timeouts for the web HTTP/HTTPS Server. Accepts duration in the format of "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". diff --git a/website/docs/sys-mgmt/events/transports.md b/website/docs/sys-mgmt/events/transports.md index e6a14aba7dd7..bf1c4fd95c6f 100644 --- a/website/docs/sys-mgmt/events/transports.md +++ b/website/docs/sys-mgmt/events/transports.md @@ -14,6 +14,10 @@ Notifications can be sent to users through multiple delivery methods, or _transp - Webhook (generic) - Webhook (Slack/Discord) +:::info +For every transport except Local, links included in a notification are made absolute using the [Base URL](../settings.md#base-url) system setting, so that they can be opened from outside of authentik. While no base URL is configured, such links remain relative. +::: + ### Local This notification transport creates a notification in the authentik UI. @@ -68,6 +72,14 @@ return { } ``` +The notification's hyperlink is stored as a URL relative to the authentik instance. Use `hyperlink_absolute` to get it resolved against the [Base URL](../settings.md#base-url) system setting, so that it can be opened from wherever the webhook is delivered to: + +```python +return { + "link": notification.hyperlink_absolute, +} +``` + For failed login notifications, the attempted username is stored in the event context. If the GeoIP and ASN context processors are configured, their data is also available in the event context: ```python diff --git a/website/docs/sys-mgmt/settings.md b/website/docs/sys-mgmt/settings.md index 8e231686b9cb..c1e766432c27 100644 --- a/website/docs/sys-mgmt/settings.md +++ b/website/docs/sys-mgmt/settings.md @@ -16,7 +16,7 @@ While this setting is empty, authentik displays a warning in the Admin interface Set this to the scheme and host only, without a path. All of authentik is served under [`AUTHENTIK_WEB__PATH`](../install-config/configuration/configuration.mdx#authentik_web__path) (`/` by default), and that prefix is already part of every link authentik generates. So even when you serve authentik under a subpath, for example `AUTHENTIK_WEB__PATH=/authentik/`, the base URL stays `https://authentik.company`, and generated links resolve under `https://authentik.company/authentik/`. ::: -This setting is currently unused until it can be marked as required, starting from authentik version 2026.11. +The base URL is used to make links absolute in [notifications](./events/notifications.md) that are delivered outside of authentik, for example over the email and webhook notification transports. While the setting is empty, those links remain relative and might not be usable from the notification's destination. ### Avatars From eff3d5c1daa78331316b6b92df7e010a8dcc0018 Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:19:31 +0300 Subject: [PATCH 2/4] Update authentik/events/models.py Co-authored-by: Jens L. Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> --- authentik/events/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/events/models.py b/authentik/events/models.py index 4010610a2921..4fcd02de0d9b 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -666,7 +666,7 @@ def hyperlink_absolute(self) -> str | None: """The hyperlink resolved against the tenant's configured base URL, for use outside of the authentik UI where relative URLs do not resolve""" if not self.hyperlink: - return self.hyperlink + return None return build_absolute_url(self.hyperlink) @property From ef18a3d5deb80a1b2512958b35e51ec5a5da6f4e Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:20:27 +0300 Subject: [PATCH 3/4] Update authentik/events/models.py Co-authored-by: Jens L. Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> --- authentik/events/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authentik/events/models.py b/authentik/events/models.py index 4fcd02de0d9b..ca04358a3426 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -604,9 +604,9 @@ def send_email(self, notification: Notification) -> list[str]: for key, value in notification.event.context.items(): if not isinstance(value, str): continue - if key == "hyperlink": - value = build_absolute_url(value) # noqa: PLW2901 context["key_value"][key] = value + if key == "hyperlink": + context["key_value"][key] = value = build_absolute_url(value) else: context["title"] += notification.body[:NOTIFICATION_SUMMARY_LENGTH] # TODO: improve permission check From da805b64b2e897b6d2a41e545b3f68526e7e2d3f Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin Date: Mon, 10 Aug 2026 16:55:46 +0300 Subject: [PATCH 4/4] events: implement review feedback on links in notifications --- authentik/events/models.py | 11 +++++----- ...absolute_url.py => test_apply_base_url.py} | 22 +++++++++++-------- authentik/tenants/utils.py | 18 ++++++--------- .../configuration/configuration.mdx | 2 ++ 4 files changed, 28 insertions(+), 25 deletions(-) rename authentik/tenants/tests/{test_build_absolute_url.py => test_apply_base_url.py} (60%) diff --git a/authentik/events/models.py b/authentik/events/models.py index ca04358a3426..277588c94660 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -55,7 +55,7 @@ from authentik.stages.email.utils import TemplateEmailMessage from authentik.tasks.models import TasksModel from authentik.tenants.models import Tenant -from authentik.tenants.utils import build_absolute_url, get_current_tenant +from authentik.tenants.utils import apply_base_url, get_current_tenant LOGGER = get_logger() DISCORD_FIELD_LIMIT = 25 @@ -517,9 +517,10 @@ def send_webhook_slack(self, notification: Notification) -> list[str]: # https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html if len(fields) >= DISCORD_FIELD_LIMIT: continue + field = {"title": key[:256], "value": value[:1024]} if key == "hyperlink": - value = build_absolute_url(value) # noqa: PLW2901 - fields.append({"title": key[:256], "value": value[:1024]}) + field["value"] = apply_base_url(value)[:1024] + fields.append(field) body = { "username": "authentik", "icon_url": "https://goauthentik.io/img/icon.png", @@ -606,7 +607,7 @@ def send_email(self, notification: Notification) -> list[str]: continue context["key_value"][key] = value if key == "hyperlink": - context["key_value"][key] = value = build_absolute_url(value) + context["key_value"][key] = apply_base_url(value) else: context["title"] += notification.body[:NOTIFICATION_SUMMARY_LENGTH] # TODO: improve permission check @@ -667,7 +668,7 @@ def hyperlink_absolute(self) -> str | None: outside of the authentik UI where relative URLs do not resolve""" if not self.hyperlink: return None - return build_absolute_url(self.hyperlink) + return apply_base_url(self.hyperlink) @property def serializer(self) -> type[Serializer]: diff --git a/authentik/tenants/tests/test_build_absolute_url.py b/authentik/tenants/tests/test_apply_base_url.py similarity index 60% rename from authentik/tenants/tests/test_build_absolute_url.py rename to authentik/tenants/tests/test_apply_base_url.py index 47251e119c64..02a6b3e407cd 100644 --- a/authentik/tenants/tests/test_build_absolute_url.py +++ b/authentik/tenants/tests/test_apply_base_url.py @@ -1,12 +1,12 @@ -"""Tests for the build_absolute_url helper""" +"""Tests for the apply_base_url helper""" from django.test import TestCase -from authentik.tenants.utils import build_absolute_url, get_current_tenant +from authentik.tenants.utils import apply_base_url, get_current_tenant -class TestBuildAbsoluteURL(TestCase): - """build_absolute_url resolves relative URLs against the tenant's base URL""" +class TestApplyBaseURL(TestCase): + """apply_base_url resolves relative URLs against the tenant's base URL""" def set_base_url(self, value: str): tenant = get_current_tenant() @@ -25,21 +25,25 @@ def test_relative(self): } for value, expected in cases.items(): with self.subTest(value=value): - self.assertEqual(build_absolute_url(value), expected) + self.assertEqual(apply_base_url(value), expected) def test_absolute_unchanged(self): - """URLs that already have a scheme or host are returned unchanged""" + """URLs that already have a scheme are returned unchanged""" self.set_base_url("https://authentik.company") for value in [ "https://files.example.com/export.csv", "http://localhost:9000/if/admin/", - "//cdn.example.com/asset", "mailto:admin@authentik.company", ]: with self.subTest(value=value): - self.assertEqual(build_absolute_url(value), value) + self.assertEqual(apply_base_url(value), value) + + def test_scheme_relative(self): + """Scheme-relative URLs adopt the base URL's scheme""" + self.set_base_url("https://authentik.company") + self.assertEqual(apply_base_url("//cdn.example.com/asset"), "https://cdn.example.com/asset") def test_no_base_url(self): """Without a configured base URL relative URLs are returned unchanged""" self.set_base_url("") - self.assertEqual(build_absolute_url("/if/admin/"), "/if/admin/") + self.assertEqual(apply_base_url("/if/admin/"), "/if/admin/") diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index 25306c27d9e8..352d48a60a55 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,6 +1,6 @@ """Tenant utils""" -from urllib.parse import urlsplit +from urllib.parse import urljoin from django.db import connection from django_tenants.utils import get_public_schema_name @@ -35,16 +35,12 @@ def normalize_base_url(value: str | None) -> str: return (value or "").strip().rstrip("/") -def build_absolute_url(url: str) -> str: - """Make a relative URL absolute by prepending the current tenant's configured base URL. - URLs that already have a scheme or host, and any URL when no base URL is configured, - are returned unchanged.""" +def apply_base_url(url: str) -> str: + """Make a relative URL absolute by resolving it against the current tenant's configured + base URL. Absolute URLs, and any URL when no base URL is configured, are returned + unchanged. Expects server-relative URLs as emitted by `reverse()`, which already carry + the `web.path` prefix all of authentik's URLs are mounted under.""" if not url: return url - parsed = urlsplit(url) - if parsed.scheme or parsed.netloc: - return url base_url = get_current_tenant(only=["base_url"]).base_url - if not base_url: - return url - return f"{base_url}/{url.lstrip('/')}" + return urljoin(base_url, url) diff --git a/website/docs/install-config/configuration/configuration.mdx b/website/docs/install-config/configuration/configuration.mdx index 8b3e9e70ac8d..d1d2df06212d 100644 --- a/website/docs/install-config/configuration/configuration.mdx +++ b/website/docs/install-config/configuration/configuration.mdx @@ -837,6 +837,8 @@ Set this to the scheme and host only, without a path. All of authentik is served Defaults to an empty string. +This setting is currently only used by email notifications produced by the object lifecycle management (an enterprise feature). It will be marked as required, starting from authentik version 2026.11. + ### `AUTHENTIK_WEB__TIMEOUT_HTTP` Configure the timeouts for the web HTTP/HTTPS Server. Accepts duration in the format of "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".