diff --git a/authentik/events/models.py b/authentik/events/models.py index 7b3df96d4316..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 get_current_tenant +from authentik.tenants.utils import apply_base_url, get_current_tenant LOGGER = get_logger() DISCORD_FIELD_LIMIT = 25 @@ -517,7 +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 - fields.append({"title": key[:256], "value": value[:1024]}) + field = {"title": key[:256], "value": value[:1024]} + if key == "hyperlink": + field["value"] = apply_base_url(value)[:1024] + fields.append(field) body = { "username": "authentik", "icon_url": "https://goauthentik.io/img/icon.png", @@ -594,7 +597,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: @@ -603,6 +606,8 @@ def send_email(self, notification: Notification) -> list[str]: if not isinstance(value, str): continue context["key_value"][key] = value + if key == "hyperlink": + context["key_value"][key] = apply_base_url(value) else: context["title"] += notification.body[:NOTIFICATION_SUMMARY_LENGTH] # TODO: improve permission check @@ -657,6 +662,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 None + return apply_base_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_apply_base_url.py b/authentik/tenants/tests/test_apply_base_url.py new file mode 100644 index 000000000000..02a6b3e407cd --- /dev/null +++ b/authentik/tenants/tests/test_apply_base_url.py @@ -0,0 +1,49 @@ +"""Tests for the apply_base_url helper""" + +from django.test import TestCase + +from authentik.tenants.utils import apply_base_url, get_current_tenant + + +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() + 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(apply_base_url(value), expected) + + def test_absolute_unchanged(self): + """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/", + "mailto:admin@authentik.company", + ]: + with self.subTest(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(apply_base_url("/if/admin/"), "/if/admin/") diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index 85a3eda9387b..352d48a60a55 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,5 +1,7 @@ """Tenant utils""" +from urllib.parse import urljoin + from django.db import connection from django_tenants.utils import get_public_schema_name @@ -31,3 +33,14 @@ 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 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 + base_url = get_current_tenant(only=["base_url"]).base_url + return urljoin(base_url, url) diff --git a/website/docs/install-config/configuration/configuration.mdx b/website/docs/install-config/configuration/configuration.mdx index 064e6b1bc5c4..d1d2df06212d 100644 --- a/website/docs/install-config/configuration/configuration.mdx +++ b/website/docs/install-config/configuration/configuration.mdx @@ -837,7 +837,7 @@ 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. +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` 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