Compare commits

...

34 Commits

Author SHA1 Message Date
authentik-automation[bot]
ee4ecf929f release: 2025.12.0-rc2 2025-12-17 22:03:04 +00:00
authentik-automation[bot]
8336556a6f root: fix docker-compose data mount (cherry-pick #18903 to version-2025.12) (#18918)
root: fix docker-compose data mount (#18903)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 19:53:44 +00:00
authentik-automation[bot]
709aad1d3b core/groups: optimize prefetch queries to fetch only required fields (cherry-pick #18448 to version-2025.12) (#18914)
core/groups: optimize prefetch queries to fetch only required fields (#18448)

Co-authored-by: João C. Fernandes <joaocfernandes@gmail.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:50:44 +00:00
authentik-automation[bot]
fb7ab4937c web/admin: reword some things on the device view page (cherry-pick #18785 to version-2025.12) (#18913)
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-17 17:55:14 +01:00
authentik-automation[bot]
5df1726d80 website/docs: 2025.12: remove superfluous changes (cherry-pick #18910 to version-2025.12) (#18912)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:25:13 +01:00
authentik-automation[bot]
9fdb568843 ci/release-tag: checkout correct branch for make test-docker (cherry-pick #18880 to version-2025.12) (#18911)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 16:24:56 +01:00
authentik-automation[bot]
8e76f56f89 api: fix latest version for public schema (cherry-pick #18902 to version-2025.12) (#18909)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix latest version for public schema (#18902)
2025-12-17 16:14:16 +01:00
authentik-automation[bot]
05d3791577 website/docs: added list of Int Guide contributors (also edited frontmatter) (cherry-pick #18888 to version-2025.12) (#18907)
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 16:10:50 +01:00
authentik-automation[bot]
d00dd7eb90 api: fix page_size with invalid query param (cherry-pick #18879 to version-2025.12) (#18908)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix page_size with invalid query param (#18879)
2025-12-17 16:10:07 +01:00
authentik-automation[bot]
8d2e404017 stages/authenticator_*: fix code input field not string (cherry-pick #18875 to version-2025.12) (#18906)
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix code input field not string (#18875)
2025-12-17 16:04:32 +01:00
authentik-automation[bot]
95eb2af25e tasks/middleware: close connections on worker status update database error (cherry-pick #18881 to version-2025.12) (#18905)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
close connections on worker status update database error (#18881)
2025-12-17 15:46:53 +01:00
authentik-automation[bot]
cbc00a501b web: fix file upload form (cherry-pick #18808 to version-2025.12) (#18884)
Co-authored-by: Dominic R <dominic@sdko.org>
fix file upload form (#18808)
2025-12-17 14:02:31 +01:00
authentik-automation[bot]
480645d897 website/docs: add icon info to style guide (cherry-pick #18832 to version-2025.12) (#18837)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 14:02:12 +01:00
authentik-automation[bot]
997c767c95 web/admin: endpoint: change wording and add helper text (cherry-pick #18871 to version-2025.12) (#18890)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <teffen@sister.software>
2025-12-17 14:00:02 +01:00
authentik-automation[bot]
5a54e1dc9a web: fix notification counter (cherry-pick #18781 to version-2025.12) (#18882)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
fix notification counter (#18781)
2025-12-16 18:44:22 +01:00
authentik-automation[bot]
49b1952566 website/docs: Add docs for passkey autofill (WebauthN Conditional UI) (cherry-pick #18805 to version-2025.12) (#18870)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-16 18:11:02 +01:00
authentik-automation[bot]
e73edc2fce web/admin: fix read-only provider selection for application form (cherry-pick #18768 to version-2025.12) (#18803)
Co-authored-by: Dominic R <dominic@sdko.org>
fix read-only provider selection for application form (#18768)
2025-12-16 18:10:49 +01:00
authentik-automation[bot]
409652e874 web: add custom message with links for empty data export list (cherry-pick #18830 to version-2025.12) (#18876)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
2025-12-16 18:09:52 +01:00
authentik-automation[bot]
1d3fb6431f website/docs: 2025.10.3 release notes (cherry-pick #18868 to version-2025.12) (#18873)
website/docs: 2025.10.3 release notes (#18868)

* website/docs: 2025.10.3 release notes



* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-16 17:06:16 +01:00
authentik-automation[bot]
76cfada60f website/docs: adjust RBAC-related details in 2025.12 release notes (cherry-pick #18863 to version-2025.12) (#18869)
website/docs: adjust RBAC-related details in 2025.12 release notes (#18863)

* website/docs: adjust RBAC-related details in 2025.12 release notes

* adjust wording




---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-16 10:39:50 -05:00
authentik-automation[bot]
ac45f80551 outposts: fix permission errors for related certificates (cherry-pick #18861 to version-2025.12) (#18866)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix permission errors for related certificates (#18861)
2025-12-16 15:23:49 +01:00
authentik-automation[bot]
5ea85f086a web/admin/rbac: misc object permission fixes (cherry-pick #18859 to version-2025.12) (#18865)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
fixes (#18859)
2025-12-16 14:54:31 +01:00
authentik-automation[bot]
e3f657746c rbac: alter migrated direct permission roles (cherry-pick #18860 to version-2025.12) (#18864)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2025-12-16 13:56:35 +01:00
authentik-automation[bot]
001b56e2cc release: 2025.12.0-rc1 2025-12-16 04:59:24 +00:00
Marcelo Elizeche Landó
ecbfd2f0de add skip s3_test_server_available to TestResolveFileUrlS3Backend 2025-12-16 01:29:40 -03:00
authentik-automation[bot]
45753397e1 admin/files: fix get_objects_for_user queryset argument in FileUsedByView (cherry-pick #18845 to version-2025.12) (#18847)
admin/files: fix get_objects_for_user queryset argument in FileUsedByView (#18845)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-16 01:15:30 +00:00
Marcelo Elizeche Landó
dc6fe1dafe core: skip s3 tests if endpoint isn't available (#18841)
skip s3 tests if endpoint isn't available
2025-12-15 20:38:01 -03:00
authentik-automation[bot]
d5e8f2f416 admin/files: revert add check for /media existence (#18636) (cherry-pick #18829 to version-2025.12) (#18838)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-15 16:45:43 +01:00
authentik-automation[bot]
d73af5a2b4 packages/django-dramatiq-postgres: broker: close django connections on consumer close (cherry-pick #18833 to version-2025.12) (#18836)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Norman Ziebal <norman.ziebal@mail.schwarz>
close django connections on consumer close (#18833)
2025-12-15 15:01:08 +01:00
authentik-automation[bot]
7042f2bba8 core: list applications fix (cherry-pick #18798 to version-2025.12) (#18828)
Co-authored-by: Ryan Pesek <44002516+ryanpesek@users.noreply.github.com>
fix (#18798)
2025-12-15 12:47:20 +00:00
authentik-automation[bot]
efeb260fa8 tests/e2e: retry detached shadow roots (cherry-pick #18796 to version-2025.12) (#18799)
tests/e2e: retry detached shadow roots (#18796)

tests(e2e): retry detached shadow roots

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-14 00:50:23 +01:00
authentik-automation[bot]
29e90092ea website/release notes: Update v2025.12 release notes (cherry-pick #18797 to version-2025.12) (#18800)
website/release notes: Update v2025.12 release notes (#18797)

* website/release notes: Update v2025.12 release notes



* fix linting

---------

Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-13 00:03:26 +00:00
Marcelo Elizeche Landó
0abe865023 Revert "Update docker compose command to start postgresql with s3"
This reverts commit 220c65a41a.
2025-12-12 20:59:44 -03:00
Marcelo Elizeche Landó
220c65a41a Update docker compose command to start postgresql with s3
Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-12 19:47:24 -03:00
54 changed files with 14336 additions and 182 deletions

View File

@@ -49,8 +49,12 @@ jobs:
test:
name: Pre-release test
runs-on: ubuntu-latest
needs:
- check-inputs
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
- run: make test-docker
bump-authentik:
name: Bump authentik version

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2025.12.0-rc1"
VERSION = "2025.12.0-rc2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
def get_version_latest(self, _) -> str:
"""Get latest version from cache"""
if get_current_tenant().schema_name == get_public_schema_name():
if get_current_tenant().schema_name != get_public_schema_name():
return authentik_version()
version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover

View File

@@ -240,7 +240,9 @@ class FileUsedByView(APIView):
for field in fields:
q |= Q(**{field: params.get("name")})
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
objs = get_objects_for_user(
request.user, f"{app}.view_{model_name}", model.objects.all()
)
objs = objs.filter(q)
for obj in objs:
serializer = UsedBySerializer(

View File

@@ -1,9 +1,4 @@
from pathlib import Path
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.config import CONFIG
class AuthentikFilesConfig(ManagedAppConfig):
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
label = "authentik_admin_files"
verbose_name = "authentik Files"
default = True
@ManagedAppConfig.reconcile_global
def check_for_media_mount(self):
if settings.TEST:
return
from authentik.events.models import Event, EventAction
if (
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
and Path("/media").exists()
):
Event.new(
EventAction.CONFIGURATION_ERROR,
message="/media has been moved to /data/media. "
"Check the release notes for migration steps.",
).save()

View File

@@ -1,10 +1,13 @@
from unittest import skipUnless
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3 backend functionality"""

View File

@@ -1,10 +1,16 @@
"""Test file service layer"""
from unittest import skipUnless
from django.http import HttpRequest
from django.test import TestCase
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
from authentik.admin.files.tests.utils import (
FileTestFileBackendMixin,
FileTestS3BackendMixin,
s3_test_server_available,
)
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@@ -81,6 +87,7 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
self.assertEqual(result, "http://example.com/files/media/public/test.png")
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
@CONFIG.patch("storage.media.s3.secure_urls", False)

View File

@@ -1,11 +1,26 @@
import shutil
import socket
from tempfile import mkdtemp
from urllib.parse import urlparse
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG, UNSET
from authentik.lib.generators import generate_id
S3_TEST_ENDPOINT = "http://localhost:8020"
def s3_test_server_available() -> bool:
"""Check if the S3 test server is reachable."""
parsed = urlparse(S3_TEST_ENDPOINT)
try:
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
return True
except OSError:
return False
class FileTestFileBackendMixin:
def setUp(self):
@@ -57,7 +72,7 @@ class FileTestS3BackendMixin:
for key in s3_config_keys:
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.media.s3.access_key", "accessKey1")
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
@@ -70,7 +85,7 @@ class FileTestS3BackendMixin:
for key in s3_config_keys:
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)

View File

@@ -15,7 +15,9 @@ class Pagination(pagination.PageNumberPagination):
def get_page_size(self, request):
if self.page_size_query_param in request.query_params:
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
page_size = super().get_page_size(request)
if page_size is not None:
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
return request.tenant.pagination_default_page_size
def get_paginated_response(self, data):

View File

@@ -180,10 +180,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
)
def _filter_applications_with_launch_url(
self, applications: QuerySet[Application]
self, paginated_apps: QuerySet[Application]
) -> list[Application]:
applications = []
for app in applications:
for app in paginated_apps:
if app.get_launch_url():
applications.append(app)
return applications

View File

@@ -33,6 +33,16 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
"pk",
"username",
"name",
"is_active",
"last_login",
"email",
"attributes",
]
class PartialUserSerializer(ModelSerializer):
"""Partial User Serializer, does not include child relations."""
@@ -42,16 +52,7 @@ class PartialUserSerializer(ModelSerializer):
class Meta:
model = User
fields = [
"pk",
"username",
"name",
"is_active",
"last_login",
"email",
"attributes",
"uid",
]
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
class RelatedGroupSerializer(ModelSerializer):
@@ -262,7 +263,14 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
base_qs = Group.objects.all().prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
# time
base_qs = base_qs.prefetch_related(
Prefetch(
"users",
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
)
)
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))

View File

@@ -18,10 +18,9 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
def get_role_for_user_id(user_id: int) -> Role:
name = f"ak-managed-role--user-{user_id}"
name = f"ak-migrated-role--user-{user_id}"
role, created = Role.objects.using(db_alias).get_or_create(
name=name,
managed=name,
)
if created:
role.users.add(user_id)
@@ -32,11 +31,10 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
if not role:
# Every django group should already have a role, so this should never happen.
# But let's be nice.
name = f"ak-managed-role--group-{group_id}"
name = f"ak-migrated-role--group-{group_id}"
role, created = Role.objects.using(db_alias).get_or_create(
group_id=group_id,
name=name,
managed=name,
)
if created:
role.group_id = group_id

View File

@@ -86,7 +86,7 @@ class OutpostConfig:
class OutpostModel(Model):
"""Base model for providers that need more objects than just themselves"""
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
"""Return a list of all required objects"""
return [self]
@@ -332,41 +332,35 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
"""Create per-object and global permissions for outpost service-account"""
# To ensure the user only has the correct permissions, we delete all of them and re-add
# the ones the user needs
with transaction.atomic():
user.remove_all_perms_from_managed_role()
for model_or_perm in self.get_required_objects():
if isinstance(model_or_perm, models.Model):
model_or_perm: models.Model
code_name = (
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
)
try:
user.assign_perms_to_managed_role(code_name, model_or_perm)
except (Permission.DoesNotExist, AttributeError) as exc:
LOGGER.warning(
"permission doesn't exist",
code_name=code_name,
user=user,
model=model_or_perm,
try:
with transaction.atomic():
user.remove_all_perms_from_managed_role()
for model_or_perm in self.get_required_objects():
if isinstance(model_or_perm, models.Model):
code_name = (
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=(
"While setting the permissions for the service-account, a "
"permission was not found: Check "
"https://docs.goauthentik.io/troubleshooting/missing_permission"
),
).with_exception(exc).set_user(user).save()
else:
app_label, perm = model_or_perm.split(".")
permission = Permission.objects.filter(
codename=perm,
content_type__app_label=app_label,
)
if not permission.exists():
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
continue
user.assign_perms_to_managed_role(permission.first())
user.assign_perms_to_managed_role(code_name, model_or_perm)
elif isinstance(model_or_perm, tuple):
perm, obj = model_or_perm
user.assign_perms_to_managed_role(perm, obj)
else:
user.assign_perms_to_managed_role(model_or_perm)
except (Permission.DoesNotExist, AttributeError) as exc:
LOGGER.warning(
"permission doesn't exist",
code_name=code_name,
user=user,
model=model_or_perm,
)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=(
"While setting the permissions for the service-account, a "
"permission was not found: Check "
"https://docs.goauthentik.io/troubleshooting/missing_permission"
),
).with_exception(exc).set_user(user).save()
LOGGER.debug(
"Updated service account's permissions",
obj_perms=user.get_all_obj_perms_on_managed_role(),
@@ -431,7 +425,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
Token.objects.filter(identifier=self.token_identifier).delete()
return self.token
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
"""Get an iterator of all objects the user needs read access to"""
objects: list[models.Model | str] = [
self,
@@ -445,7 +439,9 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
if self.managed:
for brand in Brand.objects.filter(web_certificate__isnull=False):
objects.append(brand)
objects.append(brand.web_certificate)
objects.append(("view_certificatekeypair", brand.web_certificate))
objects.append(("view_certificatekeypair_certificate", brand.web_certificate))
objects.append(("view_certificatekeypair_key", brand.web_certificate))
return objects
def __str__(self) -> str:

View File

@@ -51,10 +51,12 @@ class OutpostTests(TestCase):
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 3)
self.assertEqual(len(permissions), 5)
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
self.assertEqual(permissions[2].object_pk, str(provider.pk))
self.assertEqual(permissions[1].object_pk, str(keypair.pk))
self.assertEqual(permissions[2].object_pk, str(keypair.pk))
self.assertEqual(permissions[3].object_pk, str(outpost.pk))
self.assertEqual(permissions[4].object_pk, str(provider.pk))
# Remove provider from outpost, user should only have access to outpost
outpost.providers.remove(provider)

View File

@@ -93,11 +93,13 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
def __str__(self):
return f"LDAP Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
if self.certificate is not None:
required_models.append(self.certificate)
return required_models
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
class Meta:
verbose_name = _("LDAP Provider")

View File

@@ -179,11 +179,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def __str__(self):
return f"Proxy Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self]
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self]
if self.certificate is not None:
required_models.append(self.certificate)
return required_models
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
class Meta:
verbose_name = _("Proxy Provider")

View File

@@ -1,10 +1,14 @@
"""proxy provider tests"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.models import ClientTypes
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
@@ -127,3 +131,55 @@ class ProxyProviderTests(APITestCase):
self.assertEqual(response.status_code, 200)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
def test_sa_fetch(self):
"""Test fetching the outpost config as the service account"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
provider = ProxyProvider.objects.create(name=generate_id())
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
outpost.providers.add(provider)
res = self.client.get(
reverse("authentik_api:proxyprovideroutpost-list"),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
body = loads(res.content)
self.assertEqual(body["pagination"]["count"], 1)
def test_sa_perms_cert(self):
"""Test permissions to access a configured certificate"""
cert = create_test_cert()
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
provider = ProxyProvider.objects.create(name=generate_id(), certificate=cert)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
outpost.providers.add(provider)
res = self.client.get(
reverse("authentik_api:proxyprovideroutpost-list"),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
body = loads(res.content)
self.assertEqual(body["pagination"]["count"], 1)
cert_id = body["results"][0]["certificate"]
self.assertEqual(cert_id, str(cert.pk))
res = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
kwargs={
"pk": cert_id,
},
),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
self.assertEqual(res.status_code, 200)
# res = self.client.get(
# reverse(
# "authentik_api:certificatekeypair-view-private-key",
# kwargs={
# "pk": cert_id,
# },
# ),
# HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
# )
# self.assertEqual(res.status_code, 200)

View File

@@ -64,10 +64,12 @@ class RadiusProvider(OutpostModel, Provider):
return RadiusProviderSerializer
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self, "authentik_stages_mtls.pass_outpost_certificate"]
if self.certificate is not None:
required.append(self.certificate)
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
def __str__(self):

View File

@@ -6,7 +6,7 @@ from django.http.request import QueryDict
from django.template.exceptions import TemplateSyntaxError
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.fields import BooleanField, CharField
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import (
@@ -47,7 +47,7 @@ class AuthenticatorEmailChallengeResponse(ChallengeResponse):
device: EmailDevice
code = IntegerField(required=False)
code = CharField(required=False)
email = CharField(required=False)
component = CharField(default="ak-stage-authenticator-email")

View File

@@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.fields import BooleanField, CharField
from authentik.flows.challenge import (
Challenge,
@@ -38,7 +38,7 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
device: SMSDevice
code = IntegerField(required=False)
code = CharField(required=False)
phone_number = CharField(required=False)
component = CharField(default="ak-stage-authenticator-sms")

View File

@@ -5,7 +5,7 @@ from urllib.parse import quote
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField, IntegerField
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from authentik.flows.challenge import (
@@ -32,10 +32,10 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
device: TOTPDevice
code = IntegerField()
code = CharField()
component = CharField(default="ak-stage-authenticator-totp")
def validate_code(self, code: int) -> int:
def validate_code(self, code: str) -> str:
"""Validate totp code"""
if not self.device:
raise ValidationError(_("Code does not match"))

View File

@@ -245,7 +245,10 @@ class WorkerStatusMiddleware(Middleware):
WorkerStatusMiddleware.keep(status)
except DB_ERRORS: # pragma: no cover
sleep(10)
pass
try:
connections.close_all()
except DB_ERRORS:
pass
@staticmethod
def keep(status: WorkerStatus):

View File

@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2025.12.0-rc1 Blueprint schema",
"title": "authentik 2025.12.0-rc2 Blueprint schema",
"required": [
"version",
"entries"
@@ -14510,7 +14510,8 @@
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
},
"webauthn_stage": {
"type": "integer",
"type": "string",
"format": "uuid",
"title": "Webauthn stage",
"description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor."
}

View File

@@ -31,13 +31,13 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc2}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
restart: unless-stopped
volumes:
- ./media:/data/media
- ./data:/data
- ./custom-templates:/templates
worker:
command: worker
@@ -52,12 +52,12 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc2}
restart: unless-stopped
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/data/media
- ./data:/data
- ./certs:/certs
- ./custom-templates:/templates
volumes:

View File

@@ -1 +1 @@
2025.12.0-rc1
2025.12.0-rc2

View File

@@ -18,7 +18,7 @@ Parameters:
Description: authentik Docker image
AuthentikVersion:
Type: String
Default: 2025.12.0-rc1
Default: 2025.12.0-rc2
Description: authentik Docker image tag
AuthentikServerCPU:
Type: Number

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"dependencies": {
"@eslint/js": "^9.39.1",
"@goauthentik/eslint-config": "./packages/eslint-config",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -529,3 +529,7 @@ class _PostgresConsumer(Consumer):
conn.close()
except DATABASE_ERRORS:
pass
try:
connections.close_all()
except DATABASE_ERRORS:
pass

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.12.0-rc1"
version = "2025.12.0-rc2"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.12.0-rc1
version: 2025.12.0-rc2
description: Making authentication simple.
contact:
email: hello@goauthentik.io
@@ -33586,7 +33586,8 @@ components:
minLength: 1
default: ak-stage-authenticator-email
code:
type: integer
type: string
minLength: 1
email:
type: string
minLength: 1
@@ -33833,7 +33834,8 @@ components:
minLength: 1
default: ak-stage-authenticator-sms
code:
type: integer
type: string
minLength: 1
phone_number:
type: string
minLength: 1
@@ -34107,7 +34109,8 @@ components:
minLength: 1
default: ak-stage-authenticator-totp
code:
type: integer
type: string
minLength: 1
required:
- code
AuthenticatorTOTPStage:

6
scripts/generate_docker_compose.py Normal file → Executable file
View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
from yaml import safe_dump
from authentik import authentik_version
@@ -42,7 +44,7 @@ base = {
"image": authentik_image,
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
"restart": "unless-stopped",
"volumes": ["./media:/data/media", "./custom-templates:/templates"],
"volumes": ["./data:/data", "./custom-templates:/templates"],
},
"worker": {
"command": "worker",
@@ -62,7 +64,7 @@ base = {
"user": "root",
"volumes": [
"/var/run/docker.sock:/var/run/docker.sock",
"./media:/data/media",
"./data:/data",
"./certs:/certs",
"./custom-templates:/templates",
],

View File

@@ -23,8 +23,10 @@ from docker.models.containers import Container
from docker.models.networks import Network
from selenium import webdriver
from selenium.common.exceptions import (
DetachedShadowRootException,
NoSuchElementException,
NoSuchShadowRootException,
StaleElementReferenceException,
TimeoutException,
WebDriverException,
)
@@ -326,18 +328,23 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
while attempts < SHADOW_ROOT_RETRIES:
try:
host = container.find_element(By.CSS_SELECTOR, selector)
return host.shadow_root
except NoSuchShadowRootException:
except (
NoSuchElementException,
NoSuchShadowRootException,
DetachedShadowRootException,
StaleElementReferenceException,
):
attempts += 1
sleep(0.2)
# re-find host in case it was re-attached
try:
host = container.find_element(By.CSS_SELECTOR, selector)
except NoSuchElementException:
# loop and retry finding host
pass
inner_html = host.get_attribute("innerHTML") or "<no host>"
inner_html = "<no host>"
if host is not None:
try:
inner_html = host.get_attribute("innerHTML") or "<no host>"
except (DetachedShadowRootException, StaleElementReferenceException):
inner_html = "<stale host>"
raise RuntimeError(
f"Failed to obtain shadow root for {selector} after {attempts} attempts. "

2
uv.lock generated
View File

@@ -185,7 +185,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.12.0rc1"
version = "2025.12.0rc2"
source = { editable = "." }
dependencies = [
{ name = "ak-guardian" },

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"license": "MIT",
"workspaces": [
"./packages/*"

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc2",
"license": "MIT",
"private": true,
"scripts": {

View File

@@ -99,6 +99,9 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
const alertMsg = msg(
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
);
const providerFromInstance = this.instance?.provider;
const providerValue = providerFromInstance ?? this.provider;
const providerPrefilled = !this.instance && this.provider !== undefined;
return html`
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
@@ -134,9 +137,10 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
<ak-provider-search-input
name="provider"
label=${msg("Provider")}
value=${ifPresent(this.instance?.provider)}
.value=${providerValue}
.readOnly=${providerPrefilled}
?blankable=${!providerPrefilled}
help=${msg("Select a provider that this application should use.")}
blankable
></ak-provider-search-input>
<ak-backchannel-providers-input
name="backchannelProviders"

View File

@@ -13,6 +13,7 @@ import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/ap
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const renderElement = (item: Provider) => item.name;
const renderValue = (item: Provider | undefined) => item?.pk;
@@ -53,6 +54,9 @@ export class AkProviderInput extends AKElement {
@property({ type: Number })
value?: number;
@property({ type: Boolean, attribute: "readonly" })
readOnly = false;
@property({ type: Boolean })
required = false;
@@ -76,6 +80,8 @@ export class AkProviderInput extends AKElement {
};
render() {
const readOnlyValue = this.readOnly && typeof this.value === "number";
return html` <ak-form-element-horizontal name=${this.name}>
${AKLabel(
{
@@ -86,7 +92,9 @@ export class AkProviderInput extends AKElement {
},
this.label,
)}
${readOnlyValue
? html`<input type="hidden" name=${this.name} value=${this.value ?? ""} />`
: nothing}
<ak-search-select
.fieldID=${this.fieldID}
.selected=${this.#selected}
@@ -94,7 +102,9 @@ export class AkProviderInput extends AKElement {
.renderElement=${renderElement}
.value=${renderValue}
.groupBy=${doGroupBy}
?blankable=${!!this.blankable}
?blankable=${readOnlyValue ? false : !!this.blankable}
?readonly=${this.readOnly}
name=${ifDefined(readOnlyValue ? undefined : this.name)}
>
</ak-search-select>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}

View File

@@ -77,7 +77,11 @@ export class AgentConnectorSetup extends AKElement {
<p>${msg("Afterwards, select the enrollment token you want to use:")}</p>
</div>
<div class="pf-l-grid__item pf-m-12-col">
<p>${msg("Then download the configuration to deploy the authentik Agent")}</p>
<p>
${msg(
"Next, download the configuration to deploy the authentik Agent via MDM",
)}
</p>
</div>
</div>
<div class="pf-l-grid__item pf-m-6-col pf-l-grid">

View File

@@ -77,10 +77,13 @@ export class EnrollmentTokenForm extends WithBrandConfig(ModelForm<EnrollmentTok
value=${ifDefined(this.instance?.name)}
required
></ak-text-input>
<ak-form-element-horizontal label=${msg("Device Group")} name="deviceGroup">
<ak-form-element-horizontal label=${msg("Device Access Group")} name="deviceGroup">
<ak-endpoints-device-group-search
.group=${this.instance?.deviceGroup}
></ak-endpoints-device-group-search>
<p class="pf-c-form__helper-text">
${msg("Select a device access group to be added to upon enrollment.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="expiring">
<label class="pf-c-switch">

View File

@@ -68,7 +68,7 @@ export class DeviceViewPage extends AKElement {
? msg(str`Device ${this.device?.name}`)
: msg("Loading device..."),
description: this.device?.facts.data.os
? this.device?.facts.data.os?.name + " " + this.device?.facts.data.os?.version
? `${this.device?.facts.data.os?.name} ${this.device?.facts.data.os?.version}`
: undefined,
icon: "fa fa-laptop",
});
@@ -110,7 +110,7 @@ export class DeviceViewPage extends AKElement {
?good=${this.device.facts.data.network?.firewallEnabled}
></ak-status-label>`,
],
[msg("Group"), this.device.accessGroupObj?.name ?? "-"],
[msg("Device access group"), this.device.accessGroupObj?.name ?? "-"],
[
msg("Actions"),
html`<ak-forms-modal>
@@ -162,13 +162,13 @@ export class DeviceViewPage extends AKElement {
></ak-status-label>`,
],
[
msg("Disk size"),
msg("Primary disk size"),
rootDisk?.capacityTotalBytes
? getSize(rootDisk.capacityTotalBytes)
: "-",
],
[
msg("Disk usage"),
msg("Primary disk usage"),
rootDisk?.capacityTotalBytes && rootDisk.capacityUsedBytes
? html`<progress
value="${rootDisk.capacityUsedBytes}"

View File

@@ -91,6 +91,20 @@ export class DataExportListPage extends TablePage<DataExport> {
</div>
</dl>`;
}
protected renderEmpty(_inner?: TemplateResult): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state icon=${this.pageIcon}
><span
>${msg(
html`To create a data export, navigate to
<a href="#/identity/users">Directory > Users</a> or to
<a href="#/events/log">Events > Logs</a>.`,
)}</span
>
</ak-empty-state>`,
);
}
}
declare global {

View File

@@ -16,18 +16,33 @@ import { createRef, ref } from "lit/directives/ref.js";
// Same regex is used in the backend as well
const VALID_FILE_NAME_PATTERN = /^[a-zA-Z0-9._/-]+$/;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source
// This is perfect for the "pattern" attribute
const VALID_FILE_NAME_PATTERN_STRING = VALID_FILE_NAME_PATTERN.source;
// Note: browsers compile `pattern` using the new `v` RegExp flag (Unicode sets). Under `/v`,
// both `/` and `-` must be escaped inside character classes.
const VALID_FILE_NAME_PATTERN_STRING = "^[a-zA-Z0-9._\\/\\-]+$";
function assertValidFileName(fileName: string): void {
if (!VALID_FILE_NAME_PATTERN.test(fileName)) {
throw new Error(
msg("Filename can only contain letters, numbers, dots, hyphens, and underscores"),
msg(
"Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes",
),
);
}
}
function getFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return "";
return fileName.slice(lastDot);
}
function hasBasenameExtension(fileName: string): boolean {
const baseName = fileName.split("/").pop() ?? fileName;
const lastDot = baseName.lastIndexOf(".");
return lastDot > 0;
}
@customElement("ak-file-upload-form")
export class FileUploadForm extends Form<Record<string, unknown>> {
@property({ type: String, useDefault: true })
@@ -57,36 +72,36 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
throw new PreventFormSubmit("Selected file not provided", this);
}
assertValidFileName(this.selectedFile.name);
const api = new AdminApi(DEFAULT_CONFIG);
const customName = typeof data.fileName === "string" ? data.fileName.trim() : "";
const customName = typeof data.name === "string" ? data.name.trim() : "";
// If custom name provided, validate and append original extension
// Only validate the original filename if no custom name is provided
let finalName = this.selectedFile.name;
if (customName) {
assertValidFileName(customName);
const ext = this.selectedFile.name.substring(this.selectedFile.name.lastIndexOf("."));
finalName = customName + ext;
const ext = getFileExtension(this.selectedFile.name);
finalName =
ext && !hasBasenameExtension(customName) ? `${customName}${ext}` : customName;
} else {
assertValidFileName(this.selectedFile.name);
}
return api
.adminFileCreate({
file: this.selectedFile,
name: finalName,
usage: this.usage,
})
.then(() => {
showMessage({
level: MessageLevel.success,
message: msg("File uploaded successfully"),
});
assertValidFileName(finalName);
this.reset();
})
.finally(() => {
this.clearFileInput();
});
await api.adminFileCreate({
file: this.selectedFile,
name: finalName,
usage: this.usage,
});
showMessage({
level: MessageLevel.success,
message: msg("File uploaded successfully"),
});
this.reset();
this.clearFileInput();
}
renderForm() {
@@ -101,7 +116,7 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
@change=${this.#fileChangeListener}
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("File Name")} name="fileName">
<ak-form-element-horizontal label=${msg("File Name")} name="name">
<input
type="text"
class="pf-c-form-control"

View File

@@ -95,7 +95,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
>
</ak-rbac-role-object-permission-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Assign role permissions")}
${msg("Assign Object Permission")}
</button>
</ak-forms-modal>`;
}
@@ -135,9 +135,9 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
const assignedToModel = item.modelPermissions.some(
(uperm) => uperm.codename === perm.codename,
);
const assignedToObject = item.objectPermissions.some(
(uperm) => uperm.codename === perm.codename,
);
const assignedToObject = item.objectPermissions
.filter((uperm) => uperm.objectPk === this.objectPk)
.some((uperm) => uperm.codename === perm.codename);
let tooltip: string | null = null;
if (assignedToModel && assignedToObject) {

View File

@@ -51,6 +51,11 @@ export class NavigationButtons extends WithSession(AKElement) {
Styles,
];
connectedCallback(): void {
super.connectedCallback();
this.refreshNotifications();
}
protected async refreshNotifications(): Promise<void> {
const { currentUser } = this;

View File

@@ -23,6 +23,7 @@ type Group<T> = [string, T[]];
export interface ISearchSelectBase<T> {
blankable?: boolean;
readOnly?: boolean;
query?: string;
objects?: T[];
selectedObject: T | null;
@@ -93,6 +94,14 @@ export abstract class SearchSelectBase<T>
@property({ type: Boolean })
public creatable?: boolean;
/**
* Prevent user interaction while still rendering the current value.
* @property
* @attr
*/
@property({ type: Boolean, attribute: "readonly" })
public readOnly = false;
/**
* An initial string to filter the search contents,
* and the value of the input which further serves to restrict the search.
@@ -254,6 +263,8 @@ export abstract class SearchSelectBase<T>
}
#searchListener = (event: InputEvent) => {
if (this.readOnly) return;
const value = (event.target as SearchSelectView).rawValue;
if (!value) {
@@ -277,6 +288,8 @@ export abstract class SearchSelectBase<T>
};
private onSelect(event: InputEvent) {
if (this.readOnly) return;
const value = (event.target as SearchSelectView).value;
if (!value) {
@@ -381,6 +394,7 @@ export abstract class SearchSelectBase<T>
.options=${options}
value=${ifPresent(value)}
?blankable=${this.blankable}
?readonly=${this.readOnly}
label=${ifPresent(this.label)}
name=${ifPresent(this.name)}
placeholder=${ifPresent(this.placeholder)}

View File

@@ -24,6 +24,7 @@ export interface ISearchSelectView {
value?: string;
open: boolean;
blankable: boolean;
readOnly: boolean;
caseSensitive: boolean;
name?: string;
placeholder: string;
@@ -126,6 +127,14 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
@property({ type: Boolean })
public blankable = false;
/**
* Prevents user interaction while showing the current value.
*
* @attr
*/
@property({ type: Boolean, attribute: "readonly" })
public readOnly = false;
/**
* If not managed, make the matcher case-sensitive during interaction. If managed,
* the manager must handle this.
@@ -248,6 +257,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
//#region Event Listeners
#clickListener = (_ev: Event) => {
if (this.readOnly) return;
this.open = !this.open;
this.#inputRef.value?.focus();
};
@@ -263,6 +274,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
}
#searchKeyupListener = (event: KeyboardEvent) => {
if (this.readOnly) return;
if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
@@ -277,6 +290,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
};
#searchKeydownListener = (event: KeyboardEvent) => {
if (this.readOnly) return;
if (!this.open) return;
switch (event.key) {
@@ -339,6 +354,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
}
#inputListener = (_ev: InputEvent) => {
if (this.readOnly) return;
if (!this.managed) {
this.findValueForInput();
this.requestUpdate();
@@ -356,6 +373,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
};
#listKeydownListener = (event: KeyboardEvent) => {
if (this.readOnly) return;
if (event.key === "Tab" && event.shiftKey) {
event.preventDefault();
@@ -364,6 +383,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
};
#changeListener = (event: InputEvent) => {
if (this.readOnly) return;
if (!event.target) {
return;
}
@@ -441,6 +462,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
@keyup=${this.#searchKeyupListener}
@keydown=${this.#searchKeydownListener}
value=${this.displayValue}
?readonly=${this.readOnly}
/>
</div>
</div>

View File

@@ -36,6 +36,8 @@ Firefox has some known issues regarding TouchID (see https://bugzilla.mozilla.or
Passwordless authentication currently only supports WebAuthn devices, which provides for the use of passkeys, security keys and biometrics. For an alternate passwordless setup, see [Password stage](../password/index.md#passwordless-login), which supports other types.
If you want users to authenticate with a passkey via the browser's built-in passkey/autofill UI on the **Identification** screen ("conditional UI" / passkey autofill), configure it in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This requires a **discoverable credential (aka resident key)**.
To configure passwordless authentication, create a new Flow with the designation set to _Authentication_.
As first stage, add an _Authenticator validation_ stage, with the WebAuthn device class allowed.

View File

@@ -26,6 +26,46 @@ The CAPTCHA stage you use must be configured to use the "Invisible" mode, otherw
To run a CAPTCHA process in the background while the user is entering their identification, a CAPTCHA stage can be selected here. If a CAPTCHA stage is selected in the Identification stage, the CAPTCHA stage should not be bound to the flow.
## Passkey autofill (WebAuthn conditional UI):ak-version[2025.12]
When configured, the Identification stage can offer passkey login directly from the browser's passkey/autofill UI (also known as "conditional UI"). This allows a user to select a passkey without first typing their username.
authentik will automatically fall back to the normal identification flow when passkey autofill is not available.
### Requirements
- **HTTPS** is required for WebAuthn (except on `localhost`).
- **Browser support** for WebAuthn conditional mediation is required.
- Users must have a compatible **discoverable credential (aka resident key)** (most passkeys created by platform authenticators and password managers are discoverable).
- **Correct domain**: users must access authentik using the same hostname the passkey was created for.
### Configuration
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages** > **Stages** and either create or edit an [Authenticator validation stage](../authenticator_validate/index.mdx) that allows the **WebAuthn** device class.
3. Navigate to **Flows and Stages** > **Stages** and edit your Identification stage. Under **Passkey settings** set **WebAuthn Authenticator Validation Stage** to the Authenticator validation stage from step 2.
4. Click **Update** to save the changes.
5. Ensure users have enrolled a passkey/WebAuthn device (for example using the [WebAuthn / FIDO2 / Passkeys Authenticator setup stage](../authenticator_webauthn/index.mdx)).
### Notes
- The passkey prompt is triggered by the browser when the user focuses the username field.
- If a user has multiple passkeys, the browser will show a picker.
- If passkey login is used, the flow context will have `auth_method` set to `auth_webauthn_pwl`.
- In the default authentication flow blueprint, authentik skips the MFA validation stage after passkey login using an expression policy. If you want passkey login to still require an additional factor, disable or adjust that policy binding on the MFA stage.
### Troubleshooting
- **No passkey prompt appears**
- Ensure the Identification stage has **WebAuthn Authenticator Validation Stage** set.
- Ensure you're using **HTTPS** (except on `localhost`).
- Check browser support for conditional UI.
- Ensure the login page is not embedded in an iframe as some browsers block conditional UI outside top-level browsing contexts.
- **Passkey prompt appears, but login falls back to username/password**
- Ensure the referenced Authenticator validation stage allows the **WebAuthn** device class.
- Ensure the user has a valid, confirmed WebAuthn device enrolled.
## Enrollment/Recovery Flow
These fields specify if and which flows are linked on the form. The enrollment flow is linked as `Need an account? Sign up.`, and the recovery flow is linked as `Forgot username or password?`.

View File

@@ -8,6 +8,8 @@ This is a generic password prompt which authenticates the current `pending_user`
There are two different ways to configure passwordless authentication; you can follow the instructions [here](../authenticator_validate/index.mdx#passwordless-authentication) to allow users to directly authenticate with their authenticator (only supported for WebAuthn devices), or dynamically skip the password stage depending on the users device, which is documented here.
If you want users to be able to pick a passkey from the browser's passkey/autofill UI without entering a username first, configure **Passkey autofill (WebAuthn conditional UI)** in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This is separate from configuring a dedicated passwordless flow, and can be used alongside normal identification flows.
Depending on what kind of device you want to require the user to have:
#### WebAuthn

View File

@@ -394,6 +394,8 @@ When documenting errors, follow this structure:
- **Diagrams**:
- Use [Mermaid](https://mermaid.js.org/) for creating diagrams directly in markdown. Mermaid is our preferred tool for documentation diagrams as it allows for version control and easy updates.
- For more complex diagrams, you can use tools like [Draw.io](https://draw.io). Ensure high contrast and text descriptions.
- **authentik icons**:
- For authentik icons in integration guides, reference assets from the user's own self-hosted instance to avoid external calls, for example: `https://authentik.company/static/dist/assets/icons/icon.svg`
---

View File

@@ -389,6 +389,34 @@ If you had persistence for Redis configured, you can delete the PVC and PV after
- web/flows: improvements for hCaptcha (cherry-pick #16882 to version-2025.10) (#18128)
- web/sfe: downgrade bootstrap that was accidentally upgraded (cherry-pick #18157 to version-2025.10) (#18171)
## Fixed in 2025.10.3
- core: list applications fix (cherry-pick #18798 to version-2025.10) (#18827)
- core: optimize list applications (cherry-pick #18330 to version-2025.10) (#18791)
- enterprise/stages/mtls: fix traefik certificate parsing (cherry-pick #18607 to version-2025.10) (#18645)
- flows: refresh unauthenticated tabs (cherry-pick #18621 to version-2025.10) (#18633)
- lib/sync/outgoing: check if there is a provider before creating tasks (cherry-pick #18394 to version-2025.10) (#18397)
- outpost/proxyv2: more tests, fix pg password with spaces, and existing session on restart (cherry-pick #18211 to version-2025.10) (#18742)
- outposts: set container healthcheck inline (cherry-pick #18298 to version-2025.10) (#18370)
- packages/django-channels-postgres: fix notify size check (cherry-pick #18347 to version-2025.10) (#18409)
- packages/django-dramatiq-postgres: broker: close django connections on consumer close (cherry-pick #18833 to version-2025.10) (#18835)
- providers/scim: compare users/groups before sending update request (cherry-pick #18456 to version-2025.10) (#18465)
- root: fix missing authentik_device cookie causing error (cherry-pick #18642 to version-2025.10) (#18644)
- root: skip current tab when refreshing others (cherry-pick #18674 to version-2025.10) (#18675)
- sources/ldap: make server info optional (cherry-pick #18648 to version-2025.10) (#18654)
- stages/prompt: set allow_blank for \_read_only fields (cherry-pick #18297 to version-2025.10) (#18406)
- web: Fix row expansion on modal trigger buttons. (cherry-pick #18412 to version-2025.10) (#18647)
- web: Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18373)
- web: Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18408)
- web: Hide device picker when challenges are not present. (cherry-pick #18611 to version-2025.10) (#18681)
- web: Improved table selection behavior (cherry-pick #18622 to version-2025.10) (#18685)
- web: revert Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18407)
- web/admin: add entitlement search (cherry-pick #18291 to version-2025.10) (#18390)
- web/admin: fix brands default switch label (cherry-pick #18518 to version-2025.10) (#18522)
- web/admin: fix event volume chart not updating with query (cherry-pick #18649 to version-2025.10) (#18653)
- web/admin: fix wording in password stage (cherry-pick #18393 to version-2025.10) (#18395)
- web/admin: fixes capitalization in application wizard title (cherry-pick #17959 to version-2025.10) (#17962)
## API Changes
#### What's Changed

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@ To assign or remove _object_ permissions for a specific role:
2. Select a specific role by clicking on the role's name.
3. Click the **Permissions** tab at the top of the page, then click the **Permissions on this object** tab
4. To assign permissions that another _role_ has on this specific role:
1. Click **Assign role permissions**.
1. Click **Assign Object Permission**.
2. In the **Role** drop-down, select the role object.
3. Use the toggles to set which permissions on that selected role object you want to grant to the specific role.
4. Click **Assign** to save your settings and close the box.