Skip to content
Open
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
19 changes: 16 additions & 3 deletions authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions authentik/events/tests/test_transports.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""transport tests"""

from json import loads
from unittest.mock import PropertyMock, patch

from django.core import mail
Expand All @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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"""

Expand Down
49 changes: 49 additions & 0 deletions authentik/tenants/tests/test_apply_base_url.py
Original file line number Diff line number Diff line change
@@ -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/")
13 changes: 13 additions & 0 deletions authentik/tenants/utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
12 changes: 12 additions & 0 deletions website/docs/sys-mgmt/events/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion website/docs/sys-mgmt/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading