Compare commits

..

10 Commits

Author SHA1 Message Date
Marcelo Elizeche Landó
264ad31f50 refactor for bandit alerts 2026-04-30 02:39:29 -03:00
Marcelo Elizeche Landó
cced288ddb Add polling to shutdown.wait in lifecycle/worker_process.py 2026-04-30 01:35:06 -03:00
Marcelo Elizeche Landó
4aa323bc20 Add debug-attach to Makefile implementing Python 3.14 remote debugging interface 2026-04-30 00:12:01 -03:00
Teffen Ellis
d6c0ae21de web: Clear remember me before navigation. (#21647)
* web: Clear remember me before navigation.

* web: fix stray > in "Not you?" link and add Playwright regression for #21571

Move the closing > of the opening <a> tag so the rendered link text no longer
carries a leading > glyph. Add a browser test that seeds the identification
stage with enable_remember_me, walks the identify -> password -> "Not you?"
path, and asserts the link text, the cleared username field, and the cleared
remember-me localStorage key.
Co-Authored-By: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>

* Flesh out remember me lifecycle. Fix edgecases where it doesn't keep up with the e2e suite.

* Fix for submit events, labels.

---------

Co-authored-by: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>
2026-04-29 23:54:42 +02:00
dependabot[bot]
2c35df35b6 web: bump knip from 6.4.1 to 6.6.0 in /web (#21957)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.4.1 to 6.6.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.6.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:37:12 +02:00
dependabot[bot]
90d4f4296b core: bump github.com/getsentry/sentry-go from 0.45.1 to 0.46.0 (#21955)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.45.1 to 0.46.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.45.1...v0.46.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:36:43 +02:00
dependabot[bot]
bf7747268b core: bump uvicorn[standard] from 0.44.0 to 0.45.0 (#21956)
Bumps [uvicorn[standard]](https://github.com/Kludex/uvicorn) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.44.0...0.45.0)

---
updated-dependencies:
- dependency-name: uvicorn[standard]
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:35:09 +02:00
dependabot[bot]
552cb78458 core: bump rustls from 0.23.39 to 0.23.40 (#21958)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.39 to 0.23.40.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 12:34:27 +02:00
Dominic R
899994027d core: support hashed password in users API + automated install (#18686)
* core: add hash_password command and password_hash bootstrap support

* core: prevent hash format exposure in validation error

* core: remove redundant password length check

* core: remove extra blank lines from hash_password command

* core: add password_hash serializer tests, refine validation and imports

* core: add null password fields test, add hash warning to docs

* core: move hash validation to User.set_password_from_hash method

* core: emit password_changed signal in set_password_from_hash

* website: remove redundant hash security warning

* core: wrap conflict error message for translation

* core: wrap invalid hash error message for translation

* web, core: add set_password_hash API endpoint and admin UI

* core: simplify password_hash check to None comparison

* core: use None check for password conflict validation

* website: clarify Docker Compose $ escaping for .env vs compose.yml

* website: lint

* web: lint

* core: add nosec comment for empty password string in signal

* core: lint

* web: Fix Password Hash help text

* sources/kerberos,ldap: Gergo's review

* add testing for ^^ and type fix

* more general signal tests; not provider specific

* only used in tests

* add warning

* we can do this

* signals fix????

* core, web, website: review fixes

* style(docs): format automated install guide

* web: restore modal invoker import after rebase

Co-authored-by: Codex <codex@openai.com>

* fix generated clients

* core: trim hash password command tests

* core: add password hash permission

* core: cover service account password hashes

* web: remove password hash form

* core: regenerate password hash migration

* core: reuse password serializer for hashes

* docs: clarify hashed password imports

* Regenerate

* core: deduplicate user serializer writes

* core: deduplicate password update actions

* core: deduplicate password change signaling

* tests: reuse password hash API helper

* tests: reuse SSF credential assertions

* docs: centralize hashed password caveat

* core: name password hash signal source

* core: centralize password hash validation

* core: deduplicate serializer password saves

* docs: link source writeback caveats

* api: clarify password hash request field

* tests: deduplicate password hash API assertions

* web: reuse user display-name helper

* web: use existing user display formatter

* core: reuse reset password permission for hash endpoint

* core: keep separate password hash serializer

* tests: remove redundant password hash permission test

* 21745

Co-authored-by: Gergo <gergo@goauthentik.io>

* core: preserve empty password handling in user serializer

* core: inline blueprint user serializer fields

* Use password hash constant

* Simplify user serializer flow

* Inline password update handling

* Apply serializer cleanup

* Clean blueprint password handling

* Drop extra returns

* Split password hash signal

* Align hash signal receivers

* Remove stale password guards

* Inline password signal

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Gergo <gergo@goauthentik.io>
2026-04-29 06:27:59 +02:00
authentik-automation[bot]
99250b0498 core, web: update translations (#21952)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-04-29 04:08:12 +02:00
79 changed files with 1961 additions and 652 deletions

4
Cargo.lock generated
View File

@@ -3000,9 +3000,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.39"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",

View File

@@ -66,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"query",
"rustls",
] }
rustls = { version = "= 0.23.39", features = ["fips"] }
rustls = { version = "= 0.23.40", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
"backtrace",
"contexts",

View File

@@ -118,6 +118,9 @@ run-worker: ## Run the main authentik worker process
run-worker-watch: ## Run the authentik worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
debug-attach: ## Attach pdb to a running authentik Python worker (PEP 768). PID=<pid> to pick; SUDO=1 on macOS.
$(UV) run python scripts/debug_attach.py
core-i18n-extract:
$(UV) run ak makemessages \
--add-location file \

View File

@@ -14,6 +14,7 @@ from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django_filters.filters import (
BooleanFilter,
CharFilter,
@@ -106,6 +107,10 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy(
"Invalid password hash format. Must be a valid Django password hash."
)
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
@@ -190,47 +195,79 @@ class UserSerializer(ModelSerializer):
return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs):
"""Setting password and permissions directly is allowed only in blueprints."""
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["password_hash"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False,
child=ChoiceField(choices=get_permission_choices()),
)
def create(self, validated_data: dict) -> User:
"""If this serializer is used in the blueprint context, we allow for
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
"""Create a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
instance: User = super().create(validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
return instance
def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
"""Update a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
instance = super().update(instance, validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
return instance
def _set_password(self, instance: User, password: str | None):
"""Set password of user if we're in a blueprint context, and if it's an empty
string then use an unusable password"""
if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password:
def _validate_password_inputs(self, password: str | None, password_hash: str | None):
"""Validate mutually-exclusive password inputs before any model mutation."""
if password is not None and password_hash is not None:
raise ValidationError(_("Cannot set both password and password_hash. Use only one."))
if password_hash is None:
return
try:
User.validate_password_hash(password_hash)
except ValueError as exc:
LOGGER.warning("Failed to identify password hash format", exc_info=exc)
raise ValidationError(INVALID_PASSWORD_HASH_MESSAGE) from exc
def _set_password(self, instance: User, password: str | None, password_hash: str | None = None):
"""Set password from plain text or hash."""
if password_hash is not None:
instance.set_password_from_hash(password_hash)
instance.save()
elif password:
instance.set_password(password)
instance.save()
def _ensure_password_not_empty(self, instance: User):
"""Store an explicit unusable password instead of an empty password field."""
if len(instance.password) == 0:
instance.set_unusable_password()
instance.save()
@@ -399,6 +436,12 @@ class UserPasswordSetSerializer(PassiveSerializer):
password = CharField(required=True)
class UserPasswordHashSetSerializer(PassiveSerializer):
"""Payload to set a users' password hash directly"""
password = CharField(required=True)
class UserServiceAccountSerializer(PassiveSerializer):
"""Payload to create a service account"""
@@ -742,6 +785,11 @@ class UserViewSet(
self.request.session.modified = True
return Response(serializer.initial_data)
def _update_session_hash_after_password_change(self, request: Request, user: User):
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordSetSerializer,
@@ -765,9 +813,45 @@ class UserViewSet(
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)
return Response(status=400)
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
self._update_session_hash_after_password_change(request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordHashSetSerializer,
responses={
204: OpenApiResponse(description="Successfully changed password"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
methods=["POST"],
permission_classes=[IsAuthenticated],
)
@validate(UserPasswordHashSetSerializer)
def set_password_hash(
self, request: Request, pk: int, body: UserPasswordHashSetSerializer
) -> Response:
"""Set a user's password from a pre-hashed Django password value.
Submit the Django password hash in the shared ``password`` request field.
This updates authentik's local password verifier only. It does not attempt
to propagate the password change to LDAP or Kerberos because no raw password
is available from the request payload.
"""
user: User = self.get_object()
try:
user.set_password_from_hash(body.validated_data["password"], request=request)
user.save()
except ValueError as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(data={"password": [INVALID_PASSWORD_HASH_MESSAGE]}, status=400)
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(status=400)
self._update_session_hash_after_password_change(request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")

View File

@@ -0,0 +1,28 @@
"""Hash password using Django's password hashers"""
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
"""Hash a password using Django's password hashers"""
help = "Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"
def add_arguments(self, parser):
parser.add_argument(
"password",
type=str,
help="Password to hash",
)
def handle(self, *args, **options):
password = options["password"]
if not password:
raise CommandError("Password cannot be empty")
try:
hashed = make_password(password)
self.stdout.write(hashed)
except ValueError as exc:
raise CommandError(f"Error hashing password: {exc}") from exc

View File

@@ -10,7 +10,7 @@ from uuid import uuid4
import pgtrigger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.hashers import check_password, identify_hasher
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
@@ -560,6 +560,33 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
self.password_change_date = now()
return super().set_password(raw_password)
@staticmethod
def validate_password_hash(password_hash: str):
"""Validate that the value is a recognized Django password hash."""
identify_hasher(password_hash) # Raises ValueError if invalid
def set_password_from_hash(self, password_hash: str, signal=True, sender=None, request=None):
"""Set password directly from a pre-hashed value.
Unlike set_password(), this does not hash the input again. The provided value
must already be a valid Django password hash, and it is stored directly on the
user after validation.
Because no raw password is available, downstream password sync integrations
such as LDAP and Kerberos cannot be updated from this code path.
Raises ValueError if the hash format is not recognized.
"""
self.validate_password_hash(password_hash)
if self.pk and signal:
from authentik.core.signals import password_hash_changed
if not sender:
sender = self
password_hash_changed.send(sender=sender, user=self, request=request)
self.password = password_hash
self.password_change_date = now()
def check_password(self, raw_password: str) -> bool:
"""
Return a boolean of whether the raw_password was correct. Handles

View File

@@ -16,7 +16,11 @@ LOGGER = get_logger()
@receiver(post_startup)
def post_startup_setup_bootstrap(sender, **_):
if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"):
if (
not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD")
and not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH")
and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN")
):
return
LOGGER.info("Configuring authentik through bootstrap environment variables")
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()

View File

@@ -24,6 +24,8 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
# Arguments: user: User, request: HttpRequest | None
password_hash_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any]
login_failed = Signal()

View File

@@ -0,0 +1,28 @@
"""Tests for hash_password management command."""
from io import StringIO
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
class TestHashPasswordCommand(TestCase):
"""Test hash_password management command."""
def test_hash_password(self):
"""Test hashing a password."""
out = StringIO()
call_command("hash_password", "test123", stdout=out)
hashed = out.getvalue().strip()
self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password("test123", hashed))
def test_hash_password_empty_fails(self):
"""Test that empty password raises error."""
with self.assertRaises(CommandError) as ctx:
call_command("hash_password", "")
self.assertIn("Password cannot be empty", str(ctx.exception))

View File

@@ -1,6 +1,7 @@
from http import HTTPStatus
from os import environ
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
@@ -16,6 +17,7 @@ from authentik.tenants.flags import patch_flag
class TestSetup(FlowTestCase):
def tearDown(self):
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH", None)
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
@patch_flag(Setup, True)
@@ -154,3 +156,19 @@ class TestSetup(FlowTestCase):
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])
def test_setup_bootstrap_env_password_hash(self):
"""Test setup with password hash env var"""
User.objects.filter(username="akadmin").delete()
Setup.set(False)
password = generate_id()
password_hash = make_password(password)
environ["AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"] = password_hash
pre_startup.send(sender=self)
post_startup.send(sender=self)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))

View File

@@ -1,8 +1,15 @@
"""user tests"""
from django.test.testcases import TestCase
from unittest.mock import patch
from django.contrib.auth.hashers import make_password
from django.test.testcases import TestCase
from rest_framework.exceptions import ValidationError
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.core.signals import password_changed, password_hash_changed
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -33,3 +40,99 @@ class TestUsers(TestCase):
self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
"""Test hash password updates do not expose a raw password to sync receivers."""
user = User.objects.create(
username=generate_id(),
attributes={"distinguishedName": "cn=test,ou=users,dc=example,dc=com"},
)
password_changed_captured = []
password_hash_changed_captured = []
dispatch_uid = generate_id()
hash_dispatch_uid = generate_id()
def password_changed_receiver(sender, **kwargs):
password_changed_captured.append(kwargs)
def password_hash_changed_receiver(sender, **kwargs):
password_hash_changed_captured.append(kwargs)
password_changed.connect(password_changed_receiver, dispatch_uid=dispatch_uid)
password_hash_changed.connect(
password_hash_changed_receiver, dispatch_uid=hash_dispatch_uid
)
try:
with (
patch(
"authentik.sources.ldap.signals.LDAPSource.objects.filter"
) as ldap_sources_filter,
patch(
"authentik.sources.kerberos.signals."
"UserKerberosSourceConnection.objects.select_related"
) as kerberos_connections_select,
):
user.set_password_from_hash(make_password("new-password")) # nosec
user.save()
finally:
password_changed.disconnect(dispatch_uid=dispatch_uid)
password_hash_changed.disconnect(dispatch_uid=hash_dispatch_uid)
self.assertEqual(password_changed_captured, [])
self.assertEqual(len(password_hash_changed_captured), 1)
ldap_sources_filter.assert_not_called()
kerberos_connections_select.assert_not_called()
class TestUserSerializerPasswordHash(TestCase):
"""Test UserSerializer password_hash support in blueprint context."""
def test_password_hash_sets_password_directly(self):
"""Test a valid password hash is stored without re-hashing."""
password = "test-password-123" # nosec
password_hash = make_password(password)
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": password_hash,
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
user = serializer.save()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
self.assertIsNotNone(user.password_change_date)
def test_password_hash_rejects_invalid_format(self):
"""Test invalid password hash values are rejected."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": "not-a-valid-hash",
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
with self.assertRaises(ValidationError) as ctx:
serializer.save()
self.assertIn("Invalid password hash format", str(ctx.exception))
def test_password_hash_ignored_outside_blueprint_context(self):
"""Test password_hash is not accepted by the regular serializer."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": make_password("test"), # nosec
}
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertNotIn("password_hash", serializer.validated_data)

View File

@@ -3,6 +3,7 @@
from datetime import datetime, timedelta
from json import loads
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
@@ -26,6 +27,9 @@ from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignatio
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
INVALID_PASSWORD_HASH = "not-a-valid-hash"
INVALID_PASSWORD_HASH_ERROR = "Invalid password hash format. Must be a valid Django password hash."
class TestUsersAPI(APITestCase):
"""Test Users API"""
@@ -34,6 +38,20 @@ class TestUsersAPI(APITestCase):
self.admin = create_test_admin_user()
self.user = create_test_user()
def _set_password_hash(self, user: User, password_hash: str, client=None):
return (client or self.client).post(
reverse("authentik_api:user-set-password-hash", kwargs={"pk": user.pk}),
data={"password": password_hash},
)
def _assert_password_hash_set(
self, user: User, password: str, password_hash: str, response
) -> None:
self.assertEqual(response.status_code, 204, response.data)
user.refresh_from_db()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
def test_filter_type(self):
"""Test API filtering by type"""
self.client.force_login(self.admin)
@@ -113,6 +131,26 @@ class TestUsersAPI(APITestCase):
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
def test_set_password_hash(self):
"""Test setting a user's password from a hash."""
self.client.force_login(self.admin)
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(self.user, password_hash)
self._assert_password_hash_set(self.user, password, password_hash, response)
def test_set_password_hash_invalid(self):
"""Test invalid password hashes are rejected."""
self.client.force_login(self.admin)
response = self._set_password_hash(self.user, INVALID_PASSWORD_HASH)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{"password": [INVALID_PASSWORD_HASH_ERROR]},
)
def test_recovery(self):
"""Test user recovery link"""
flow = create_test_flow(
@@ -261,6 +299,29 @@ class TestUsersAPI(APITestCase):
self.assertTrue(token_filter.exists())
self.assertTrue(token_filter.first().expiring)
def test_service_account_set_password_hash(self):
"""Service account password hash can be set through the API."""
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-service-account"),
data={
"name": "test-sa",
"create_group": False,
},
)
self.assertEqual(response.status_code, 200, response.data)
body = loads(response.content)
user = User.objects.get(pk=body["user_pk"])
self.assertEqual(user.type, UserTypes.SERVICE_ACCOUNT)
self.assertFalse(user.has_usable_password())
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(user, password_hash)
self._assert_password_hash_set(user, password, password_hash, response)
def test_service_account_no_expire(self):
"""Service account creation without token expiration"""
self.client.force_login(self.admin)

View File

@@ -12,7 +12,7 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.signals import password_changed
from authentik.core.signals import password_changed, password_hash_changed
from authentik.enterprise.providers.ssf.models import (
EventTypes,
SSFProvider,
@@ -84,14 +84,13 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
)
@receiver(password_changed)
def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_):
def _send_password_credential_change(user: User, change_type: str):
"""Credential change trigger (password changed)"""
send_ssf_events(
EventTypes.CAEP_CREDENTIAL_CHANGE,
{
"credential_type": "password",
"change_type": "revoke" if password is None else "update",
"change_type": change_type,
},
sub_id={
"format": "complex",
@@ -103,6 +102,16 @@ def ssf_password_changed_cred_change(sender, user: User, password: str | None, *
)
@receiver(password_hash_changed)
@receiver(password_changed)
def ssf_password_changed_cred_change(signal, sender, user: User, password: str | None = None, **_):
"""Credential change trigger (password changed)"""
if signal is password_hash_changed:
_send_password_credential_change(user, "update")
return
_send_password_credential_change(user, "revoke" if password is None else "update")
device_type_map = {
StaticDevice: "pin",
TOTPDevice: "pin",

View File

@@ -1,5 +1,6 @@
from uuid import uuid4
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -52,6 +53,21 @@ class TestSignals(APITestCase):
)
self.assertEqual(res.status_code, 201, res.content)
def _assert_password_credential_change(self, user, change_type: str):
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], change_type)
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
def test_signal_logout(self):
"""Test user logout"""
user = create_test_user()
@@ -79,19 +95,25 @@ class TestSignals(APITestCase):
user.set_password(generate_id())
user.save()
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], "update")
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
self._assert_password_credential_change(user, "update")
def test_signal_password_change_from_hash(self):
"""Test user password change from a pre-hashed password."""
user = create_test_user()
self.client.force_login(user)
user.set_password_from_hash(make_password(generate_id()))
user.save()
self._assert_password_credential_change(user, "update")
def test_signal_password_revoke(self):
"""Test explicit password revoke."""
user = create_test_user()
self.client.force_login(user)
user.set_password(None)
user.save()
self._assert_password_credential_change(user, "revoke")
def test_signal_authenticator_added(self):
"""Test authenticator creation signal"""

View File

@@ -11,7 +11,7 @@ from django.http import HttpRequest
from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User
from authentik.core.signals import login_failed, password_changed
from authentik.core.signals import login_failed, password_changed, password_hash_changed
from authentik.events.models import Event, EventAction
from authentik.flows.models import Stage
from authentik.flows.planner import (
@@ -112,8 +112,15 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
)
@receiver(password_hash_changed)
@receiver(password_changed)
def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_):
def on_password_changed(
sender,
user: User,
password: str | None = None,
request: HttpRequest | None = None,
**_,
):
"""Log password change"""
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user)

View File

@@ -2,6 +2,7 @@
from urllib.parse import urlencode
from django.contrib.auth.hashers import make_password
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.views.debug import SafeExceptionReporterFilter
@@ -10,7 +11,7 @@ from guardian.shortcuts import get_anonymous_user
from authentik.brands.models import Brand
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event
from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
@@ -213,3 +214,14 @@ class TestEvents(TestCase):
event = Event.new("unittest", foo="foo bar \u0000 baz")
event.save()
self.assertEqual(event.context["foo"], "foo bar baz")
def test_password_set_signal_on_set_password_from_hash(self):
"""Changing password from hash should still emit an audit event."""
user = create_test_user()
old_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
user.set_password_from_hash(make_password(generate_id()))
user.save()
new_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
self.assertEqual(new_count, old_count + 1)

View File

@@ -61,11 +61,6 @@ class SAMLProviderSerializer(ProviderSerializer):
url_download_metadata = SerializerMethodField()
url_issuer = SerializerMethodField()
# Unified SAML endpoint (primary)
url_unified = SerializerMethodField()
url_unified_init = SerializerMethodField()
# Legacy endpoints (for backward compatibility)
url_sso_post = SerializerMethodField()
url_sso_redirect = SerializerMethodField()
url_sso_init = SerializerMethodField()
@@ -112,36 +107,6 @@ class SAMLProviderSerializer(ProviderSerializer):
except Provider.application.RelatedObjectDoesNotExist:
return DEFAULT_ISSUER
def get_url_unified(self, instance: SAMLProvider) -> str:
"""Get unified SAML endpoint URL (handles SSO and SLO)"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_unified_init(self, instance: SAMLProvider) -> str:
"""Get IdP-initiated SAML URL"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:init",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL"""
if "request" not in self._context:
@@ -278,8 +243,6 @@ class SAMLProviderSerializer(ProviderSerializer):
"default_name_id_policy",
"url_download_metadata",
"url_issuer",
"url_unified",
"url_unified_init",
"url_sso_post",
"url_sso_redirect",
"url_sso_init",

View File

@@ -241,7 +241,7 @@ class SAMLProvider(Provider):
"""Use IDP-Initiated SAML flow as launch URL"""
try:
return reverse(
"authentik_providers_saml:init",
"authentik_providers_saml:sso-init",
kwargs={"application_slug": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:

View File

@@ -81,35 +81,54 @@ class MetadataProcessor:
element.text = name_id_format
yield element
def _get_unified_url(self) -> str:
"""Get the unified SAML endpoint URL"""
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
def get_sso_bindings(self) -> Iterator[Element]:
"""Get all SSO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element.attrib["Binding"] = binding
element.attrib["Location"] = unified_url
element.attrib["Location"] = url
yield element
def get_slo_bindings(self) -> Iterator[Element]:
"""Get all SLO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService")
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element.attrib["Binding"] = binding
element.attrib["Location"] = unified_url
element.attrib["Location"] = url
yield element
def _prepare_signature(self, entity_descriptor: _Element):

View File

@@ -4,26 +4,19 @@ from django.urls import path
from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet
from authentik.providers.saml.api.providers import SAMLProviderViewSet
from authentik.providers.saml.views import metadata, sso, unified
from authentik.providers.saml.views import metadata, sso
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
urlpatterns = [
# Unified Endpoint - handles SSO and SLO based on message type
# Base path for Issuer/Entity ID
path(
"<slug:application_slug>/",
unified.SAMLUnifiedView.as_view(),
sso.SAMLSSOBindingRedirectView.as_view(),
name="base",
),
# IdP-initiated
path(
"<slug:application_slug>/init/",
sso.SAMLSSOBindingInitView.as_view(),
name="init",
),
# LEGACY Endpoints (backward compatibility)
# SSO Bindings
path(
"<slug:application_slug>/sso/binding/redirect/",

View File

@@ -1,118 +0,0 @@
"""Unified SAML endpoint - handles SSO and SLO based on message type"""
from base64 import b64decode
from defusedxml.lxml import fromstring
from django.http import HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.constants import NS_MAP
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.providers.saml.views.flows import (
REQUEST_KEY_SAML_REQUEST,
REQUEST_KEY_SAML_RESPONSE,
)
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
from authentik.providers.saml.views.sso import (
SAMLSSOBindingPOSTView,
SAMLSSOBindingRedirectView,
)
LOGGER = get_logger()
# SAML message type constants
SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest"
SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest"
def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None:
"""Parse SAML request to determine if AuthnRequest or LogoutRequest."""
try:
if is_post_binding:
decoded_xml = b64decode(saml_request.encode())
else:
decoded_xml = decode_base64_and_inflate(saml_request)
root = fromstring(decoded_xml)
if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_AUTHN_REQUEST
if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_LOGOUT_REQUEST
return None
except Exception: # noqa: BLE001
return None
@method_decorator(xframe_options_sameorigin, name="dispatch")
@method_decorator(csrf_exempt, name="dispatch")
class SAMLUnifiedView(View):
"""Unified SAML endpoint - handles SSO and SLO based on message type.
The operation type is determined by parsing
the incoming SAML message:
- AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView)
- LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
- LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
"""
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Route the request based on SAML message type."""
# ak user was not logged in, redirected to login, and is back w POST payload in session
if SESSION_KEY_POST in request.session:
return self._delegate_to_sso(request, application_slug, is_post_binding=True)
# Determine binding from HTTP method
is_post_binding = request.method == "POST"
data = request.POST if is_post_binding else request.GET
# LogoutResponse - delegate to SLO view (handles it in dispatch)
if REQUEST_KEY_SAML_RESPONSE in data:
return self._delegate_to_slo(request, application_slug, is_post_binding)
# Check for SAML request
if REQUEST_KEY_SAML_REQUEST not in data:
LOGGER.info("SAML payload missing")
return bad_request_message(request, "The SAML request payload is missing.")
# Detect message type and delegate
saml_request = data[REQUEST_KEY_SAML_REQUEST]
message_type = detect_saml_message_type(saml_request, is_post_binding)
if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST:
return self._delegate_to_sso(request, application_slug, is_post_binding)
elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST:
return self._delegate_to_slo(request, application_slug, is_post_binding)
else:
LOGGER.warning("Unknown SAML message type", message_type=message_type)
return bad_request_message(
request, f"Unsupported SAML message type: {message_type or 'unknown'}"
)
def _delegate_to_sso(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SSO view."""
if is_post_binding:
view = SAMLSSOBindingPOSTView.as_view()
else:
view = SAMLSSOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)
def _delegate_to_slo(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SLO view."""
if is_post_binding:
view = SPInitiatedSLOBindingPOSTView.as_view()
else:
view = SPInitiatedSLOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)

View File

@@ -5537,6 +5537,14 @@
"minLength": 1,
"title": "Password"
},
"password_hash": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Password hash"
},
"permissions": {
"type": "array",
"items": {

View File

@@ -11,6 +11,7 @@ context:
group_name: authentik Admins
email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "root@example.com"]
password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null]
password_hash: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD_HASH, null]
token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null]
entries:
- model: authentik_core.group
@@ -31,6 +32,7 @@ entries:
groups:
- !KeyOf admin-group
password: !Context password
password_hash: !Context password_hash
- model: authentik_core.token
state: created
conditions:

2
go.mod
View File

@@ -7,7 +7,7 @@ require (
beryju.io/radius-eap v0.1.0
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/getsentry/sentry-go v0.45.1
github.com/getsentry/sentry-go v0.46.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-openapi/runtime v0.29.4

4
go.sum
View File

@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw=
github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import faulthandler
import os
import random
import signal
@@ -76,6 +77,12 @@ def main(worker_id: int, socket_path: str):
signal.signal(signal.SIGINT, immediate_shutdown)
signal.signal(signal.SIGQUIT, immediate_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
# SIGUSR1 dumps every thread's traceback to stderr. Without this, the default
# action is "terminate", which kills the worker (and trips the Rust supervisor).
# Side-benefit: signal delivery wakes the eval loop, so `pdb -p` can attach to
# an otherwise-idle worker parked in a C-level syscall.
faulthandler.enable()
faulthandler.register(signal.SIGUSR1)
random.seed()
@@ -97,7 +104,11 @@ def main(worker_id: int, socket_path: str):
# Notify rust process that we are ready
os.kill(os.getppid(), signal.SIGUSR2)
shutdown.wait()
# Poll instead of waiting indefinitely so the main thread's eval loop ticks
# periodically — PEP 768's debugger pending hook is serviced on the main
# thread, and a permanent Event.wait() never returns to bytecode execution.
while not shutdown.wait(timeout=1.0):
pass
logger.info("Shutting down worker...")

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 00:30+0000\n"
"POT-Creation-Date: 2026-04-29 00:28+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -392,6 +392,10 @@ msgstr ""
msgid "Open launch URL in a new browser tab or window."
msgstr ""
#: authentik/core/models.py
msgid "Hide this application from the user's My applications page."
msgstr ""
#: authentik/core/models.py
msgid "Application"
msgstr ""
@@ -2491,7 +2495,9 @@ msgid ""
msgstr ""
#: authentik/providers/saml/models.py
msgid "Also known as EntityID"
msgid ""
"Also known as EntityID. Providing a value overrides the default issuer "
"generated by authentik."
msgstr ""
#: authentik/providers/saml/models.py
@@ -2685,6 +2691,10 @@ msgstr ""
msgid "SAML NameID format"
msgstr ""
#: authentik/providers/saml/models.py
msgid "SAML Issuer used for this session"
msgstr ""
#: authentik/providers/saml/models.py
msgid "SAML Session"
msgstr ""

Binary file not shown.

View File

@@ -54,6 +54,7 @@ import type {
User,
UserAccountRequest,
UserConsent,
UserPasswordHashSetRequest,
UserPasswordSetRequest,
UserPath,
UserRecoveryEmailRequest,
@@ -104,6 +105,7 @@ import {
UserAccountRequestToJSON,
UserConsentFromJSON,
UserFromJSON,
UserPasswordHashSetRequestToJSON,
UserPasswordSetRequestToJSON,
UserPathFromJSON,
UserRecoveryEmailRequestToJSON,
@@ -508,6 +510,11 @@ export interface CoreUsersSetPasswordCreateRequest {
userPasswordSetRequest: UserPasswordSetRequest;
}
export interface CoreUsersSetPasswordHashCreateRequest {
id: number;
userPasswordHashSetRequest: UserPasswordHashSetRequest;
}
export interface CoreUsersUpdateRequest {
id: number;
userRequest: UserRequest;
@@ -5288,6 +5295,77 @@ export class CoreApi extends runtime.BaseAPI {
await this.coreUsersSetPasswordCreateRaw(requestParameters, initOverrides);
}
/**
* Creates request options for coreUsersSetPasswordHashCreate without sending the request
*/
async coreUsersSetPasswordHashCreateRequestOpts(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["id"] == null) {
throw new runtime.RequiredError(
"id",
'Required parameter "id" was null or undefined when calling coreUsersSetPasswordHashCreate().',
);
}
if (requestParameters["userPasswordHashSetRequest"] == null) {
throw new runtime.RequiredError(
"userPasswordHashSetRequest",
'Required parameter "userPasswordHashSetRequest" was null or undefined when calling coreUsersSetPasswordHashCreate().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/core/users/{id}/set_password_hash/`;
urlPath = urlPath.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters["id"])));
return {
path: urlPath,
method: "POST",
headers: headerParameters,
query: queryParameters,
body: UserPasswordHashSetRequestToJSON(requestParameters["userPasswordHashSetRequest"]),
};
}
/**
* Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload.
*/
async coreUsersSetPasswordHashCreateRaw(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<void>> {
const requestOptions =
await this.coreUsersSetPasswordHashCreateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.VoidApiResponse(response);
}
/**
* Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload.
*/
async coreUsersSetPasswordHashCreate(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<void> {
await this.coreUsersSetPasswordHashCreateRaw(requestParameters, initOverrides);
}
/**
* Creates request options for coreUsersUpdate without sending the request
*/

View File

@@ -266,18 +266,6 @@ export interface SAMLProvider {
* @memberof SAMLProvider
*/
readonly urlIssuer: string;
/**
* Get unified SAML endpoint URL (handles SSO and SLO)
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnified: string;
/**
* Get IdP-initiated SAML URL
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnifiedInit: string;
/**
* Get SSO Post URL
* @type {string}
@@ -340,8 +328,6 @@ export function instanceOfSAMLProvider(value: object): value is SAMLProvider {
if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined)
return false;
if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false;
if (!("urlUnified" in value) || value["urlUnified"] === undefined) return false;
if (!("urlUnifiedInit" in value) || value["urlUnifiedInit"] === undefined) return false;
if (!("urlSsoPost" in value) || value["urlSsoPost"] === undefined) return false;
if (!("urlSsoRedirect" in value) || value["urlSsoRedirect"] === undefined) return false;
if (!("urlSsoInit" in value) || value["urlSsoInit"] === undefined) return false;
@@ -428,8 +414,6 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
: SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]),
urlDownloadMetadata: json["url_download_metadata"],
urlIssuer: json["url_issuer"],
urlUnified: json["url_unified"],
urlUnifiedInit: json["url_unified_init"],
urlSsoPost: json["url_sso_post"],
urlSsoRedirect: json["url_sso_redirect"],
urlSsoInit: json["url_sso_init"],
@@ -456,8 +440,6 @@ export function SAMLProviderToJSONTyped(
| "meta_model_name"
| "url_download_metadata"
| "url_issuer"
| "url_unified"
| "url_unified_init"
| "url_sso_post"
| "url_sso_redirect"
| "url_sso_init"

View File

@@ -0,0 +1,70 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Payload to set a users' password hash directly
* @export
* @interface UserPasswordHashSetRequest
*/
export interface UserPasswordHashSetRequest {
/**
*
* @type {string}
* @memberof UserPasswordHashSetRequest
*/
password: string;
}
/**
* Check if a given object implements the UserPasswordHashSetRequest interface.
*/
export function instanceOfUserPasswordHashSetRequest(
value: object,
): value is UserPasswordHashSetRequest {
if (!("password" in value) || value["password"] === undefined) return false;
return true;
}
export function UserPasswordHashSetRequestFromJSON(json: any): UserPasswordHashSetRequest {
return UserPasswordHashSetRequestFromJSONTyped(json, false);
}
export function UserPasswordHashSetRequestFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): UserPasswordHashSetRequest {
if (json == null) {
return json;
}
return {
password: json["password"],
};
}
export function UserPasswordHashSetRequestToJSON(json: any): UserPasswordHashSetRequest {
return UserPasswordHashSetRequestToJSONTyped(json, false);
}
export function UserPasswordHashSetRequestToJSONTyped(
value?: UserPasswordHashSetRequest | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
password: value["password"],
};
}

View File

@@ -842,6 +842,7 @@ export * from "./UserLogoutStageRequest";
export * from "./UserMatchingModeEnum";
export * from "./UserOAuthSourceConnection";
export * from "./UserOAuthSourceConnectionRequest";
export * from "./UserPasswordHashSetRequest";
export * from "./UserPasswordSetRequest";
export * from "./UserPath";
export * from "./UserPlexSourceConnection";

View File

@@ -66,7 +66,7 @@ dependencies = [
"ua-parser==1.0.2",
"unidecode==1.4.0",
"urllib3<3",
"uvicorn[standard]==0.44.0",
"uvicorn[standard]==0.45.0",
"watchdog==6.0.0",
"webauthn==2.7.1",
"wsproto==1.3.2",

View File

@@ -4522,6 +4522,41 @@ paths:
description: Bad request
'403':
$ref: '#/components/responses/GenericErrorResponse'
/core/users/{id}/set_password_hash/:
post:
operationId: core_users_set_password_hash_create
description: |-
Set a user's password from a pre-hashed Django password value.
Submit the Django password hash in the shared ``password`` request field.
This updates authentik's local password verifier only. It does not attempt
to propagate the password change to LDAP or Kerberos because no raw password
is available from the request payload.
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserPasswordHashSetRequest'
required: true
security:
- authentik: []
responses:
'204':
description: Successfully changed password
'400':
description: Bad request
'403':
$ref: '#/components/responses/GenericErrorResponse'
/core/users/{id}/used_by/:
get:
operationId: core_users_used_by_list
@@ -53821,14 +53856,6 @@ components:
type: string
description: Get Issuer/EntityID URL
readOnly: true
url_unified:
type: string
description: Get unified SAML endpoint URL (handles SSO and SLO)
readOnly: true
url_unified_init:
type: string
description: Get IdP-initiated SAML URL
readOnly: true
url_sso_post:
type: string
description: Get SSO Post URL
@@ -53868,8 +53895,6 @@ components:
- url_sso_init
- url_sso_post
- url_sso_redirect
- url_unified
- url_unified_init
- verbose_name
- verbose_name_plural
SAMLProviderImportRequest:
@@ -57598,6 +57623,15 @@ components:
- identifier
- source
- user
UserPasswordHashSetRequest:
type: object
description: Payload to set a users' password hash directly
properties:
password:
type: string
minLength: 1
required:
- password
UserPasswordSetRequest:
type: object
description: Payload to set a users' password directly

91
scripts/debug_attach.py Normal file
View File

@@ -0,0 +1,91 @@
"""Attach pdb to a running authentik Python worker via PEP 768."""
import os
import shutil
import subprocess # nosec B404 — needed to launch ps and pdb
import sys
PS_BIN = shutil.which("ps") or "/bin/ps"
def list_python_procs() -> list[tuple[int, int, str]]:
# argv is fully controlled, no shell, ps path resolved at startup.
out = subprocess.check_output([PS_BIN, "-eo", "pid,ppid,command"], text=True) # nosec B603
procs: list[tuple[int, int, str]] = []
for line in out.splitlines()[1:]:
try:
pid_s, ppid_s, cmd = line.split(None, 2)
except ValueError:
continue
if not pid_s.isdigit() or not ppid_s.isdigit():
continue
procs.append((int(pid_s), int(ppid_s), cmd))
return procs
def find_targets() -> list[tuple[int, str]]:
# Match any authentik Python process: dev_server / runserver via manage.py,
# gunicorn, dramatiq, or the worker_process supervisor. Go/Rust supervisors
# and the `uv run` / shell wrappers don't match these patterns.
needles = (
"manage.py",
"manage dev_server",
"manage runserver",
"gunicorn",
"dramatiq",
"lifecycle.worker_process",
"lifecycle/worker_process",
)
matches = [(p, pp, c) for p, pp, c in list_python_procs() if any(n in c for n in needles)]
matched_pids = {p for p, _, _ in matches}
parents_of_matches = {pp for _, pp, _ in matches if pp in matched_pids}
# A leaf is a match that isn't itself the parent of another match — this
# picks the dev_server reloader child or the gunicorn worker, and still
# includes single-process workers (which trivially have no child match).
leaves = [(p, c) for p, _, c in matches if p not in parents_of_matches]
return leaves
def attach(pid: int) -> int:
use_sudo = os.environ.get("SUDO") == "1"
cmd = [sys.executable, "-m", "pdb", "-p", str(pid)]
if use_sudo:
cmd = ["sudo", "-E", *cmd]
print(f"attaching pdb to pid {pid} (Ctrl-D or `quit` to detach)", file=sys.stderr)
# cmd is built from sys.executable plus a digits-only PID.
rc = subprocess.call(cmd) # nosec B603
if rc != 0 and not use_sudo and sys.platform == "darwin":
print(
"\nattach failed. On macOS task_for_pid is restricted; "
f"retry with: SUDO=1 make debug-attach PID={pid}",
file=sys.stderr,
)
return rc
def main() -> int:
env_pid = os.environ.get("PID")
if env_pid:
if not env_pid.isdigit():
print(f"PID={env_pid!r} is not numeric", file=sys.stderr)
return 2
return attach(int(env_pid))
targets = find_targets()
if not targets:
print(
"no gunicorn/dramatiq Python workers found — is `make run-server` "
"or `make run-worker` running?",
file=sys.stderr,
)
return 1
if len(targets) > 1:
print("multiple worker candidates — pick one with PID=<pid>:", file=sys.stderr)
for pid, cmd in targets:
print(f" {pid}\t{cmd[:120]}", file=sys.stderr)
return 1
return attach(targets[0][0])
if __name__ == "__main__":
sys.exit(main())

8
uv.lock generated
View File

@@ -375,7 +375,7 @@ requires-dist = [
{ name = "ua-parser", specifier = "==1.0.2" },
{ name = "unidecode", specifier = "==1.4.0" },
{ name = "urllib3", specifier = "<3" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.44.0" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.45.0" },
{ name = "watchdog", specifier = "==6.0.0" },
{ name = "webauthn", specifier = "==2.7.1" },
{ name = "wsproto", specifier = "==1.3.2" },
@@ -3808,15 +3808,15 @@ socks = [
[[package]]
name = "uvicorn"
version = "0.44.0"
version = "0.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
{ url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" },
]
[package.optional-dependencies]

View File

@@ -77,6 +77,8 @@ export class FormFixture extends PageFixture {
/**
* Search for a row containing the given text.
*
* @returns A locator for the row entry matching the query.
*/
public search = async (
query: string,

View File

@@ -1,6 +1,8 @@
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
import { expect, Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
@@ -11,6 +13,8 @@ export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
rememberMe?: boolean;
page?: Page;
}
export interface SessionFixtureInit extends PageFixtureInit {
@@ -36,6 +40,10 @@ export class SessionFixture extends PageFixture {
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.page.getByLabel("Password");
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
name: "Remember me on this device",
});
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
@@ -66,19 +74,45 @@ export class SessionFixture extends PageFixture {
/**
* Log into the application.
*/
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
public async login(
{
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
rememberMe,
}: LoginInit = {},
page = this.page,
): Promise<void> {
this.logger.info("Logging in...");
const initialURL = new URL(this.page.url());
const initialURL = new URL(page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await this.page.goto(to.toString());
await page.goto(to.toString());
}
if (typeof rememberMe === "boolean") {
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
if (rememberMeCheckboxVisible) {
if (rememberMe) {
await this.$rememberMeCheckbox.check();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is checked",
).toBeChecked();
} else {
await this.$rememberMeCheckbox.uncheck();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is unchecked",
).not.toBeChecked();
}
}
}
await this.$usernameField.fill(username);
@@ -102,7 +136,7 @@ export class SessionFixture extends PageFixture {
//#region Navigation
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
public async toLoginPage(page: Page = this.page) {
await page.goto(SessionFixture.pathname);
}
}

225
web/package-lock.json generated
View File

@@ -78,7 +78,7 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^6.4.1",
"knip": "^6.6.0",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",
@@ -2141,9 +2141,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2224,9 +2224,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.121.0.tgz",
"integrity": "sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.126.0.tgz",
"integrity": "sha512-svyoHt25J4741QJ5aa4R+h0iiBeSRt63Lr3aAZcxy2c/NeSE1IfDeMnSij6rIg7EjxkdlXzz613wUjeCeilBNA==",
"cpu": [
"arm"
],
@@ -2240,9 +2240,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.121.0.tgz",
"integrity": "sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.126.0.tgz",
"integrity": "sha512-hPEBRKgplp1mG9GkINFsr4JVMDNrGJLOqfDaadTWpAoTnzYR5Rmv8RMvB3hJZpiNvbk1aacopdHUP1pggMQ/cw==",
"cpu": [
"arm64"
],
@@ -2256,9 +2256,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.121.0.tgz",
"integrity": "sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.126.0.tgz",
"integrity": "sha512-ccRpu9sdYmznePJQG5halhs0FW5tw5a8zRSoZXOzM1OjoeZ4jiRRruFiPclsD59edoVAK1l83dvfjWz1nQi6lg==",
"cpu": [
"arm64"
],
@@ -2272,9 +2272,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.121.0.tgz",
"integrity": "sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.126.0.tgz",
"integrity": "sha512-CHB4zVjNSKqx8Fw9pHowzQQnjjuq04i4Ng0Avj+DixlwhwAoMYqlFbocYIlbg+q3zOLGlm7vEHm83jqEMitnyg==",
"cpu": [
"x64"
],
@@ -2288,9 +2288,9 @@
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.121.0.tgz",
"integrity": "sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.126.0.tgz",
"integrity": "sha512-RQ3nEJdcDKBfBjmLJ3Vl1d0KQERPV1P8eUrnBm7+VTYyoaJSPLVFuPg1mlD1hk3n0/879VLFMfusFkBal4ssWQ==",
"cpu": [
"x64"
],
@@ -2304,9 +2304,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.121.0.tgz",
"integrity": "sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.126.0.tgz",
"integrity": "sha512-onipc2wCDA7Bauzb4KK1mab0GsEDf4ujiIfWECdnmY/2LlzAoX3xdQRLAUyEDB1kn3yilHBrkmXDdHluyHXxiw==",
"cpu": [
"arm"
],
@@ -2320,9 +2320,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.121.0.tgz",
"integrity": "sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.126.0.tgz",
"integrity": "sha512-5BuJJPohrV5NJ8lmcYOMbfRCUGoYH5J9HZHeuqOLwkHXWAuPMN3X1h8bC/2mWjmosdbfTtmyIdX3spS/TkqKNg==",
"cpu": [
"arm"
],
@@ -2336,9 +2336,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.121.0.tgz",
"integrity": "sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.126.0.tgz",
"integrity": "sha512-r2KApRgm2pOJaduRm6GOT8x0whcr67AyejNkSdzPt34GJ+Y3axcXN2mwlTs+8lfO/SSmpO5ZJGYiHYnxEE0jkw==",
"cpu": [
"arm64"
],
@@ -2352,9 +2352,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.121.0.tgz",
"integrity": "sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.126.0.tgz",
"integrity": "sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==",
"cpu": [
"arm64"
],
@@ -2368,9 +2368,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.121.0.tgz",
"integrity": "sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.126.0.tgz",
"integrity": "sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==",
"cpu": [
"ppc64"
],
@@ -2384,9 +2384,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.121.0.tgz",
"integrity": "sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.126.0.tgz",
"integrity": "sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==",
"cpu": [
"riscv64"
],
@@ -2400,9 +2400,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.121.0.tgz",
"integrity": "sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.126.0.tgz",
"integrity": "sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==",
"cpu": [
"riscv64"
],
@@ -2416,9 +2416,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.121.0.tgz",
"integrity": "sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.126.0.tgz",
"integrity": "sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==",
"cpu": [
"s390x"
],
@@ -2432,9 +2432,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.121.0.tgz",
"integrity": "sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.126.0.tgz",
"integrity": "sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==",
"cpu": [
"x64"
],
@@ -2448,9 +2448,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.121.0.tgz",
"integrity": "sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.126.0.tgz",
"integrity": "sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==",
"cpu": [
"x64"
],
@@ -2464,9 +2464,9 @@
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.121.0.tgz",
"integrity": "sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.126.0.tgz",
"integrity": "sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==",
"cpu": [
"arm64"
],
@@ -2480,25 +2480,27 @@
}
},
"node_modules/@oxc-parser/binding-wasm32-wasi": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.121.0.tgz",
"integrity": "sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.126.0.tgz",
"integrity": "sha512-Y17hhnrQTrxgAxAyAq401vnN9URsAL4s5AjqpG1NDsXSlhe1yBNnns+rC2P6xcMoitgX5nKH2ryYt9oiFRlzLw==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.121.0.tgz",
"integrity": "sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.126.0.tgz",
"integrity": "sha512-Znug1u1iRvT4VC3jANz6nhGBHsFwEFMxuimYpJFwMtsB6H5FcEoZRMmH26tHkSTD03JvDmG+gB65W3ajLjPcSw==",
"cpu": [
"arm64"
],
@@ -2512,9 +2514,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.121.0.tgz",
"integrity": "sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.126.0.tgz",
"integrity": "sha512-qrw7mx5hFFTxVSXToOA40hpnjgNB/DJprZchtB4rDKNLKqkD3F26HbzaQeH1nxAKej0efSZfJd5Sw3qdtOLGhw==",
"cpu": [
"ia32"
],
@@ -2528,9 +2530,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.121.0.tgz",
"integrity": "sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.126.0.tgz",
"integrity": "sha512-ibB1s+mPUFXvS7MFJO2jpw/aCNs/P6ifnWlRyTYB+WYBpniOiCcHQQskZneJtwcjQMDRol3RGG3ihoYnzXSY4w==",
"cpu": [
"x64"
],
@@ -2544,9 +2546,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.121.0.tgz",
"integrity": "sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -10004,9 +10006,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -11567,9 +11569,9 @@
}
},
"node_modules/knip": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz",
"integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.6.0.tgz",
"integrity": "sha512-IT1YDiHyRctYYsuZNBd/ZiGoa7HmCaxs+ZrWxCfYjQKPG6QyRqMfkteqC+rBuMymBJeLXyBnRa7hn95O+sGG8Q==",
"funding": [
{
"type": "github",
@@ -11582,18 +11584,17 @@
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"fdir": "^6.5.0",
"formatly": "^0.3.0",
"get-tsconfig": "4.13.7",
"get-tsconfig": "4.14.0",
"jiti": "^2.6.0",
"minimist": "^1.2.8",
"oxc-parser": "^0.121.0",
"oxc-parser": "^0.126.0",
"oxc-resolver": "^11.19.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"picomatch": "^4.0.4",
"smol-toml": "^1.6.1",
"strip-json-comments": "5.0.3",
"tinyglobby": "^0.2.16",
"unbash": "^2.2.0",
"yaml": "^2.8.2",
"zod": "^4.1.11"
@@ -11618,6 +11619,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/knip/node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/langium": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
@@ -14266,12 +14283,12 @@
}
},
"node_modules/oxc-parser": {
"version": "0.121.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.121.0.tgz",
"integrity": "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.126.0.tgz",
"integrity": "sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "^0.121.0"
"@oxc-project/types": "^0.126.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -14280,26 +14297,26 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.121.0",
"@oxc-parser/binding-android-arm64": "0.121.0",
"@oxc-parser/binding-darwin-arm64": "0.121.0",
"@oxc-parser/binding-darwin-x64": "0.121.0",
"@oxc-parser/binding-freebsd-x64": "0.121.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.121.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.121.0",
"@oxc-parser/binding-linux-arm64-musl": "0.121.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.121.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.121.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.121.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.121.0",
"@oxc-parser/binding-linux-x64-gnu": "0.121.0",
"@oxc-parser/binding-linux-x64-musl": "0.121.0",
"@oxc-parser/binding-openharmony-arm64": "0.121.0",
"@oxc-parser/binding-wasm32-wasi": "0.121.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.121.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.121.0",
"@oxc-parser/binding-win32-x64-msvc": "0.121.0"
"@oxc-parser/binding-android-arm-eabi": "0.126.0",
"@oxc-parser/binding-android-arm64": "0.126.0",
"@oxc-parser/binding-darwin-arm64": "0.126.0",
"@oxc-parser/binding-darwin-x64": "0.126.0",
"@oxc-parser/binding-freebsd-x64": "0.126.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.126.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.126.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.126.0",
"@oxc-parser/binding-linux-arm64-musl": "0.126.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.126.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.126.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.126.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.126.0",
"@oxc-parser/binding-linux-x64-gnu": "0.126.0",
"@oxc-parser/binding-linux-x64-musl": "0.126.0",
"@oxc-parser/binding-openharmony-arm64": "0.126.0",
"@oxc-parser/binding-wasm32-wasi": "0.126.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.126.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.126.0",
"@oxc-parser/binding-win32-x64-msvc": "0.126.0"
}
},
"node_modules/oxc-resolver": {

View File

@@ -154,7 +154,7 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^6.4.1",
"knip": "^6.6.0",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",

View File

@@ -3,6 +3,7 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -75,7 +76,7 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
}
protected override rowLabel(item: GoogleWorkspaceProviderUser): string {
return item.userObj.name || item.userObj.username;
return formatUserDisplayName(item.userObj);
}
protected columns: TableColumn[] = [

View File

@@ -3,6 +3,7 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -75,7 +76,7 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
}
protected override rowLabel(item: MicrosoftEntraProviderUser): string {
return item.userObj.name || item.userObj.username;
return formatUserDisplayName(item.userObj);
}
protected columns: TableColumn[] = [

View File

@@ -391,20 +391,28 @@ export class SAMLProviderViewPage extends AKElement {
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SAML Endpoint")}</span
>${msg("SSO URL (Post)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlUnified)}"
value="${ifDefined(this.provider.urlSsoPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SSO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoRedirect)}"
/>
<p class="pf-c-form__helper-text">
${msg(
"SAML provider endpoint. Use this URL for SP configuration.",
)}
</p>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
@@ -416,7 +424,33 @@ export class SAMLProviderViewPage extends AKElement {
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlUnifiedInit)}"
value="${ifDefined(this.provider.urlSsoInit)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Post)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloRedirect)}"
/>
</div>
</form>

View File

@@ -4,6 +4,7 @@ import "#elements/sync/SyncObjectForm";
import "#admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -73,7 +74,7 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
}
protected override rowLabel(item: SCIMProviderUser): string {
return item.userObj.name || item.userObj.username;
return formatUserDisplayName(item.userObj);
}
protected columns: TableColumn[] = [

View File

@@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -25,7 +26,7 @@ export class SCIMSourceUserList extends Table<SCIMSourceUser> {
}
protected override rowLabel(item: SCIMSourceUser): string {
return item.userObj.name || item.userObj.username;
return formatUserDisplayName(item.userObj);
}
protected columns: TableColumn[] = [

View File

@@ -2,20 +2,30 @@ import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/HorizontalFormElement";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { Form } from "#elements/forms/Form";
import { ifPresent } from "#elements/utils/attributes";
import { FocusTarget } from "#elements/utils/focus";
import { AKLabel } from "#components/ak-label";
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-user-password-form")
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public override submitLabel = msg("Set Password");
public static shadowRootOptions: ShadowRootInit = {
...Form.shadowRootOptions,
delegatesFocus: true,
};
public static override verboseName = msg("Password");
public static override verboseNamePlural = msg("Passwords");
public static override submittingVerb = msg("Setting");
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
@@ -23,6 +33,9 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#region Properties
public override submitLabel = msg("Set Password");
public override successMessage = msg("Successfully updated password.");
@property({ type: Number })
public instancePk?: number;
@@ -30,13 +43,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public label = msg("New Password");
@property({ type: String })
public placeholder = msg("New Password");
public placeholder = msg("Type a new password...");
@property({ type: String })
public username?: string;
@property({ type: String, useDefault: true })
public username: string | null = null;
@property({ type: String })
public email?: string;
@property({ type: String, useDefault: true })
public email: string | null = null;
public override size = PFSize.Medium;
/**
* The autocomplete attribute to use for the password field.
@@ -50,17 +65,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#endregion
public override getSuccessMessage(): string {
return msg("Successfully updated password.");
}
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override firstUpdated(): void {
this.focus();
requestAnimationFrame(() => {
this.focus();
});
}
protected override async send(data: UserPasswordSetRequest): Promise<void> {
@@ -94,17 +107,26 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
/>`
: nothing}
<ak-form-element-horizontal label=${this.label} required name="password">
<ak-form-element-horizontal required name="password">
${AKLabel(
{
slot: "label",
className: "pf-c-form__group-label",
htmlFor: "password",
required: true,
},
this.label,
)}
<input
autofocus
${this.autofocusTarget.toRef()}
id="password"
type="password"
value=""
class="pf-c-form-control"
required
placeholder=${ifDefined(this.placeholder || this.label)}
aria-label=${this.label}
autocomplete=${ifDefined(this.autocomplete)}
placeholder=${ifPresent(this.placeholder || this.label)}
autocomplete=${ifPresent(this.autocomplete)}
/>
</ak-form-element-horizontal>`;
}

View File

@@ -1,3 +1,5 @@
import { formatUserDisplayName } from "#common/users";
import { modalInvoker } from "#elements/dialogs";
import { LitFC } from "#elements/types";
@@ -52,7 +54,7 @@ export const RecoveryButtons: LitFC<RecoveryButtonsProps> = ({
class="pf-c-button pf-m-secondary ${buttonClasses || ""}"
type="button"
${modalInvoker(UserPasswordForm, {
headline: msg(str`Update ${user.name || user.username}'s password`),
headline: msg(str`Update ${formatUserDisplayName(user)}'s password`),
username: user.username,
email: user.email,
instancePk: user.pk,

140
web/src/common/storage.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* @file Storage utilities.
*/
import { ConsoleLogger } from "#logger/browser";
/**
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
*/
export class StorageAccessor {
constructor(
/**
* The key under which the value is stored in the storage backend.
*/
public readonly key: string,
/**
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
*/
protected readonly storage: Storage,
protected logger = ConsoleLogger.prefix("storage-accessor"),
) {
if (typeof key !== "string") {
throw new TypeError("Storage key must be a string");
}
if (!key) {
throw new TypeError("Storage key must be a non-empty string");
}
}
/**
* Create a {@link StorageAccessor} for local storage.
*
* @param key The key under which the value is stored in localStorage.
*/
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
/**
* Create a {@link StorageAccessor} for session storage.
*
* @param key The key under which the value is stored in sessionStorage.
*/
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
/**
* Read the value from storage.
*
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
*
* @returns The stored value, or `null` if the key does not exist or an error occurs.
*/
public read<T extends string>(fallback?: T): T | null {
try {
const value = this.storage.getItem(this.key);
return value !== null ? (value as T) : (fallback ?? null);
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public write(value: string | null): boolean {
if (!value) {
if (this.read()) {
return this.delete();
}
return true;
}
try {
this.storage.setItem(this.key, value);
return true;
} catch (_error: unknown) {
return false;
}
}
/**
* Read the value from storage and parse it as JSON.
*
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
*
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
*/
public readJSON<T>(fallback?: T): T | null {
const value = this.read<string>();
if (value === null) {
return fallback ?? null;
}
try {
return JSON.parse(value) as T;
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage after stringifying it as JSON.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public writeJSON(value: unknown): boolean {
try {
const stringified = JSON.stringify(value);
return this.write(stringified);
} catch (error: unknown) {
this.logger.error("Failed to write JSON value to storage", error);
return false;
}
}
/**
* Delete the value from storage.
*
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
*/
public delete(): boolean {
this.logger.debug("Deleting value from storage");
try {
this.storage.removeItem(this.key);
return true;
} catch (error: unknown) {
this.logger.error("Failed to delete value from storage", error);
return false;
}
}
}

View File

@@ -207,6 +207,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
<a
href="${globalAK().api.base}flows/-/default/invalidation/"
class="pf-c-button pf-m-plain"
aria-label=${msg("Sign out")}
>
<pf-tooltip position="top" content=${msg("Sign out")}>
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>

View File

@@ -414,6 +414,13 @@ export class Form<T = Record<string, unknown>, D = T>
const { submittingVerb, verboseName } = this.constructor as typeof Form;
if (!verboseName) {
return msg(str`${submittingVerb}...`, {
id: "form.submitting.no-entity",
desc: "The message shown while a form is being submitted, when no entity name is provided.",
});
}
return msg(str`${submittingVerb} ${verboseName}...`, {
id: "form.submitting",
desc: "The message shown while a form is being submitted.",
@@ -615,6 +622,7 @@ export class Form<T = Record<string, unknown>, D = T>
protected doSubmit = (event: SubmitEvent): void => {
if (this.submitting) {
this.logger.info("Skipping submit. Already submitting!");
return;
}
this.submitting = true;

View File

@@ -4,6 +4,44 @@
import { createRef, ref, Ref } from "lit/directives/ref.js";
export interface FocusErrorOptions extends ErrorOptions {
target: Element | null;
}
export class FocusAssertionError extends Error {
public override name = "FocusAssertionError";
public readonly target: Element | null;
constructor(message: string, { target, ...options }: FocusErrorOptions) {
super(message, options);
this.target = target;
}
}
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
if (!target) {
throw new FocusAssertionError("Skipping focus, no target", { target: null });
}
if (!(target instanceof HTMLElement)) {
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
}
if (document.activeElement === target) {
throw new FocusAssertionError("Target is already focused", { target });
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
}
if (typeof target.focus !== "function") {
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
}
}
/**
* Recursively check if the target element or any of its children are active (i.e. "focused").
*
@@ -36,35 +74,17 @@ export function isActiveElement(
* @category DOM
*/
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
if (!target) {
console.debug("FocusTarget: Skipping focus, no target", target);
try {
assertFocusable(target);
return true;
} catch (error) {
if (error instanceof FocusAssertionError) {
console.debug(error.message, error.target);
} else {
console.error("Unexpected error during focus assertion", error);
}
return false;
}
if (!(target instanceof HTMLElement)) {
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
return false;
}
if (document.activeElement === target) {
console.debug("FocusTarget: Target is already focused", target);
return false;
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
console.debug("FocusTarget: Skipping focus, target is not visible", target);
return false;
}
if (typeof target.focus !== "function") {
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
return false;
}
return true;
}
/**

View File

@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
import { isDefaultAvatar } from "#elements/utils/images";
import Styles from "#flow/FormStatic.css";
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
import { StageChallengeLike } from "#flow/types";
import { msg, str } from "@lit/localize";
@@ -69,7 +70,9 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
${flowInfo?.cancelUrl
? html`
<div slot="link">
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
>${msg("Not you?")}</a
>
</div>
`
: nothing}

View File

@@ -121,9 +121,10 @@ export class InputPassword extends AKElement {
//#region Refs
inputRef: Ref<HTMLInputElement> = createRef();
@property({ attribute: false, useDefault: true })
public inputRef: Ref<HTMLInputElement> = createRef();
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
public toggleVisibilityRef = createRef<HTMLButtonElement>();
//#endregion

View File

@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
@intersectionObserver()
public visible = false;
protected autofocusTarget = new FocusTarget();
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
focus = this.autofocusTarget.focus;
#visibilityListener = () => {

View File

@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
import { BaseStage } from "#flow/stages/base";
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
import Styles from "#flow/stages/identification/styles.css";
@@ -30,6 +30,7 @@ import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
import { createRef, ref } from "lit-html/directives/ref.js";
import { customElement, property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
@@ -45,8 +46,6 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
type EmptyString = string | null | undefined;
export const PasswordManagerPrefill: {
password?: string;
totp?: string;
@@ -82,21 +81,26 @@ export class IdentificationStage extends BaseStage<
PFFormControl,
PFTitle,
PFButton,
...RememberMe.styles,
...RememberMeController.styles,
Styles,
];
/**
* The ID of the input field.
* The ID of the identifier input field, used for accessibility and focus management.
*
* @attr
*/
@property({ type: String, attribute: "input-id" })
public inputID = "ak-identifier-input";
protected passwordFieldRef = createRef<HTMLInputElement>();
#form?: HTMLFormElement;
private rememberMe = new RememberMe(this);
public defaultUserIdentification: string | null = null;
protected rememberMeController: RememberMeController | null = null;
#autoRedirect = new AutoRedirect(this);
#captcha = new CaptchaDisplayController(this);
#webauthn = new WebauthnController(this);
@@ -109,15 +113,23 @@ export class IdentificationStage extends BaseStage<
super();
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
// the lint pass that there are no unused private fields.
this.addController(this.rememberMe);
this.addController(this.#autoRedirect);
this.addController(this.#captcha);
this.addController(this.#webauthn);
}
#prepareRememberMeFrame = -1;
public override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("challenge") && this.challenge) {
cancelAnimationFrame(this.#prepareRememberMeFrame);
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
this.prepareRememberMeController();
});
this.#createHelperForm();
}
}
@@ -127,10 +139,46 @@ export class IdentificationStage extends BaseStage<
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
cancelAnimationFrame(this.#prepareRememberMeFrame);
}
public override firstUpdated(): void {
this.focus();
}
protected prepareRememberMeController(): void {
if (!this.challenge) return;
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
if (!enableRememberMe) {
this.defaultUserIdentification = pendingUserIdentifier;
if (this.rememberMeController) {
this.removeController(this.rememberMeController);
this.rememberMeController = null;
}
return;
}
if (!this.rememberMeController) {
this.rememberMeController = new RememberMeController(this, {
identificationFieldID: this.inputID,
identificationFieldRef: this.autofocusTarget.reference,
passwordFieldRef: this.passwordFieldRef,
pendingUserIdentifier,
});
this.addController(this.rememberMeController);
}
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
}
//#endregion
//#region Helper Form
@@ -247,11 +295,11 @@ export class IdentificationStage extends BaseStage<
id: string,
type: string,
label: string,
username: EmptyString,
initialUserIdentification: string | null,
autocomplete: string,
) {
return html`<input
${this.autofocusTarget.toRef()}
${ref(this.autofocusTarget.reference)}
id=${id}
type=${type}
name="uidField"
@@ -260,56 +308,57 @@ export class IdentificationStage extends BaseStage<
autocomplete=${autocomplete}
spellcheck="false"
class="pf-c-form-control"
value=${username ?? ""}
value=${initialUserIdentification ?? ""}
required
/>`;
}
protected renderPasswordFields(challenge: IdentificationChallenge) {
const { allowShowPassword } = challenge;
return html`
<ak-flow-input-password
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password>
`;
return html`<ak-flow-input-password
.inputRef=${this.passwordFieldRef}
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password> `;
}
protected renderInput(challenge: IdentificationChallenge) {
const {
flowDesignation,
passwordFields,
passwordlessUrl,
pendingUserIdentifier,
primaryAction,
userFields,
} = challenge;
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
challenge;
const fields = (userFields || []).sort();
if (fields.length === 0) {
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
}
const { inputID, rememberMe } = this;
const {
inputID,
defaultUserIdentification: initialUserIdentification,
rememberMeController,
} = this;
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
const username = rememberMe.username ?? pendingUserIdentifier;
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
console.debug(
"Rendering identification stage with fields:",
fields,
initialUserIdentification,
);
// prettier-ignore
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
<div class="pf-c-form__group">
${AKLabel({ required: true, htmlFor: inputID }, label)}
${this.renderUidField(inputID, type, label, username, autocomplete)}
${rememberMe.render()}
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
${rememberMeController?.renderToggleInput() ?? null}
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
</div>
${passwordFields ? this.renderPasswordFields(challenge) : nothing}

View File

@@ -1,11 +1,35 @@
import { StorageAccessor } from "#common/storage";
import { getCookie } from "#common/utils";
import { ReactiveElementHost } from "#elements/types";
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
import { msg } from "@lit/localize";
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
import { ConsoleLogger } from "#logger/browser";
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
import { msg } from "@lit/localize";
import { css, html, ReactiveController } from "lit";
import { createRef, Ref } from "lit-html/directives/ref.js";
export class RememberMeStorage {
static readonly user = StorageAccessor.local("authentik-remember-me-user");
static readonly session = StorageAccessor.local("authentik-remember-me-session");
static reset = () => {
this.user.delete();
this.session.delete();
};
}
function readSessionID() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
export interface RememberMeControllerInit {
pendingUserIdentifier: string | null;
identificationFieldRef: Ref<HTMLInputElement>;
passwordFieldRef: Ref<HTMLInputElement> | null;
identificationFieldID: string;
}
/**
* Remember the user's `username` "on this device."
@@ -24,7 +48,7 @@ type RememberMeHost = ReactiveControllerHost & IdentificationStage;
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
* you?" link, at which point it begins again to record the username as it is typed in.
*/
export class RememberMe implements ReactiveController {
export class RememberMeController implements ReactiveController {
static readonly styles = [
css`
.remember-me-switch {
@@ -35,121 +59,178 @@ export class RememberMe implements ReactiveController {
`,
];
public username?: string;
//#region Lifecycle
#trackRememberMe = () => {
if (!this.#usernameField || this.#usernameField.value === undefined) {
return;
}
this.username = this.#usernameField.value;
localStorage?.setItem("authentik-remember-me-user", this.username);
};
public readonly identificationFieldRef: Ref<HTMLInputElement>;
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
public readonly defaultChecked: boolean;
public readonly defaultUserIdentification: string | null;
public readonly identificationFieldID: string;
// When active, save current details and record every keystroke to the username.
// When inactive, clear all fields and remove keystroke recorder.
#toggleRememberMe = () => {
if (!this.#rememberMeToggle || !this.#rememberMeToggle.checked) {
localStorage?.removeItem("authentik-remember-me-user");
localStorage?.removeItem("authentik-remember-me-session");
this.username = undefined;
this.#usernameField?.removeEventListener("keyup", this.#trackRememberMe);
return;
}
if (!this.#usernameField) {
return;
}
localStorage?.setItem("authentik-remember-me-user", this.#usernameField.value);
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
};
protected logger = ConsoleLogger.prefix("controller/remember-me");
protected autoSubmitAttempts = 0;
protected currentSessionID = readSessionID();
constructor(private host: RememberMeHost) {}
constructor(
protected host: ReactiveElementHost<IdentificationStage>,
{
identificationFieldRef,
passwordFieldRef,
identificationFieldID,
}: RememberMeControllerInit,
) {
this.identificationFieldRef = identificationFieldRef;
this.passwordFieldRef = passwordFieldRef || null;
this.identificationFieldID = identificationFieldID;
// Record a stable token that we can use between requests to track if we've
// been here before. If we can't, clear out the username.
public hostConnected() {
try {
const sessionId = localStorage.getItem("authentik-remember-me-session");
if (!!this.#localSession && sessionId === this.#localSession) {
this.username = undefined;
localStorage?.removeItem("authentik-remember-me-user");
}
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
const persistedSessionID = RememberMeStorage.session.read();
get #localSession() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
get #usernameField() {
return this.host.renderRoot.querySelector(
'input[name="uidField"]',
) as HTMLInputElement | null;
}
get #rememberMeToggle() {
return this.host.renderRoot.querySelector(
"#authentik-remember-me",
) as HTMLInputElement | null;
}
get #submitButton() {
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
}
get #isEnabled() {
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
}
get #canAutoSubmit() {
return (
!!this.host.challenge &&
!!this.username &&
!!this.#usernameField?.value &&
!this.host.challenge.passwordFields &&
!this.host.challenge.passwordlessUrl
);
}
// Before the page is updated, try to extract the username from localstorage.
public hostUpdate() {
if (!this.#isEnabled) {
return;
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
this.logger.debug("Session ID mismatch, clearing remembered username");
RememberMeStorage.user.delete();
}
try {
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
const persistedUserIdentifier = RememberMeStorage.user.read();
this.defaultUserIdentification =
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
this.defaultChecked = !!persistedUserIdentifier;
}
// After the page is updated, if everything is ready to go, do the autosubmit.
public hostUpdated() {
if (this.#isEnabled && this.#canAutoSubmit) {
this.#submitButton?.click();
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
this.autoSubmitAttempts++;
this.host.submitForm?.();
}
}
public render() {
return this.#isEnabled
? html` <label class="pf-c-switch remember-me-switch">
<input
class="pf-c-switch__input"
id="authentik-remember-me"
@click=${this.#toggleRememberMe}
type="checkbox"
?checked=${!!this.username}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`
: nothing;
//#region Event Listeners
#writeFrameID = -1;
public inputListener = (event: InputEvent) => {
cancelAnimationFrame(this.#writeFrameID);
const { value } = event.target as HTMLInputElement;
this.#writeFrameID = requestAnimationFrame(() => {
RememberMeStorage.user.write(value);
});
};
//#endregion
//#region Public API
/**
* Toggle the "remember me" feature on or off.
*
* When toggled on, the current username is saved to localStorage and will be automatically
* submitted on future visits. Additionally, every keystroke in the username field will update
* the stored username.
*
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
* is removed to stop updating the stored username.
*/
public toggleChangeListener = (event: Event) => {
const checkbox = event.target as HTMLInputElement;
const { usernameField, passwordField } = this;
if (!checkbox.checked) {
this.logger.debug("Disabling remember me");
RememberMeStorage.reset();
if (usernameField) {
usernameField.removeEventListener("input", this.inputListener);
usernameField.focus();
usernameField.select();
}
return;
}
if (!usernameField) {
this.logger.warn("Cannot enable remember me: no username field found");
return;
}
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
if (focusTarget) {
focusTarget.focus();
focusTarget.select();
}
this.logger.debug("Enabling remember me for user");
RememberMeStorage.user.write(usernameField.value);
RememberMeStorage.session.write(this.currentSessionID);
usernameField.addEventListener("input", this.inputListener, {
passive: true,
});
};
/**
* Determines if the "remember me" feature can be automatically submitted, which requires:
*
* - An active challenge.
* - A stored username from a previous session.
* - The identifier input field to be present in the DOM.
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
*/
public canAutoSubmit(): boolean {
const { challenge } = this.host;
if (!challenge) return false;
if (!challenge.enableRememberMe) return false;
if (challenge.passwordFields) return false;
if (challenge.passwordlessUrl) return false;
if (!this.defaultChecked) return false;
return !!this.usernameField?.value;
}
//#endregion
//#region Rendering
protected readonly checkboxRef = createRef<HTMLInputElement>();
protected get usernameField() {
return this.identificationFieldRef.value || null;
}
protected get passwordField() {
return this.passwordFieldRef?.value || null;
}
protected get checkboxToggle() {
return this.checkboxRef.value || null;
}
public renderToggleInput = () => {
return html`<label
class="pf-c-switch remember-me-switch"
for="authentik-remember-me"
aria-description=${msg(
"When enabled, your username will be remembered on this device for future logins.",
)}
>
<input
class="pf-c-switch__input"
type="checkbox"
id="authentik-remember-me"
@change=${this.toggleChangeListener}
?checked=${this.defaultChecked}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`;
};
//#endregion
}
export default RememberMe;
export default RememberMeController;

View File

@@ -179,7 +179,7 @@ test.describe("Groups", () => {
});
});
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;

View File

@@ -17,11 +17,7 @@ test.describe("Provider Wizard", () => {
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/core/providers",
});
});
await test.step("Authenticate", async () => session.login());
await test.step("Navigate to provider wizard", async () => {
await expect(dialog, "Dialog is initially closed").toBeHidden();

View File

@@ -0,0 +1,119 @@
import { expect, test } from "#e2e";
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
import type { Page } from "@playwright/test";
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
const readStoredUserIdentifier = (page: Page) =>
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
test.describe("Session Lifecycle", () => {
test.beforeAll(
'Ensure "Enable Remember me on this device" is on for the default identification stage',
async ({ browser }, { title: testName }) => {
if (Date.now()) return;
const context = await browser.newContext();
const page = await context.newPage();
const navigator = new NavigatorFixture(page, testName);
const form = new FormFixture(page, testName);
const session = new SessionFixture({ page, testName, navigator });
await test.step("Authenticate", async () =>
session.login({
to: "/if/admin/#/flow/stages",
page,
}));
const $stage = await test.step("Find stage via search", () =>
form.search(IDENTIFICATION_STAGE_NAME, page));
await $stage.getByRole("button", { name: "Edit Stage" }).click();
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
await dialog.getByRole("button", { name: "Save Changes" }).click();
await expect(dialog, "Edit modal closes after save").toBeHidden();
await context.close();
},
);
test.beforeEach(async ({ session, page }) => {
await session.toLoginPage();
await page.evaluate(
([userKey, sessionKey]) => {
localStorage.removeItem(userKey);
localStorage.removeItem(sessionKey);
},
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
);
await page.reload();
await session.$identificationStage.waitFor({ state: "visible" });
});
test("Remember me persists username", async ({ navigator, session, page }) => {
await test.step("Verify identification stage", async () => {
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is visible",
).toBeVisible();
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is not checked by default",
).not.toBeChecked();
});
await test.step("Identify with remember-me enabled", async () => {
await session.login(
{
rememberMe: true,
to: "if/user/#/library",
},
page,
);
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(
storedUserIdentifier,
"username persists to localStorage when remember-me is checked",
).toBe(GOOD_USERNAME);
});
await test.step("Sign out and verify username is remembered", async () => {
const signOutLink = page.getByRole("link", { name: "Sign out" });
await expect(signOutLink, "Sign out link is visible").toBeVisible();
await signOutLink.click();
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
const notYouLink = page.getByRole("link", { name: "Not you?" });
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
await notYouLink.click();
await expect(
session.$identificationStage,
"Identification stage is visible after clicking not you link",
).toBeVisible();
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
});
});
});

View File

@@ -2198,9 +2198,6 @@
<source>Issuer</source>
<target>Vydavatel</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Publikum</target>
@@ -10998,6 +10995,18 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2213,9 +2213,6 @@
<source>Issuer</source>
<target>Aussteller</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Zielgruppe</target>
@@ -11031,6 +11028,18 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1688,9 +1688,6 @@
<trans-unit id="sb7a30abc1dcf6c36">
<source>Issuer</source>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
</trans-unit>
@@ -9001,6 +8998,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2176,9 +2176,6 @@
<source>Issuer</source>
<target>Emisor</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audiencia</target>
@@ -10956,6 +10953,18 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2255,10 +2255,6 @@
<source>Issuer</source>
<target>Myöntäjä</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
<target>Tunnetaan myös nimellä Entity ID.</target>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Yleisö</target>
@@ -11198,6 +11194,18 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2252,9 +2252,6 @@
<source>Issuer</source>
<target>Émetteur</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audience</target>
@@ -11186,6 +11183,18 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2144,9 +2144,6 @@
<source>Issuer</source>
<target>Emittente</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audience</target>
@@ -10905,6 +10902,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2249,10 +2249,6 @@
<source>Issuer</source>
<target>発行者</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
<target>Entity ID とも呼ばれます。</target>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>オーディエンス</target>
@@ -11187,6 +11183,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2090,9 +2090,6 @@
<source>Issuer</source>
<target>발급자</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>수신처</target>
@@ -10553,6 +10550,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2007,9 +2007,6 @@
<source>Issuer</source>
<target>Uitgever</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Publiek</target>
@@ -10237,6 +10234,18 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2091,9 +2091,6 @@
<source>Issuer</source>
<target>Wystawca</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Odbiorcy</target>
@@ -10579,6 +10576,18 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2252,9 +2252,6 @@
<source>Issuer</source>
<target>Emissor</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audiência</target>
@@ -11179,6 +11176,18 @@ por exemplo: <x id="0" equiv-text="&lt;code&gt;"/>oci://registry.domain.tld/path
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2110,9 +2110,6 @@
<source>Issuer</source>
<target>Издатель</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Аудитория</target>
@@ -10665,6 +10662,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2093,9 +2093,6 @@
<source>Issuer</source>
<target>Yayımcı</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>İzleyici</target>
@@ -10654,6 +10651,18 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2258,10 +2258,6 @@
<source>Issuer</source>
<target>颁发者</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
<target>又称之为 Entity ID</target>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audience</target>
@@ -11459,6 +11455,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2025,9 +2025,6 @@
<source>Issuer</source>
<target>發行者</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audience</target>
@@ -10290,6 +10287,18 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -45,6 +45,33 @@ For example:
password: this-should-be-a-long-value
```
### `password_hash`
In blueprints, a user's password can also be set using the `password_hash` field. The value must be a valid Django password hash, such as one generated with the `hash_password` management command.
Use `password_hash` when you need to import or bootstrap an existing hash without exposing the raw password to authentik:
```bash
docker compose run --rm server hash_password 'your-password'
```
For example:
```yaml
# [...]
- model: authentik_core.user
state: present
identifiers:
username: test-user
attrs:
name: test user
password_hash: pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM=
```
`password` and `password_hash` are mutually exclusive; setting both on the same user causes blueprint validation to fail.
`password_hash` follows the [hashed-password import behavior](../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash): it updates only authentik's local password verifier and does not propagate to LDAP or Kerberos integrations.
### `permissions`
The `permissions` field can be used to set global permissions for a user. A full list of possible permissions is included in the JSON schema for blueprints.

View File

@@ -8,9 +8,56 @@ To install authentik automatically (skipping the Out-of-box experience), you can
These can't be defined using the file-based syntax (`file://`), so you can't pass them in as secrets in a Docker Compose installation.
:::
### `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`
Configure the default password for the `akadmin` user using a pre-hashed Django password value. Only read on the first startup.
This stores the hash directly as authentik's local password verifier. Because authentik never sees the raw password, hashed-password imports do not propagate the password to LDAP or Kerberos integrations, even when password writeback is enabled.
To generate a hash, run this command before your initial deployment:
```bash
docker compose run --rm server hash_password 'your-password'
```
The generated hash includes a random salt, so running the command multiple times for the same password produces different output. Use the complete output as the value of `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`.
:::warning Escaping `$` in Docker Compose
Password hashes contain `$` characters which Docker Compose interprets as variable references.
**In `.env` files**, use single quotes to prevent interpolation:
```bash
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH='pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM='
```
**In `docker-compose.yml`** (inline environment), escape each `$` with `$$`:
```yaml
services:
worker:
environment:
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: "pbkdf2_sha256$$1000000$$xKKFuYtJEE27km09BD49x2$$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM="
```
See the Docker Compose documentation on [`.env` file interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) and [Compose file interpolation](https://docs.docker.com/reference/compose-file/interpolation/) for details.
:::
### `AUTHENTIK_BOOTSTRAP_PASSWORD`
Configure the default password for the `akadmin` user. Only read on the first startup. Can be used for any flow executor.
:::warning
This option stores plaintext passwords in environment variables. Use [`AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`](#authentik_bootstrap_password_hash) instead.
:::
Configure the default password for the `akadmin` user. Only read on the first startup.
Setting both `AUTHENTIK_BOOTSTRAP_PASSWORD` and `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH` will result in an error.
### Other hashed-password import paths
For post-install automation, hashed passwords can also be set via blueprints with the `password_hash` user attribute, or via the `/api/v3/core/users/<id>/set_password_hash/` API endpoint with the hash provided in the `password` field. The API endpoint requires the `authentik_core.reset_user_password` permission and can target regular users or service accounts.
These paths share the same local-verifier-only behavior as `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH`.
### `AUTHENTIK_BOOTSTRAP_TOKEN`
@@ -22,15 +69,24 @@ Set the email address for the default `akadmin` user.
## Kubernetes
In the Helm values, set the `akadmin` user password and token:
In the Helm values, set the `akadmin` user password hash and token:
```yaml
authentik:
bootstrap_token: test
bootstrap_password: test
bootstrap_password_hash: "pbkdf2_sha256$1000000$xKKFuYtJEE27km09BD49x2$4+Z6j3utmouPF5mik0Z21L2P0og5IlmMmIJ46Tj3zCM="
bootstrap_token: "your-token-here"
bootstrap_email: "admin@authentik.company"
```
To store the password and token in a secret, use:
:::note Helm escaping
When using password hashes in quoted YAML strings as shown above, no escaping of `$` characters is required. The `$` character only needs escaping when:
- Using Helm templating syntax (e.g., `{{ .Values.something }}`) where `$` has special meaning
- Referencing values from environment variable substitution in your values file
:::
Or store the password hash in a secret and reference it via `envFrom`:
```yaml
global:
@@ -39,4 +95,4 @@ global:
name: _some-secret_
```
where _some-secret_ contains the environment variables as in the documentation above.
where _some-secret_ contains the environment variables as documented above.

View File

@@ -99,7 +99,7 @@ If not specified, the server name defaults to trying out all entries in the keyt
There are some extra settings you can configure:
- Update internal password on login: when a user logs in to authentik using the Kerberos source as a password backend, their internal authentik password will be updated to match the one from Kerberos.
- Use password writeback: when a user changes their password in authentik, their Kerberos password is automatically updated to match the one from authentik. This is only available if synchronization is configured.
- Use password writeback: when a user changes their password in authentik, their Kerberos password is automatically updated to match the one from authentik. This is only available if synchronization is configured, and requires authentik to receive the raw password; [hashed-password imports](../../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash) are not written back to Kerberos.
## Kerberos source property mappings

View File

@@ -17,7 +17,7 @@ To create or edit a source in authentik, open the Admin interface and navigate t
- **Enabled**: Toggle this option on to allow authentik to use the defined LDAP source.
- **Update internal password on login**: When the user logs in to authentik using the LDAP password backend, the password is stored as a hashed value in authentik. Toggle off (default setting) if you do not want to store the hashed passwords in authentik.
- **Sync users**: Enable or disable user synchronization between authentik and the LDAP source.
- **User password writeback**: Enable this option if you want to write password changes that are made in authentik back to LDAP.
- **User password writeback**: Enable this option if you want to write password changes that are made in authentik back to LDAP. This requires authentik to receive the raw password; [hashed-password imports](../../../../install-config/automated-install.mdx#authentik_bootstrap_password_hash) are not written back to LDAP.
- **Sync groups**: Enable/disable group synchronization between authentik and the LDAP source.
- **Delete Not Found Objects**: :ak-version[2025.6] This option synchronizes user and group deletions from LDAP sources to authentik. User deletion requires enabling **Sync users** and group deletion requires enabling **Sync groups**.