mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 15:12:13 +02:00
Compare commits
10 Commits
saml-endpo
...
remote_deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264ad31f50 | ||
|
|
cced288ddb | ||
|
|
4aa323bc20 | ||
|
|
d6c0ae21de | ||
|
|
2c35df35b6 | ||
|
|
90d4f4296b | ||
|
|
bf7747268b | ||
|
|
552cb78458 | ||
|
|
899994027d | ||
|
|
99250b0498 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
|
||||
28
authentik/core/management/commands/hash_password.py
Normal file
28
authentik/core/management/commands/hash_password.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
28
authentik/core/tests/test_hash_password_command.py
Normal file
28
authentik/core/tests/test_hash_password_command.py
Normal 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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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)
|
||||
@@ -5537,6 +5537,14 @@
|
||||
"minLength": 1,
|
||||
"title": "Password"
|
||||
},
|
||||
"password_hash": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 1,
|
||||
"title": "Password hash"
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
BIN
locale/no_NO/LC_MESSAGES/django.mo
Normal file
BIN
locale/no_NO/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
78
packages/client-ts/src/apis/CoreApi.ts
generated
78
packages/client-ts/src/apis/CoreApi.ts
generated
@@ -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
|
||||
*/
|
||||
|
||||
18
packages/client-ts/src/models/SAMLProvider.ts
generated
18
packages/client-ts/src/models/SAMLProvider.ts
generated
@@ -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"
|
||||
|
||||
70
packages/client-ts/src/models/UserPasswordHashSetRequest.ts
generated
Normal file
70
packages/client-ts/src/models/UserPasswordHashSetRequest.ts
generated
Normal 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"],
|
||||
};
|
||||
}
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
54
schema.yml
54
schema.yml
@@ -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
91
scripts/debug_attach.py
Normal 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
8
uv.lock
generated
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
225
web/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
140
web/src/common/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
119
web/test/browser/session-lifecycle.test.ts
Normal file
119
web/test/browser/session-lifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="<code>"/>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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user