Compare commits

..

6 Commits

Author SHA1 Message Date
e5c8229a83 tweaks 2025-06-27 12:57:27 -05:00
390f4d87da Optimised images with calibre/image-actions 2025-06-26 12:34:35 +00:00
9900312b5d fix image link 2025-06-26 07:11:58 -05:00
5a69ded74d major surgery 2025-06-24 10:52:03 -05:00
fbc7dbb151 more content 2025-06-23 18:53:49 -05:00
4e2e678de3 tweak 2025-06-20 17:34:21 -05:00
237 changed files with 6699 additions and 3124 deletions

View File

@ -15,8 +15,8 @@ jobs:
matrix: matrix:
version: version:
- docs - docs
- version-2025-4
- version-2025-2 - version-2025-2
- version-2024-12
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: | - run: |

View File

@ -226,61 +226,6 @@ jobs:
flags: e2e flags: e2e
file: unittest.xml file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
test-conformance:
name: test-conformance (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v5
with:
flags: conformance
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: conformance
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: conformance-certification-${{ matrix.job.glob }}
path: tests/openid_conformance/exports/
ci-core-mark: ci-core-mark:
if: always() if: always()
needs: needs:
@ -290,7 +235,6 @@ jobs:
- test-unittest - test-unittest
- test-integration - test-integration
- test-e2e - test-e2e
- test-conformance
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - uses: re-actors/alls-green@release/v1

View File

@ -2,7 +2,7 @@ name: "CodeQL"
on: on:
push: push:
branches: [main, next, version*] branches: [main, "*", next, version*]
pull_request: pull_request:
branches: [main] branches: [main]
schedule: schedule:

1
.gitignore vendored
View File

@ -217,4 +217,3 @@ source_docs/
### Docker ### ### Docker ###
docker-compose.override.yml docker-compose.override.yml
tests/openid_conformance/exports/*.zip

View File

@ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv # Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.7.14 AS uv FROM ghcr.io/astral-sh/uv:0.7.13 AS uv
# Stage 5: Base python image # Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base

View File

@ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable:
for blueprint_file in Path("blueprints/").glob("**/*.yaml"): for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
if "local" in str(blueprint_file) or "testing" in str(blueprint_file): if "local" in str(blueprint_file):
continue continue
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))

View File

@ -1,6 +1,8 @@
"""Authenticator Devices API Views""" """Authenticator Devices API Views"""
from drf_spectacular.utils import extend_schema from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import ( from rest_framework.fields import (
BooleanField, BooleanField,
@ -13,7 +15,6 @@ from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from authentik.core.api.users import ParamUserSerializer
from authentik.core.api.utils import MetaNameSerializer from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.stages.authenticator import device_classes, devices_for_user from authentik.stages.authenticator import device_classes, devices_for_user
@ -22,7 +23,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceSerializer(MetaNameSerializer): class DeviceSerializer(MetaNameSerializer):
"""Serializer for authenticator devices""" """Serializer for Duo authenticator devices"""
pk = CharField() pk = CharField()
name = CharField() name = CharField()
@ -32,27 +33,22 @@ class DeviceSerializer(MetaNameSerializer):
last_updated = DateTimeField(read_only=True) last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True) last_used = DateTimeField(read_only=True, allow_null=True)
extra_description = SerializerMethodField() extra_description = SerializerMethodField()
external_id = SerializerMethodField()
def get_type(self, instance: Device) -> str: def get_type(self, instance: Device) -> str:
"""Get type of device""" """Get type of device"""
return instance._meta.label return instance._meta.label
def get_extra_description(self, instance: Device) -> str | None: def get_extra_description(self, instance: Device) -> str:
"""Get extra description""" """Get extra description"""
if isinstance(instance, WebAuthnDevice): if isinstance(instance, WebAuthnDevice):
return instance.device_type.description if instance.device_type else None return (
instance.device_type.description
if instance.device_type
else _("Extra description not available")
)
if isinstance(instance, EndpointDevice): if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel") return instance.data.get("deviceSignals", {}).get("deviceModel")
return None return ""
def get_external_id(self, instance: Device) -> str | None:
"""Get external Device ID"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.aaguid if instance.device_type else None
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return None
class DeviceViewSet(ViewSet): class DeviceViewSet(ViewSet):
@ -61,6 +57,7 @@ class DeviceViewSet(ViewSet):
serializer_class = DeviceSerializer serializer_class = DeviceSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses={200: DeviceSerializer(many=True)})
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
"""Get all devices for current user""" """Get all devices for current user"""
devices = devices_for_user(request.user) devices = devices_for_user(request.user)
@ -82,11 +79,18 @@ class AdminDeviceViewSet(ViewSet):
yield from device_set yield from device_set
@extend_schema( @extend_schema(
parameters=[ParamUserSerializer], parameters=[
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
responses={200: DeviceSerializer(many=True)}, responses={200: DeviceSerializer(many=True)},
) )
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
"""Get all devices for current user""" """Get all devices for current user"""
args = ParamUserSerializer(data=request.query_params) kwargs = {}
args.is_valid(raise_exception=True) if "user" in request.query_params:
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data) kwargs = {"user": request.query_params["user"]}
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)

View File

@ -90,12 +90,6 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False)
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
"""Simplified Group Serializer for user's groups""" """Simplified Group Serializer for user's groups"""

View File

@ -13,6 +13,7 @@ class Command(TenantCommand):
parser.add_argument("usernames", nargs="*", type=str) parser.add_argument("usernames", nargs="*", type=str)
def handle_per_tenant(self, **options): def handle_per_tenant(self, **options):
print(options)
new_type = UserTypes(options["type"]) new_type = UserTypes(options["type"])
qs = ( qs = (
User.objects.exclude_anonymous() User.objects.exclude_anonymous()

View File

@ -1,8 +1,10 @@
from hashlib import sha256 from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http.request import HttpRequest
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from authentik.core.models import ( from authentik.core.models import (
@ -60,6 +62,31 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created:
instance.save() instance.save()
@receiver(user_logged_out)
def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_):
"""Session revoked trigger (user logged out)"""
if not request.session or not request.session.session_key or not user:
return
send_ssf_event(
EventTypes.CAEP_SESSION_REVOKED,
{
"initiating_entity": "user",
},
sub_id={
"format": "complex",
"session": {
"format": "opaque",
"id": sha256(request.session.session_key.encode("ascii")).hexdigest(),
},
"user": {
"format": "email",
"email": user.email,
},
},
request=request,
)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_):
"""Session revoked trigger (users' session has been deleted) """Session revoked trigger (users' session has been deleted)

View File

@ -97,7 +97,6 @@ class SourceStageFinal(StageView):
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan plan = token.plan
plan.context.update(self.executor.plan.context)
plan.context[PLAN_CONTEXT_IS_RESTORED] = token plan.context[PLAN_CONTEXT_IS_RESTORED] = token
response = plan.to_redirect(self.request, token.flow) response = plan.to_redirect(self.request, token.flow)
token.delete() token.delete()

View File

@ -90,17 +90,14 @@ class TestSourceStage(FlowTestCase):
plan: FlowPlan = session[SESSION_KEY_PLAN] plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
plan.context["foo"] = "bar"
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session.save() session.save()
# Pretend we've just returned from the source # Pretend we've just returned from the source
with self.assertFlowFinishes() as ff: response = self.client.get(
response = self.client.get( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True )
) self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200) self.assertStageRedirects(
self.assertStageRedirects( response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) )
)
self.assertEqual(ff().context["foo"], "bar")

View File

@ -19,7 +19,7 @@ from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict from authentik.events.utils import model_to_dict
from authentik.lib.sentry import should_ignore_exception from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.stages.authenticator_static.models import StaticToken from authentik.stages.authenticator_static.models import StaticToken
@ -173,7 +173,7 @@ class AuditMiddleware:
message=exception_to_string(exception), message=exception_to_string(exception),
) )
thread.run() thread.run()
elif not should_ignore_exception(exception): elif before_send({}, {"exc_info": (None, exception, None)}) is not None:
thread = EventNewThread( thread = EventNewThread(
EventAction.SYSTEM_EXCEPTION, EventAction.SYSTEM_EXCEPTION,
request, request,

View File

@ -2,9 +2,7 @@
from django.test import TestCase from django.test import TestCase
from authentik.events.context_processors.base import get_context_processors
from authentik.events.context_processors.geoip import GeoIPContextProcessor from authentik.events.context_processors.geoip import GeoIPContextProcessor
from authentik.events.models import Event, EventAction
class TestGeoIP(TestCase): class TestGeoIP(TestCase):
@ -15,7 +13,8 @@ class TestGeoIP(TestCase):
def test_simple(self): def test_simple(self):
"""Test simple city wrapper""" """Test simple city wrapper"""
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json # IPs from
# https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
self.assertEqual( self.assertEqual(
self.reader.city_dict("2.125.160.216"), self.reader.city_dict("2.125.160.216"),
{ {
@ -26,12 +25,3 @@ class TestGeoIP(TestCase):
"long": -1.25, "long": -1.25,
}, },
) )
def test_special_chars(self):
"""Test city name with special characters"""
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
event = Event.new(EventAction.LOGIN)
event.client_ip = "89.160.20.112"
for processor in get_context_processors():
processor.enrich_event(event)
event.save()

View File

@ -4,10 +4,8 @@ from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode from urllib.parse import urlencode
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_flow, create_test_user from authentik.core.tests.utils import create_test_flow, create_test_user
@ -650,25 +648,3 @@ class TestFlowExecutor(FlowTestCase):
self.assertStageResponse(response, flow, component="ak-stage-identification") self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True) response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
self.assertStageResponse(response, flow, component="ak-stage-access-denied") self.assertStageResponse(response, flow, component="ak-stage-access-denied")
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_json(self):
"""Test invalid JSON body"""
flow = create_test_flow()
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
with override_settings(TEST=False, DEBUG=False):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)
with self.assertRaises(ParseError):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)

View File

@ -55,7 +55,7 @@ from authentik.flows.planner import (
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import AccessDeniedStage, StageView from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
@ -234,13 +234,12 @@ class FlowExecutorView(APIView):
"""Handle exception in stage execution""" """Handle exception in stage execution"""
if settings.DEBUG or settings.TEST: if settings.DEBUG or settings.TEST:
raise exc raise exc
capture_exception(exc)
self._logger.warning(exc) self._logger.warning(exc)
if not should_ignore_exception(exc): Event.new(
capture_exception(exc) action=EventAction.SYSTEM_EXCEPTION,
Event.new( message=exception_to_string(exc),
action=EventAction.SYSTEM_EXCEPTION, ).from_http(self.request)
message=exception_to_string(exc),
).from_http(self.request)
challenge = FlowErrorChallenge(self.request, exc) challenge = FlowErrorChallenge(self.request, exc)
challenge.is_valid(raise_exception=True) challenge.is_valid(raise_exception=True)
return to_stage_response(self.request, HttpChallengeResponse(challenge)) return to_stage_response(self.request, HttpChallengeResponse(challenge))

View File

@ -104,7 +104,6 @@ def get_logger_config():
"hpack": "WARNING", "hpack": "WARNING",
"httpx": "WARNING", "httpx": "WARNING",
"azure": "WARNING", "azure": "WARNING",
"httpcore": "WARNING",
} }
for handler_name, level in handler_level_map.items(): for handler_name, level in handler_level_map.items():
base_config["loggers"][handler_name] = { base_config["loggers"][handler_name] = {

View File

@ -14,7 +14,6 @@ from django_redis.exceptions import ConnectionInterrupted
from docker.errors import DockerException from docker.errors import DockerException
from h11 import LocalProtocolError from h11 import LocalProtocolError
from ldap3.core.exceptions import LDAPException from ldap3.core.exceptions import LDAPException
from psycopg.errors import Error
from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError, ResponseError from redis.exceptions import RedisError, ResponseError
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
@ -45,49 +44,6 @@ class SentryIgnoredException(Exception):
"""Base Class for all errors that are suppressed, and not sent to sentry.""" """Base Class for all errors that are suppressed, and not sent to sentry."""
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
class SentryTransport(HttpTransport): class SentryTransport(HttpTransport):
"""Custom sentry transport with custom user-agent""" """Custom sentry transport with custom user-agent"""
@ -145,17 +101,56 @@ def traces_sampler(sampling_context: dict) -> float:
return float(CONFIG.get("error_reporting.sample_rate", 0.1)) return float(CONFIG.get("error_reporting.sample_rate", 0.1))
def should_ignore_exception(exc: Exception) -> bool:
"""Check if an exception should be dropped"""
return isinstance(exc, ignored_classes)
def before_send(event: dict, hint: dict) -> dict | None: def before_send(event: dict, hint: dict) -> dict | None:
"""Check if error is database error, and ignore if so""" """Check if error is database error, and ignore if so"""
from psycopg.errors import Error
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
exc_value = None exc_value = None
if "exc_info" in hint: if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"] _, exc_value, _ = hint["exc_info"]
if should_ignore_exception(exc_value): if isinstance(exc_value, ignored_classes):
LOGGER.debug("dropping exception", exc=exc_value) LOGGER.debug("dropping exception", exc=exc_value)
return None return None
if "logger" in event: if "logger" in event:

View File

@ -2,7 +2,7 @@
from django.test import TestCase from django.test import TestCase
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception from authentik.lib.sentry import SentryIgnoredException, before_send
class TestSentry(TestCase): class TestSentry(TestCase):
@ -10,8 +10,8 @@ class TestSentry(TestCase):
def test_error_not_sent(self): def test_error_not_sent(self):
"""Test SentryIgnoredError not sent""" """Test SentryIgnoredError not sent"""
self.assertTrue(should_ignore_exception(SentryIgnoredException())) self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)}))
def test_error_sent(self): def test_error_sent(self):
"""Test error sent""" """Test error sent"""
self.assertFalse(should_ignore_exception(ValueError())) self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)}))

View File

@ -1,13 +1,15 @@
"""authentik outpost signals""" """authentik outpost signals"""
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import AuthenticatedSession, Provider from authentik.core.models import AuthenticatedSession, Provider, User
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
@ -80,6 +82,14 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
@receiver(user_logged_out)
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to providers"""
if not request.session or not request.session.session_key:
return
outpost_session_end.delay(request.session.session_key)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted""" """Catch logout by expiring sessions being deleted"""

View File

@ -1,10 +1,23 @@
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import AuthenticatedSession, User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out)
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_): def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout""" """Revoke tokens upon user logout"""

View File

@ -2,11 +2,13 @@
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession, User
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import ( from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION, RAC_CLIENT_GROUP_SESSION,
@ -15,6 +17,21 @@ from authentik.providers.rac.consumer_client import (
from authentik.providers.rac.models import ConnectionToken, Endpoint from authentik.providers.rac.models import ConnectionToken, Endpoint
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Disconnect any open RAC connections"""
if not request.session or not request.session.session_key:
return
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": request.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_): def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer() layer = get_channel_layer()

View File

@ -5,6 +5,7 @@ from itertools import batched
from django.db import transaction from django.db import transaction
from pydantic import ValidationError from pydantic import ValidationError
from pydanticscim.group import GroupMember from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp
from authentik.core.models import Group from authentik.core.models import Group
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.mapper import PropertyMappingManager
@ -19,12 +20,7 @@ from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import ( from authentik.providers.scim.clients.exceptions import (
SCIMRequestException, SCIMRequestException,
) )
from authentik.providers.scim.clients.schema import ( from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
SCIM_GROUP_SCHEMA,
PatchOp,
PatchOperation,
PatchRequest,
)
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.providers.scim.models import ( from authentik.providers.scim.models import (
SCIMMapping, SCIMMapping,

View File

@ -1,7 +1,5 @@
"""Custom SCIM schemas""" """Custom SCIM schemas"""
from enum import Enum
from pydantic import Field from pydantic import Field
from pydanticscim.group import Group as BaseGroup from pydanticscim.group import Group as BaseGroup
from pydanticscim.responses import PatchOperation as BasePatchOperation from pydanticscim.responses import PatchOperation as BasePatchOperation
@ -67,21 +65,6 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
) )
class PatchOp(str, Enum):
replace = "replace"
remove = "remove"
add = "add"
@classmethod
def _missing_(cls, value):
value = value.lower()
for member in cls:
if member.lower() == value:
return member
return None
class PatchRequest(BasePatchRequest): class PatchRequest(BasePatchRequest):
"""PatchRequest which correctly sets schemas""" """PatchRequest which correctly sets schemas"""
@ -91,7 +74,6 @@ class PatchRequest(BasePatchRequest):
class PatchOperation(BasePatchOperation): class PatchOperation(BasePatchOperation):
"""PatchOperation with optional path""" """PatchOperation with optional path"""
op: PatchOp
path: str | None path: str | None

View File

@ -27,7 +27,7 @@ from structlog.stdlib import get_logger
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
from authentik import get_full_version from authentik import get_full_version
from authentik.lib.sentry import should_ignore_exception from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
@ -81,7 +81,7 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception) LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception)
CTX_TASK_ID.set(...) CTX_TASK_ID.set(...)
if not should_ignore_exception(exception): if before_send({}, {"exc_info": (None, exception, None)}) is not None:
Event.new( Event.new(
EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id
).save() ).save()

View File

@ -1,49 +1,13 @@
"""authentik database backend""" """authentik database backend"""
from django.core.checks import Warning
from django.db.backends.base.validation import BaseDatabaseValidation
from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
class DatabaseValidation(BaseDatabaseValidation):
def check(self, **kwargs):
return self._check_encoding()
def _check_encoding(self):
"""Throw a warning when the server_encoding is not UTF-8 or
server_encoding and client_encoding are mismatched"""
messages = []
with self.connection.cursor() as cursor:
cursor.execute("SHOW server_encoding;")
server_encoding = cursor.fetchone()[0]
cursor.execute("SHOW client_encoding;")
client_encoding = cursor.fetchone()[0]
if server_encoding != client_encoding:
messages.append(
Warning(
"PostgreSQL Server and Client encoding are mismatched: Server: "
f"{server_encoding}, Client: {client_encoding}",
id="ak.db.W001",
)
)
if server_encoding != "UTF8":
messages.append(
Warning(
f"PostgreSQL Server encoding is not UTF8: {server_encoding}",
id="ak.db.W002",
)
)
return messages
class DatabaseWrapper(BaseDatabaseWrapper): class DatabaseWrapper(BaseDatabaseWrapper):
"""database backend which supports rotating credentials""" """database backend which supports rotating credentials"""
validation_class = DatabaseValidation
def get_connection_params(self): def get_connection_params(self):
"""Refresh DB credentials before getting connection params""" """Refresh DB credentials before getting connection params"""
conn_params = super().get_connection_params() conn_params = super().get_connection_params()

View File

@ -1,277 +0,0 @@
"""Test SCIM Group"""
from json import dumps
from uuid import uuid4
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.sources.scim.models import (
SCIMSource,
SCIMSourceGroup,
)
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
class TestSCIMGroups(APITestCase):
"""Test SCIM Group view"""
def setUp(self) -> None:
self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id())
def test_group_list(self):
"""Test full group list"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_group_list_single(self):
"""Test full group list (single group)"""
group = Group.objects.create(name=generate_id())
user = create_test_user()
group.users.add(user)
SCIMSourceGroup.objects.create(
source=self.source,
group=group,
id=str(uuid4()),
)
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(group.pk),
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
SCIMGroupSchema.model_validate_json(response.content, strict=True)
def test_group_create(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members(self):
"""Test group create"""
user = create_test_user()
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{
"displayName": generate_id(),
"externalId": ext_id,
"members": [{"value": str(user.uuid)}],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members_empty(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_duplicate(self):
"""Test group create (duplicate)"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 409)
self.assertJSONEqual(
response.content,
{
"detail": "Group with ID exists already.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "uniqueness",
"status": 409,
},
)
def test_group_update(self):
"""Test group update"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
def test_group_update_non_existent(self):
"""Test group update"""
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(uuid4()),
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=404)
self.assertJSONEqual(
response.content,
{
"detail": "Group not found.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": 404,
},
)
def test_group_patch_add(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "Add",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertTrue(group.users.filter(pk=user.pk).exists())
def test_group_patch_remove(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
group.users.add(user)
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "remove",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertFalse(group.users.filter(pk=user.pk).exists())
def test_group_delete(self):
"""Test group delete"""
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=204)

View File

@ -177,51 +177,3 @@ class TestSCIMUsers(APITestCase):
SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"],
"0123456789", "0123456789",
) )
def test_user_update(self):
"""Test user update"""
user = create_test_user()
existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
data=dumps(
{
"id": str(existing.pk),
"userName": generate_id(),
"externalId": ext_id,
"emails": [
{
"primary": True,
"value": user.email,
}
],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_user_delete(self):
"""Test user delete"""
user = create_test_user()
SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 204)

View File

@ -8,7 +8,6 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.sources.scim.models import SCIMSource from authentik.sources.scim.models import SCIMSource
@ -27,7 +26,6 @@ class SCIMTokenAuth(BaseAuthentication):
_username, _, password = b64decode(key.encode()).decode().partition(":") _username, _, password = b64decode(key.encode()).decode().partition(":")
token = self.check_token(password, source_slug) token = self.check_token(password, source_slug)
if token: if token:
CTX_AUTH_VIA.set("scim_basic")
return (token.user, token) return (token.user, token)
return None return None
@ -54,5 +52,4 @@ class SCIMTokenAuth(BaseAuthentication):
token = self.check_token(key, source_slug) token = self.check_token(key, source_slug)
if not token: if not token:
return None return None
CTX_AUTH_VIA.set("scim_token")
return (token.user, token) return (token.user, token)

View File

@ -1,11 +1,13 @@
"""SCIM Utils""" """SCIM Utils"""
from typing import Any from typing import Any
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.core.paginator import Page, Paginator from django.core.paginator import Page, Paginator
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import resolve
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
@ -44,7 +46,7 @@ class SCIMView(APIView):
logger: BoundLogger logger: BoundLogger
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
parser_classes = [SCIMParser, JSONParser] parser_classes = [SCIMParser]
renderer_classes = [SCIMRenderer] renderer_classes = [SCIMRenderer]
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
@ -54,6 +56,28 @@ class SCIMView(APIView):
def get_authenticators(self): def get_authenticators(self):
return [SCIMTokenAuth(self)] return [SCIMTokenAuth(self)]
def patch_resolve_value(self, raw_value: dict) -> User | Group | None:
"""Attempt to resolve a raw `value` attribute of a patch operation into
a database model"""
model = User
query = {}
if "$ref" in raw_value:
url = urlparse(raw_value["$ref"])
if match := resolve(url.path):
if match.url_name == "v2-users":
model = User
query = {"pk": int(match.kwargs["user_id"])}
elif "type" in raw_value:
match raw_value["type"]:
case "User":
model = User
query = {"pk": int(raw_value["value"])}
case "Group":
model = Group
else:
return None
return model.objects.filter(**query).first()
def filter_parse(self, request: Request): def filter_parse(self, request: Request):
"""Parse the path of a Patch Operation""" """Parse the path of a Patch Operation"""
path = request.query_params.get("filter") path = request.query_params.get("filter")

View File

@ -1,58 +0,0 @@
from enum import Enum
from pydanticscim.responses import SCIMError as BaseSCIMError
from rest_framework.exceptions import ValidationError
class SCIMErrorTypes(Enum):
invalid_filter = "invalidFilter"
too_many = "tooMany"
uniqueness = "uniqueness"
mutability = "mutability"
invalid_syntax = "invalidSyntax"
invalid_path = "invalidPath"
no_target = "noTarget"
invalid_value = "invalidValue"
invalid_vers = "invalidVers"
sensitive = "sensitive"
class SCIMError(BaseSCIMError):
scimType: SCIMErrorTypes | None = None
detail: str | None = None
class SCIMValidationError(ValidationError):
status_code = 400
default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400)
def __init__(self, detail: SCIMError | None):
if detail is None:
detail = self.default_detail
detail.status = self.status_code
self.detail = detail.model_dump(mode="json", exclude_none=True)
class SCIMConflictError(SCIMValidationError):
status_code = 409
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
scimType=SCIMErrorTypes.uniqueness,
status=self.status_code,
)
)
class SCIMNotFoundError(SCIMValidationError):
status_code = 404
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
status=self.status_code,
)
)

View File

@ -4,25 +4,19 @@ from uuid import uuid4
from django.db.models import Q from django.db.models import Q
from django.db.transaction import atomic from django.db.transaction import atomic
from django.http import QueryDict from django.http import Http404, QueryDict
from django.urls import reverse from django.urls import reverse
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from pydanticscim.group import GroupMember from pydanticscim.group import GroupMember
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from scim2_filter_parser.attr_paths import AttrPath
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
from authentik.sources.scim.models import SCIMSourceGroup from authentik.sources.scim.models import SCIMSourceGroup
from authentik.sources.scim.views.v2.base import SCIMObjectView from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import (
SCIMConflictError,
SCIMNotFoundError,
SCIMValidationError,
)
class GroupsView(SCIMObjectView): class GroupsView(SCIMObjectView):
@ -33,7 +27,7 @@ class GroupsView(SCIMObjectView):
def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
"""Convert Group to SCIM data""" """Convert Group to SCIM data"""
payload = SCIMGroupModel( payload = SCIMGroupModel(
schemas=[SCIM_GROUP_SCHEMA], schemas=[SCIM_USER_SCHEMA],
id=str(scim_group.group.pk), id=str(scim_group.group.pk),
externalId=scim_group.id, externalId=scim_group.id,
displayName=scim_group.group.name, displayName=scim_group.group.name,
@ -64,7 +58,7 @@ class GroupsView(SCIMObjectView):
if group_id: if group_id:
connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
if not connection: if not connection:
raise SCIMNotFoundError("Group not found.") raise Http404
return Response(self.group_to_scim(connection)) return Response(self.group_to_scim(connection))
connections = ( connections = (
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
@ -125,7 +119,7 @@ class GroupsView(SCIMObjectView):
).first() ).first()
if connection: if connection:
self.logger.debug("Found existing group") self.logger.debug("Found existing group")
raise SCIMConflictError("Group with ID exists already.") return Response(status=409)
connection = self.update_group(None, request.data) connection = self.update_group(None, request.data)
return Response(self.group_to_scim(connection), status=201) return Response(self.group_to_scim(connection), status=201)
@ -135,44 +129,10 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id source=self.source, group__group_uuid=group_id
).first() ).first()
if not connection: if not connection:
raise SCIMNotFoundError("Group not found.") raise Http404
connection = self.update_group(connection, request.data) connection = self.update_group(connection, request.data)
return Response(self.group_to_scim(connection), status=200) return Response(self.group_to_scim(connection), status=200)
@atomic
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
"""Patch group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise SCIMNotFoundError("Group not found.")
for _op in request.data.get("Operations", []):
operation = PatchOperation.model_validate(_op)
if operation.op.lower() not in ["add", "remove", "replace"]:
raise SCIMValidationError()
attr_path = AttrPath(f'{operation.path} eq ""', {})
if attr_path.first_path == ("members", None, None):
# FIXME: this can probably be de-duplicated
if operation.op == PatchOp.add:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.add(*User.objects.filter(query))
elif operation.op == PatchOp.remove:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.remove(*User.objects.filter(query))
return Response(self.group_to_scim(connection), status=200)
@atomic @atomic
def delete(self, request: Request, group_id: str, **kwargs) -> Response: def delete(self, request: Request, group_id: str, **kwargs) -> Response:
"""Delete group handler""" """Delete group handler"""
@ -180,7 +140,7 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id source=self.source, group__group_uuid=group_id
).first() ).first()
if not connection: if not connection:
raise SCIMNotFoundError("Group not found.") raise Http404
connection.group.delete() connection.group.delete()
connection.delete() connection.delete()
return Response(status=204) return Response(status=204)

View File

@ -1,11 +1,11 @@
"""SCIM Meta views""" """SCIM Meta views"""
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
class ResourceTypesView(SCIMView): class ResourceTypesView(SCIMView):
@ -138,7 +138,7 @@ class ResourceTypesView(SCIMView):
resource = [x for x in resource_types if x.get("id") == resource_type] resource = [x for x in resource_types if x.get("id") == resource_type]
if resource: if resource:
return Response(resource[0]) return Response(resource[0])
raise SCIMNotFoundError("Resource not found.") raise Http404
return Response( return Response(
{ {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -3,12 +3,12 @@
from json import loads from json import loads
from django.conf import settings from django.conf import settings
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
with open( with open(
settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json",
@ -44,7 +44,7 @@ class SchemaView(SCIMView):
schema = [x for x in schemas if x.get("id") == schema_uri] schema = [x for x in schemas if x.get("id") == schema_uri]
if schema: if schema:
return Response(schema[0]) return Response(schema[0])
raise SCIMNotFoundError("Schema not found.") raise Http404
return Response( return Response(
{ {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -33,8 +33,6 @@ class ServiceProviderConfigView(SCIMView):
{ {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"authenticationSchemes": auth_schemas, "authenticationSchemes": auth_schemas,
# We only support patch for groups currently, so don't broadly advertise it.
# Implementations that require Group patch will use it regardless of this flag.
"patch": {"supported": False}, "patch": {"supported": False},
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": { "filter": {

View File

@ -4,7 +4,7 @@ from uuid import uuid4
from django.db.models import Q from django.db.models import Q
from django.db.transaction import atomic from django.db.transaction import atomic
from django.http import QueryDict from django.http import Http404, QueryDict
from django.urls import reverse from django.urls import reverse
from pydanticscim.user import Email, EmailKind, Name from pydanticscim.user import Email, EmailKind, Name
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -16,7 +16,6 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import User as SCIMUserModel from authentik.providers.scim.clients.schema import User as SCIMUserModel
from authentik.sources.scim.models import SCIMSourceUser from authentik.sources.scim.models import SCIMSourceUser
from authentik.sources.scim.views.v2.base import SCIMObjectView from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError
class UsersView(SCIMObjectView): class UsersView(SCIMObjectView):
@ -70,7 +69,7 @@ class UsersView(SCIMObjectView):
.first() .first()
) )
if not connection: if not connection:
raise SCIMNotFoundError("User not found.") raise Http404
return Response(self.user_to_scim(connection)) return Response(self.user_to_scim(connection))
connections = ( connections = (
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
@ -123,7 +122,7 @@ class UsersView(SCIMObjectView):
).first() ).first()
if connection: if connection:
self.logger.debug("Found existing user") self.logger.debug("Found existing user")
raise SCIMConflictError("Group with ID exists already.") return Response(status=409)
connection = self.update_user(None, request.data) connection = self.update_user(None, request.data)
return Response(self.user_to_scim(connection), status=201) return Response(self.user_to_scim(connection), status=201)
@ -131,7 +130,7 @@ class UsersView(SCIMObjectView):
"""Update user handler""" """Update user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection: if not connection:
raise SCIMNotFoundError("User not found.") raise Http404
self.update_user(connection, request.data) self.update_user(connection, request.data)
return Response(self.user_to_scim(connection), status=200) return Response(self.user_to_scim(connection), status=200)
@ -140,7 +139,7 @@ class UsersView(SCIMObjectView):
"""Delete user handler""" """Delete user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection: if not connection:
raise SCIMNotFoundError("User not found.") raise Http404
connection.user.delete() connection.user.delete()
connection.delete() connection.delete()
return Response(status=204) return Response(status=204)

View File

@ -1,7 +1,6 @@
"""Validation stage challenge checking""" """Validation stage challenge checking"""
from json import loads from json import loads
from typing import TYPE_CHECKING
from urllib.parse import urlencode from urllib.parse import urlencode
from django.http import HttpRequest from django.http import HttpRequest
@ -37,12 +36,10 @@ from authentik.stages.authenticator_email.models import EmailDevice
from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_sms.models import SMSDevice
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING:
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
class DeviceChallenge(PassiveSerializer): class DeviceChallenge(PassiveSerializer):
@ -55,11 +52,11 @@ class DeviceChallenge(PassiveSerializer):
def get_challenge_for_device( def get_challenge_for_device(
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
) -> dict: ) -> dict:
"""Generate challenge for a single device""" """Generate challenge for a single device"""
if isinstance(device, WebAuthnDevice): if isinstance(device, WebAuthnDevice):
return get_webauthn_challenge(stage_view, stage, device) return get_webauthn_challenge(request, stage, device)
if isinstance(device, EmailDevice): if isinstance(device, EmailDevice):
return {"email": mask_email(device.email)} return {"email": mask_email(device.email)}
# Code-based challenges have no hints # Code-based challenges have no hints
@ -67,30 +64,26 @@ def get_challenge_for_device(
def get_webauthn_challenge_without_user( def get_webauthn_challenge_without_user(
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage request: HttpRequest, stage: AuthenticatorValidateStage
) -> dict: ) -> dict:
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check """Same as `get_webauthn_challenge`, but allows any client device. We can then later check
who the device belongs to.""" who the device belongs to."""
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
authentication_options = generate_authentication_options( authentication_options = generate_authentication_options(
rp_id=get_rp_id(stage_view.request), rp_id=get_rp_id(request),
allow_credentials=[], allow_credentials=[],
user_verification=UserVerificationRequirement(stage.webauthn_user_verification), user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
) )
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
authentication_options.challenge
)
return loads(options_to_json(authentication_options)) return loads(options_to_json(authentication_options))
def get_webauthn_challenge( def get_webauthn_challenge(
stage_view: "AuthenticatorValidateStageView", request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None
stage: AuthenticatorValidateStage,
device: WebAuthnDevice | None = None,
) -> dict: ) -> dict:
"""Send the client a challenge that we'll check later""" """Send the client a challenge that we'll check later"""
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
allowed_credentials = [] allowed_credentials = []
@ -101,14 +94,12 @@ def get_webauthn_challenge(
allowed_credentials.append(user_device.descriptor) allowed_credentials.append(user_device.descriptor)
authentication_options = generate_authentication_options( authentication_options = generate_authentication_options(
rp_id=get_rp_id(stage_view.request), rp_id=get_rp_id(request),
allow_credentials=allowed_credentials, allow_credentials=allowed_credentials,
user_verification=UserVerificationRequirement(stage.webauthn_user_verification), user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
) )
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
authentication_options.challenge
)
return loads(options_to_json(authentication_options)) return loads(options_to_json(authentication_options))
@ -155,7 +146,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
"""Validate WebAuthn Challenge""" """Validate WebAuthn Challenge"""
request = stage_view.request request = stage_view.request
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE) challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
stage: AuthenticatorValidateStage = stage_view.executor.current_stage stage: AuthenticatorValidateStage = stage_view.executor.current_stage
try: try:
credential = parse_authentication_credential_json(data) credential = parse_authentication_credential_json(data)

View File

@ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
data={ data={
"device_class": device_class, "device_class": device_class,
"device_uid": device.pk, "device_uid": device.pk,
"challenge": get_challenge_for_device(self, stage, device), "challenge": get_challenge_for_device(self.request, stage, device),
"last_used": device.last_used, "last_used": device.last_used,
} }
) )
@ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
"device_class": DeviceClasses.WEBAUTHN, "device_class": DeviceClasses.WEBAUTHN,
"device_uid": -1, "device_uid": -1,
"challenge": get_webauthn_challenge_without_user( "challenge": get_webauthn_challenge_without_user(
self, self.request,
self.executor.current_stage, self.executor.current_stage,
), ),
"last_used": None, "last_used": None,

View File

@ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice, WebAuthnDevice,
WebAuthnDeviceType, WebAuthnDeviceType,
) )
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
from authentik.stages.identification.models import IdentificationStage, UserFields from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
@ -103,11 +103,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
webauthn_user_verification=UserVerification.PREFERRED, webauthn_user_verification=UserVerification.PREFERRED,
) )
plan = FlowPlan("") challenge = get_challenge_for_device(request, stage, webauthn_device)
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
del challenge["challenge"] del challenge["challenge"]
self.assertEqual( self.assertEqual(
challenge, challenge,
@ -126,9 +122,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
validate_challenge_webauthn( validate_challenge_webauthn(
{}, {}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
self.user,
) )
def test_device_challenge_webauthn_restricted(self): def test_device_challenge_webauthn_restricted(self):
@ -199,35 +193,22 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0, sign_count=0,
rp_id=generate_id(), rp_id=generate_id(),
) )
plan = FlowPlan("") challenge = get_challenge_for_device(request, stage, webauthn_device)
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
self.assertEqual( self.assertEqual(
challenge["allowCredentials"], challenge,
[ {
{ "allowCredentials": [
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", {
"type": "public-key", "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
} "type": "public-key",
], }
) ],
self.assertIsNotNone(challenge["challenge"]) "challenge": bytes_to_base64url(webauthn_challenge),
self.assertEqual( "rpId": "testserver",
challenge["rpId"], "timeout": 60000,
"testserver", "userVerification": "preferred",
) },
self.assertEqual(
challenge["timeout"],
60000,
)
self.assertEqual(
challenge["userVerification"],
"preferred",
) )
def test_get_challenge_userless(self): def test_get_challenge_userless(self):
@ -247,16 +228,18 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0, sign_count=0,
rp_id=generate_id(), rp_id=generate_id(),
) )
plan = FlowPlan("") challenge = get_webauthn_challenge_without_user(request, stage)
stage_view = AuthenticatorValidateStageView( webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request self.assertEqual(
challenge,
{
"allowCredentials": [],
"challenge": bytes_to_base64url(webauthn_challenge),
"rpId": "testserver",
"timeout": 60000,
"userVerification": "preferred",
},
) )
challenge = get_webauthn_challenge_without_user(stage_view, stage)
self.assertEqual(challenge["allowCredentials"], [])
self.assertIsNotNone(challenge["challenge"])
self.assertEqual(challenge["rpId"], "testserver")
self.assertEqual(challenge["timeout"], 60000)
self.assertEqual(challenge["userVerification"], "preferred")
def test_validate_challenge_unrestricted(self): def test_validate_challenge_unrestricted(self):
"""Test webauthn authentication (unrestricted webauthn device)""" """Test webauthn authentication (unrestricted webauthn device)"""
@ -292,10 +275,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -369,10 +352,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -450,10 +433,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -513,14 +496,17 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
) )
plan = FlowPlan(flow.pk.hex) stage_view = AuthenticatorValidateStageView(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( FlowExecutorView(flow=flow, current_stage=stage), request=request
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )
request = get_request("/") request = get_request("/")
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
request.session.save()
stage_view = AuthenticatorValidateStageView( stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request FlowExecutorView(flow=flow, current_stage=stage), request=request
) )
request.META["SERVER_NAME"] = "localhost" request.META["SERVER_NAME"] = "localhost"
request.META["SERVER_PORT"] = "9000" request.META["SERVER_PORT"] = "9000"

View File

@ -25,7 +25,6 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
"resident_key_requirement", "resident_key_requirement",
"device_type_restrictions", "device_type_restrictions",
"device_type_restrictions_obj", "device_type_restrictions_obj",
"max_attempts",
] ]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.1.11 on 2025-06-13 22:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_webauthn",
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
),
]
operations = [
migrations.AddField(
model_name="authenticatorwebauthnstage",
name="max_attempts",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -84,8 +84,6 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True) device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
max_attempts = models.PositiveIntegerField(default=0)
@property @property
def serializer(self) -> type[BaseSerializer]: def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_webauthn.api.stages import ( from authentik.stages.authenticator_webauthn.api.stages import (

View File

@ -5,13 +5,12 @@ from uuid import UUID
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.translation import gettext as __
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from webauthn import options_to_json from webauthn import options_to_json
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
from webauthn.helpers.exceptions import WebAuthnException from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.structs import ( from webauthn.helpers.structs import (
AttestationConveyancePreference, AttestationConveyancePreference,
AuthenticatorAttachment, AuthenticatorAttachment,
@ -42,8 +41,7 @@ from authentik.stages.authenticator_webauthn.models import (
) )
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge" SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt"
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
@ -64,7 +62,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
def validate_response(self, response: dict) -> dict: def validate_response(self, response: dict) -> dict:
"""Validate webauthn challenge response""" """Validate webauthn challenge response"""
challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
try: try:
registration: VerifiedRegistration = verify_registration_response( registration: VerifiedRegistration = verify_registration_response(
@ -73,7 +71,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
expected_rp_id=get_rp_id(self.request), expected_rp_id=get_rp_id(self.request),
expected_origin=get_origin(self.request), expected_origin=get_origin(self.request),
) )
except WebAuthnException as exc: except InvalidRegistrationResponse as exc:
self.stage.logger.warning("registration failed", exc=exc) self.stage.logger.warning("registration failed", exc=exc)
raise ValidationError(f"Registration failed. Error: {exc}") from None raise ValidationError(f"Registration failed. Error: {exc}") from None
@ -116,10 +114,9 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response_class = AuthenticatorWebAuthnChallengeResponse response_class = AuthenticatorWebAuthnChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge: def get_challenge(self, *args, **kwargs) -> Challenge:
# clear session variables prior to starting a new registration
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage: AuthenticatorWebAuthnStage = self.executor.current_stage stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
# clear flow variables prior to starting a new registration
self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
user = self.get_pending_user() user = self.get_pending_user()
# library accepts none so we store null in the database, but if there is a value # library accepts none so we store null in the database, but if there is a value
@ -142,7 +139,8 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
attestation=AttestationConveyancePreference.DIRECT, attestation=AttestationConveyancePreference.DIRECT,
) )
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge
self.request.session.save()
return AuthenticatorWebAuthnChallenge( return AuthenticatorWebAuthnChallenge(
data={ data={
"registration": loads(options_to_json(registration_options)), "registration": loads(options_to_json(registration_options)),
@ -155,24 +153,6 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response.user = self.get_pending_user() response.user = self.get_pending_user()
return response return response
def challenge_invalid(self, response):
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1
if (
stage.max_attempts > 0
and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts
):
return self.executor.stage_invalid(
__(
"Exceeded maximum attempts. "
"Contact your {brand} administrator for help.".format(
brand=self.request.brand.branding_title
)
)
)
return super().challenge_invalid(response)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated # Webauthn Challenge has already been validated
webauthn_credential: VerifiedRegistration = response.validated_data["response"] webauthn_credential: VerifiedRegistration = response.validated_data["response"]
@ -199,3 +179,6 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
else: else:
return self.executor.stage_invalid("Device with Credential ID already exists.") return self.executor.stage_invalid("Device with Credential ID already exists.")
return self.executor.stage_ok() return self.executor.stage_ok()
def cleanup(self):
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)

View File

@ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice, WebAuthnDevice,
WebAuthnDeviceType, WebAuthnDeviceType,
) )
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
@ -57,9 +57,6 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
response = self.client.get( response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
) )
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
session = self.client.session session = self.client.session
self.assertStageResponse( self.assertStageResponse(
@ -73,7 +70,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"name": self.user.username, "name": self.user.username,
"displayName": self.user.name, "displayName": self.user.name,
}, },
"challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), "challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]),
"pubKeyCredParams": [ "pubKeyCredParams": [
{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -8}, {"type": "public-key", "alg": -8},
@ -100,11 +97,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"""Test registration""" """Test registration"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session session = self.client.session
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -149,11 +146,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session session = self.client.session
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -212,11 +209,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session session = self.client.session
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -262,11 +259,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session session = self.client.session
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -301,109 +298,3 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_max_retries(self):
"""Test registration (exceeding max retries)"""
self.stage.max_attempts = 2
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
# first failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-authenticator-webauthn",
response_errors={
"response": [
{
"string": (
"Registration failed. Error: Unable to decode "
"client_data_json bytes as JSON"
),
"code": "invalid",
}
]
},
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
# Second failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-access-denied",
error_message=(
"Exceeded maximum attempts. Contact your authentik administrator for help."
),
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())

View File

@ -16,7 +16,6 @@ def check_embedded_outpost_disabled(app_configs, **kwargs):
"Embedded outpost must be disabled when tenants API is enabled.", "Embedded outpost must be disabled when tenants API is enabled.",
hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to " hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to "
"True, or disable the tenants API by setting tenants.enabled to False", "True, or disable the tenants API by setting tenants.enabled to False",
id="ak.tenants.E001",
) )
] ]
return [] return []

View File

@ -13310,12 +13310,6 @@
"format": "uuid" "format": "uuid"
}, },
"title": "Device type restrictions" "title": "Device type restrictions"
},
"max_attempts": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Max attempts"
} }
}, },
"required": [] "required": []

4
go.mod
View File

@ -6,7 +6,7 @@ require (
beryju.io/ldap v0.1.0 beryju.io/ldap v0.1.0
github.com/avast/retry-go/v4 v4.6.1 github.com/avast/retry-go/v4 v4.6.1
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.34.0 github.com/getsentry/sentry-go v0.33.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/runtime v0.28.0
@ -29,7 +29,7 @@ require (
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2 github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025062.4 goauthentik.io/api/v3 v3.2025062.3
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0 golang.org/x/sync v0.15.0

8
go.sum
View File

@ -71,8 +71,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= 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/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.34.0 h1:1FCHBVp8TfSc8L10zqSwXUZNiOSF+10qw4czjarTiY4= github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg=
github.com/getsentry/sentry-go v0.34.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
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 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-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= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@ -298,8 +298,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025062.4 h1:HuyL12kKserXT2w+wCDUYNRSeyCCGX81wU9SRCPuxDo= goauthentik.io/api/v3 v3.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA=
goauthentik.io/api/v3 v3.2025062.4/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -10,7 +10,7 @@ from typing import Any
from psycopg import Connection, Cursor, connect from psycopg import Connection, Cursor, connect
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG, django_db_config from authentik.lib.config import CONFIG
LOGGER = get_logger() LOGGER = get_logger()
ADV_LOCK_UID = 1000 ADV_LOCK_UID = 1000
@ -115,13 +115,9 @@ def run_migrations():
execute_from_command_line(["", "migrate_schemas"]) execute_from_command_line(["", "migrate_schemas"])
if CONFIG.get_bool("tenants.enabled", False): if CONFIG.get_bool("tenants.enabled", False):
execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"]) execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"])
# Run django system checks for all databases execute_from_command_line(
check_args = ["", "check"] ["", "check"] + ([] if CONFIG.get_bool("debug") else ["--deploy"])
for label in django_db_config(CONFIG).keys(): )
check_args.append(f"--database={label}")
if not CONFIG.get_bool("debug"):
check_args.append("--deploy")
execute_from_command_line(check_args)
finally: finally:
release_lock(curr) release_lock(curr)
curr.close() curr.close()

View File

@ -576,17 +576,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
"integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "integrity": "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/type-utils": "8.35.0", "@typescript-eslint/type-utils": "8.34.1",
"@typescript-eslint/utils": "8.35.0", "@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -600,7 +600,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.35.0", "@typescript-eslint/parser": "^8.34.1",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <5.9.0"
} }
@ -616,16 +616,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.1.tgz",
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -641,14 +641,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.1.tgz",
"integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "integrity": "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.35.0", "@typescript-eslint/tsconfig-utils": "^8.34.1",
"@typescript-eslint/types": "^8.35.0", "@typescript-eslint/types": "^8.34.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -663,14 +663,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.1.tgz",
"integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "integrity": "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0" "@typescript-eslint/visitor-keys": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -681,9 +681,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.1.tgz",
"integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "integrity": "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -698,14 +698,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.1.tgz",
"integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "integrity": "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/utils": "8.35.0", "@typescript-eslint/utils": "8.34.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@ -722,9 +722,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz",
"integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -736,16 +736,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.1.tgz",
"integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "integrity": "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.35.0", "@typescript-eslint/project-service": "8.34.1",
"@typescript-eslint/tsconfig-utils": "8.35.0", "@typescript-eslint/tsconfig-utils": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -804,16 +804,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.1.tgz",
"integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "integrity": "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0" "@typescript-eslint/typescript-estree": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -828,13 +828,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.1.tgz",
"integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "integrity": "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -920,19 +920,17 @@
} }
}, },
"node_modules/array-includes": { "node_modules/array-includes": {
"version": "3.1.9", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.7",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1", "define-properties": "^1.2.1",
"es-abstract": "^1.24.0", "es-abstract": "^1.23.2",
"es-object-atoms": "^1.1.1", "es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.3.0", "get-intrinsic": "^1.2.4",
"is-string": "^1.1.1", "is-string": "^1.0.7"
"math-intrinsics": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1378,27 +1376,27 @@
} }
}, },
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.0", "version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
"integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"array-buffer-byte-length": "^1.0.2", "array-buffer-byte-length": "^1.0.2",
"arraybuffer.prototype.slice": "^1.0.4", "arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7", "available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8", "call-bind": "^1.0.8",
"call-bound": "^1.0.4", "call-bound": "^1.0.3",
"data-view-buffer": "^1.0.2", "data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2", "data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1", "data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1", "es-object-atoms": "^1.0.0",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0", "es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8", "function.prototype.name": "^1.1.8",
"get-intrinsic": "^1.3.0", "get-intrinsic": "^1.2.7",
"get-proto": "^1.0.1", "get-proto": "^1.0.0",
"get-symbol-description": "^1.1.0", "get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4", "globalthis": "^1.0.4",
"gopd": "^1.2.0", "gopd": "^1.2.0",
@ -1410,24 +1408,21 @@
"is-array-buffer": "^3.0.5", "is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-data-view": "^1.0.2", "is-data-view": "^1.0.2",
"is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1", "is-regex": "^1.2.1",
"is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4", "is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1", "is-string": "^1.1.1",
"is-typed-array": "^1.1.15", "is-typed-array": "^1.1.15",
"is-weakref": "^1.1.1", "is-weakref": "^1.1.0",
"math-intrinsics": "^1.1.0", "math-intrinsics": "^1.1.0",
"object-inspect": "^1.13.4", "object-inspect": "^1.13.3",
"object-keys": "^1.1.1", "object-keys": "^1.1.1",
"object.assign": "^4.1.7", "object.assign": "^4.1.7",
"own-keys": "^1.0.1", "own-keys": "^1.0.1",
"regexp.prototype.flags": "^1.5.4", "regexp.prototype.flags": "^1.5.3",
"safe-array-concat": "^1.1.3", "safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0", "safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0", "safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0", "set-proto": "^1.0.0",
"stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10", "string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9", "string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8", "string.prototype.trimstart": "^1.0.8",
@ -1436,7 +1431,7 @@
"typed-array-byte-offset": "^1.0.4", "typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7", "typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0", "unbox-primitive": "^1.1.0",
"which-typed-array": "^1.1.19" "which-typed-array": "^1.1.18"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1639,9 +1634,9 @@
} }
}, },
"node_modules/eslint-module-utils": { "node_modules/eslint-module-utils": {
"version": "2.12.1", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^3.2.7" "debug": "^3.2.7"
@ -1665,29 +1660,29 @@
} }
}, },
"node_modules/eslint-plugin-import": { "node_modules/eslint-plugin-import": {
"version": "2.32.0", "version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.8",
"array.prototype.findlastindex": "^1.2.6", "array.prototype.findlastindex": "^1.2.5",
"array.prototype.flat": "^1.3.3", "array.prototype.flat": "^1.3.2",
"array.prototype.flatmap": "^1.3.3", "array.prototype.flatmap": "^1.3.2",
"debug": "^3.2.7", "debug": "^3.2.7",
"doctrine": "^2.1.0", "doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9", "eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.12.1", "eslint-module-utils": "^2.12.0",
"hasown": "^2.0.2", "hasown": "^2.0.2",
"is-core-module": "^2.16.1", "is-core-module": "^2.15.1",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"object.fromentries": "^2.0.8", "object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3", "object.groupby": "^1.0.3",
"object.values": "^1.2.1", "object.values": "^1.2.0",
"semver": "^6.3.1", "semver": "^6.3.1",
"string.prototype.trimend": "^1.0.9", "string.prototype.trimend": "^1.0.8",
"tsconfig-paths": "^3.15.0" "tsconfig-paths": "^3.15.0"
}, },
"engines": { "engines": {
@ -2506,18 +2501,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
"integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -3710,19 +3693,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"internal-slot": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -4065,15 +4035,15 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.1.tgz",
"integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "integrity": "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/parser": "8.35.0", "@typescript-eslint/parser": "8.34.1",
"@typescript-eslint/utils": "8.35.0" "@typescript-eslint/utils": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -96,7 +96,6 @@ dev = [
"pytest-django==4.11.1", "pytest-django==4.11.1",
"pytest-github-actions-annotate-failures==0.3.0", "pytest-github-actions-annotate-failures==0.3.0",
"pytest-randomly==3.16.0", "pytest-randomly==3.16.0",
"pytest-subtests>=0.14.1",
"pytest-timeout==2.4.0", "pytest-timeout==2.4.0",
"requests-mock==1.12.1", "requests-mock==1.12.1",
"ruff==0.11.9", "ruff==0.11.9",

View File

@ -34963,10 +34963,6 @@ paths:
name: friendly_name name: friendly_name
schema: schema:
type: string type: string
- in: query
name: max_attempts
schema:
type: integer
- in: query - in: query
name: name name: name
schema: schema:
@ -42637,10 +42633,6 @@ components:
items: items:
$ref: '#/components/schemas/WebAuthnDeviceType' $ref: '#/components/schemas/WebAuthnDeviceType'
readOnly: true readOnly: true
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
required: required:
- component - component
- device_type_restrictions_obj - device_type_restrictions_obj
@ -42683,10 +42675,6 @@ components:
items: items:
type: string type: string
format: uuid format: uuid
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
required: required:
- name - name
AuthorizationCodeAuthMethodEnum: AuthorizationCodeAuthMethodEnum:
@ -43953,7 +43941,7 @@ components:
- name - name
Device: Device:
type: object type: object
description: Serializer for authenticator devices description: Serializer for Duo authenticator devices
properties: properties:
verbose_name: verbose_name:
type: string type: string
@ -43992,18 +43980,11 @@ components:
nullable: true nullable: true
extra_description: extra_description:
type: string type: string
nullable: true
description: Get extra description description: Get extra description
readOnly: true readOnly: true
external_id:
type: string
nullable: true
description: Get external Device ID
readOnly: true
required: required:
- confirmed - confirmed
- created - created
- external_id
- extra_description - extra_description
- last_updated - last_updated
- last_used - last_used
@ -52644,10 +52625,6 @@ components:
items: items:
type: string type: string
format: uuid format: uuid
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
PatchedBlueprintInstanceRequest: PatchedBlueprintInstanceRequest:
type: object type: object
description: Info about a single blueprint instance file description: Info about a single blueprint instance file

View File

@ -6,10 +6,8 @@ services:
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
network_mode: host network_mode: host
restart: always restart: always
extra_hosts:
- "host.docker.internal:host-gateway"
mailpit: mailpit:
image: docker.io/axllent/mailpit:v1.26.2 image: docker.io/axllent/mailpit:v1.26.1
ports: ports:
- 1025:1025 - 1025:1025
- 8025:8025 - 8025:8025

View File

@ -165,7 +165,6 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
def _get_driver(self) -> WebDriver: def _get_driver(self) -> WebDriver:
count = 0 count = 0
opts = webdriver.ChromeOptions() opts = webdriver.ChromeOptions()
opts.accept_insecure_certs = True
opts.add_argument("--disable-search-engine-choice-screen") opts.add_argument("--disable-search-engine-choice-screen")
# This breaks selenium when running remotely...? # This breaks selenium when running remotely...?
# opts.set_capability("goog:loggingPrefs", {"browser": "ALL"}) # opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
@ -250,6 +249,7 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
def login(self, shadow_dom=True): def login(self, shadow_dom=True):
"""Do entire login flow""" """Do entire login flow"""
if shadow_dom: if shadow_dom:
flow_executor = self.get_shadow_root("ak-flow-executor") flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor) identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)

View File

@ -0,0 +1,8 @@
# #Test files for OpenID Conformance testing.
These config files assume testing is being done using the [OpenID Conformance Suite
](https://openid.net/certification/about-conformance-suite/), locally.
See https://gitlab.com/openid/conformance-suite/-/wikis/Developers/Build-&-Run for running the conformance suite locally.
Requires docker containers to be able to access the host via `host.docker.internal` and an entry in the hosts file that maps `host.docker.internal` to localhost.

View File

@ -1,8 +1,6 @@
version: 1 version: 1
metadata: metadata:
name: OpenID Conformance testing name: OIDC conformance testing
labels:
blueprints.goauthentik.io/instantiate: "false"
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/providers/oauth2/scope-address managed: goauthentik.io/providers/oauth2/scope-address
@ -23,72 +21,38 @@ entries:
attrs: attrs:
name: "authentik default OAuth Mapping: OpenID 'phone'" name: "authentik default OAuth Mapping: OpenID 'phone'"
scope_name: phone scope_name: phone
description: "General phone information" description: "General phone Information"
expression: | expression: |
return { return {
"phone_number": "+1234", "phone_number": "+1234",
"phone_number_verified": True, "phone_number_verified": True,
} }
- identifiers:
managed: goauthentik.io/providers/oauth2/scope-profile-oidc-standard
model: authentik_providers_oauth2.scopemapping
attrs:
name: "OIDC conformance profile"
scope_name: profile
description: "General profile information"
expression: |
return {
# Because authentik only saves the user's full name, and has no concept of first and last names,
# the full name is used as given name.
# You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.username,
"nickname": request.user.username,
"groups": [group.name for group in request.user.ak_groups.all()],
"website" : "foo",
"zoneinfo" : "foo",
"birthdate" : "2000",
"gender" : "foo",
"profile" : "foo",
"middle_name" : "foo",
"locale" : "foo",
"picture" : "foo",
"updated_at" : 1748557810,
"family_name" : "foo",
}
- model: authentik_providers_oauth2.oauth2provider - model: authentik_providers_oauth2.oauth2provider
id: oidc-conformance-1 id: provider
identifiers: identifiers:
name: oidc-conformance-1 name: provider
attrs: attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global issuer_mode: global
client_id: 4054d882aff59755f2f279968b97ce8806a926e1 client_id: 4054d882aff59755f2f279968b97ce8806a926e1
client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867
redirect_uris: redirect_uris: |
- matching_mode: strict https://localhost:8443/test/a/authentik/callback
url: https://localhost:8443/test/a/authentik/callback https://localhost.emobix.co.uk:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings: property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application - model: authentik_core.application
identifiers: identifiers:
slug: oidc-conformance-1 slug: conformance
attrs: attrs:
provider: !KeyOf oidc-conformance-1 provider: !KeyOf provider
name: OIDC Conformance (1) name: Conformance
- model: authentik_providers_oauth2.oauth2provider - model: authentik_providers_oauth2.oauth2provider
id: oidc-conformance-2 id: oidc-conformance-2
@ -96,27 +60,22 @@ entries:
name: oidc-conformance-2 name: oidc-conformance-2
attrs: attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global issuer_mode: global
client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26
client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789
redirect_uris: redirect_uris: |
- matching_mode: strict https://localhost:8443/test/a/authentik/callback
url: https://localhost:8443/test/a/authentik/callback https://localhost.emobix.co.uk:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings: property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application - model: authentik_core.application
identifiers: identifiers:
slug: oidc-conformance-2 slug: oidc-conformance-2
attrs: attrs:
provider: !KeyOf oidc-conformance-2 provider: !KeyOf oidc-conformance-2
name: OIDC Conformance (2) name: OIDC Conformance

View File

@ -0,0 +1,20 @@
{
"alias": "authentik",
"description": "authentik",
"server": {
"discoveryUrl": "http://host.docker.internal:9000/application/o/conformance/.well-known/openid-configuration"
},
"client": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867"
},
"client_secret_post": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867"
},
"client2": {
"client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26",
"client_secret": "ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789"
},
"consent": {}
}

View File

@ -1,156 +0,0 @@
from json import dumps
from os import makedirs
from pathlib import Path
from time import sleep
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from authentik.blueprints.tests import apply_blueprint, reconcile_app
from authentik.providers.oauth2.models import OAuth2Provider
from tests.e2e.utils import SeleniumTestCase
from tests.openid_conformance.conformance import Conformance
class TestOpenIDConformance(SeleniumTestCase):
conformance: Conformance
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
"default/flow-default-provider-invalidation.yaml",
)
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
@apply_blueprint("testing/oidc-conformance.yaml")
def setUp(self):
super().setUp()
makedirs(Path(__file__).parent / "exports", exist_ok=True)
provider_a = OAuth2Provider.objects.get(
client_id="4054d882aff59755f2f279968b97ce8806a926e1"
)
provider_b = OAuth2Provider.objects.get(
client_id="ad64aeaf1efe388ecf4d28fcc537e8de08bcae26"
)
self.test_plan_config = {
"alias": "authentik",
"description": "authentik",
"server": {
"discoveryUrl": self.url(
"authentik_providers_oauth2:provider-info",
application_slug="oidc-conformance-1",
),
},
"client": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": provider_a.client_secret,
},
"client_secret_post": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": provider_a.client_secret,
},
"client2": {
"client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26",
"client_secret": provider_b.client_secret,
},
"consent": {},
}
self.test_variant = {
"server_metadata": "discovery",
"client_registration": "static_client",
}
def run_test(self, test_plan: str, test_plan_config: dict):
# Create a Conformance instance...
self.conformance = Conformance(f"https://{self.host}:8443/", None, verify_ssl=False)
test_plan = self.conformance.create_test_plan(
test_plan,
dumps(test_plan_config),
self.test_variant,
)
plan_id = test_plan["id"]
for test in test_plan["modules"]:
with self.subTest(test["testModule"], **test["variant"]):
# Fetch name and variant of the next test to run
module_name = test["testModule"]
variant = test["variant"]
module_instance = self.conformance.create_test_from_plan_with_variant(
plan_id, module_name, variant
)
module_id = module_instance["id"]
self.run_single_test(module_id)
self.conformance.wait_for_state(module_id, ["FINISHED"], timeout=self.wait_timeout)
sleep(2)
self.conformance.exporthtml(plan_id, Path(__file__).parent / "exports")
def run_single_test(self, module_id: str):
"""Process instructions for a single test, navigate to browser URLs and take screenshots"""
tested_browser_url = 0
uploaded_image = 0
cleared_cookies = False
while True:
# Fetch all info
test_status = self.conformance.get_test_status(module_id)
test_log = self.conformance.get_test_log(module_id)
test_info = self.conformance.get_module_info(module_id)
# Check status early, if we're finished already we don't want to do anything extra
if test_info["status"] in ["INTERRUPTED", "FINISHED"]:
return
# Check if we need to clear cookies - tests only indicates this in their written summary
# so this check is a bit brittle
if "cookies" in test_info["summary"] and not cleared_cookies:
# Navigate to our origin to delete cookies in the right context
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
self.driver.delete_all_cookies()
cleared_cookies = True
# Check if we need deal with any browser URLs
browser_urls = test_status.get("browser", {}).get("urls", [])
if len(browser_urls) > tested_browser_url:
self.do_browser(browser_urls[tested_browser_url])
tested_browser_url += 1
continue
# Check if we need to upload any items
upload_items = [x for x in test_log if "upload" in x]
if len(upload_items) > uploaded_image:
screenshot = self.get_screenshot()
self.conformance.upload_image(
module_id, upload_items[uploaded_image]["upload"], screenshot
)
sleep(3)
uploaded_image += 1
continue
sleep(0.1)
def get_screenshot(self):
"""Get a screenshot, but resize the window first so we don't exceed 500kb"""
self.driver.set_window_size(800, 600)
screenshot = f"data:image/jpeg;base64,{self.driver.get_screenshot_as_base64()}"
self.driver.maximize_window()
return screenshot
def do_browser(self, url):
"""For any specific OpenID Conformance test, execute the operations required"""
self.driver.get(url)
should_expect_completion = False
if "if/flow/default-authentication-flow" in self.driver.current_url:
self.logger.debug("Logging in")
self.login()
should_expect_completion = True
if "prompt=consent" in url or "offline_access" in url:
self.logger.debug("Authorizing")
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor")))
sleep(1)
flow_executor = self.get_shadow_root("ak-flow-executor")
consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor)
consent_stage.find_element(
By.CSS_SELECTOR,
"[type=submit]",
).click()
should_expect_completion = True
if should_expect_completion:
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "#complete")))

View File

@ -1,29 +0,0 @@
services:
mongodb:
image: mongo:6.0.13
httpd:
image: ghcr.io/beryju/oidc-conformance-suite-httpd:v5.1.32
ports:
- "8443:8443"
- "8444:8444"
depends_on:
- server
server:
image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.32
ports:
- "9999:9999"
extra_hosts:
- "host.docker.internal:host-gateway"
command: >
java
-Xdebug -Xrunjdwp:transport=dt_socket,address=*:9999,server=y,suspend=n
-jar /server/fapi-test-suite.jar
-Djdk.tls.maxHandshakeMessageSize=65536
--fintechlabs.base_url=https://host.docker.internal:8443
--fintechlabs.base_mtls_url=https://host.docker.internal:8444
--fintechlabs.devmode=true
--fintechlabs.startredir=true
links:
- mongodb:mongodb
depends_on:
- mongodb

View File

@ -1,192 +0,0 @@
import json
import os
import re
import time
import zipfile
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class Conformance:
HTTP_OK = 200
HTTP_CREATED = 201
def __init__(self, api_url_base, api_token, verify_ssl):
if not api_url_base.endswith("/"):
api_url_base += "/"
self.api_url_base = api_url_base
self.session = requests.Session()
self.session.verify = verify_ssl
retries = Retry(
total=5,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
self.session.mount("https://", HTTPAdapter(max_retries=retries))
self.session.mount("http://", HTTPAdapter(max_retries=retries))
self.session.headers.update({"Content-Type": "application/json"})
if api_token is not None:
self.session.headers.update({"Authorization": f"Bearer {api_token}"})
def get_all_test_modules(self):
url = f"{self.api_url_base}api/runner/available"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_all_test_modules failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def get_test_status(self, module_id):
url = f"{self.api_url_base}api/runner/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_test_status failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def exporthtml(self, plan_id, path):
for _ in range(5):
url = f"{self.api_url_base}api/plan/exporthtml/{plan_id}"
try:
with self.session.get(url, stream=True) as response:
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"exporthtml failed - HTTP {response.status_code} {response.content}"
)
cd = response.headers.get("content-disposition", "")
local_filename = re.findall('filename="(.+)"', cd)[0]
full_path = os.path.join(path, local_filename)
with open(full_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
zip_file = zipfile.ZipFile(full_path)
ret = zip_file.testzip()
if ret is not None:
raise Exception(f"exporthtml returned corrupt zip file: {ret}")
return full_path
except Exception as e:
print(f"requests {url} exception {e} caught - retrying")
time.sleep(1)
raise Exception(f"exporthtml for {plan_id} failed even after retries")
def create_certification_package(
self, plan_id, conformance_pdf_path, rp_logs_zip_path=None, output_zip_directory="./"
):
with (
open(conformance_pdf_path, "rb") as cert_pdf,
open(rp_logs_zip_path, "rb") if rp_logs_zip_path else open(os.devnull, "rb") as rp_logs,
):
files = {
"certificationOfConformancePdf": cert_pdf,
"clientSideData": rp_logs,
}
headers = self.session.headers.copy()
headers.pop("Content-Type", None)
url = f"{self.api_url_base}api/plan/{plan_id}/certificationpackage"
response = self.session.post(url, files=files, headers=headers)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"certificationpackage failed - HTTP {response.status_code} {response.content}"
)
cd = response.headers.get("content-disposition", "")
local_filename = re.findall('filename="(.+)"', cd)[0]
full_path = os.path.join(output_zip_directory, local_filename)
with open(full_path, "wb") as f:
f.write(response.content)
print(f"Certification package zip for plan id {plan_id} written to {full_path}")
def create_test_plan(self, name, configuration, variant=None):
url = f"{self.api_url_base}api/plan"
payload = {"planName": name}
if variant is not None:
payload["variant"] = json.dumps(variant)
response = self.session.post(url, params=payload, data=configuration)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
f"create_test_plan failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def create_test(self, test_name, configuration):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name}
response = self.session.post(url, params=payload, data=configuration)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(f"create_test failed - HTTP {response.status_code} {response.content}")
return response.json()
def create_test_from_plan(self, plan_id, test_name):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name, "plan": plan_id}
response = self.session.post(url, params=payload)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
f"create_test_from_plan failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def create_test_from_plan_with_variant(self, plan_id, test_name, variant):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name, "plan": plan_id}
if variant is not None:
payload["variant"] = json.dumps(variant)
response = self.session.post(url, params=payload)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
"create_test_from_plan_with_variant failed - "
f"HTTP {response.status_code} {response.content}"
)
return response.json()
def get_module_info(self, module_id):
url = f"{self.api_url_base}api/info/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_module_info failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def get_test_log(self, module_id):
url = f"{self.api_url_base}api/log/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"get_test_log failed - HTTP {response.status_code} {response.content}")
return response.json()
def upload_image(self, log_id, placeholder, data):
url = f"{self.api_url_base}api/log/{log_id}/images/{placeholder}"
response = self.session.post(url, data=data, headers={"Content-Type": "text/plain"})
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"upload_image failed - HTTP {response.status_code} {response.content}")
def start_test(self, module_id):
url = f"{self.api_url_base}api/runner/{module_id}"
response = self.session.post(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"start_test failed - HTTP {response.status_code} {response.content}")
return response.json()
def wait_for_state(self, module_id, required_states, timeout=240):
timeout_at = time.time() + timeout
while time.time() < timeout_at:
info = self.get_module_info(module_id)
status = info.get("status")
if status in required_states:
return status
if status == "INTERRUPTED":
raise Exception(f"Test module {module_id} has moved to INTERRUPTED")
time.sleep(1)
raise Exception(
f"Timed out waiting for test module {module_id} "
f"to be in one of states: {required_states}"
)

View File

@ -1,10 +0,0 @@
from tests.e2e.utils import retry
from tests.openid_conformance.base import TestOpenIDConformance
class TestOpenIDConformanceBasic(TestOpenIDConformance):
@retry()
def test_oidcc_basic_certification_test(self):
test_plan_name = "oidcc-basic-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)

View File

@ -1,10 +0,0 @@
from tests.e2e.utils import retry
from tests.openid_conformance.base import TestOpenIDConformance
class TestOpenIDConformanceBasic(TestOpenIDConformance):
@retry()
def test_oidcc_basic_certification_test(self):
test_plan_name = "oidcc-basic-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)

15
uv.lock generated
View File

@ -259,7 +259,6 @@ dev = [
{ name = "pytest-django" }, { name = "pytest-django" },
{ name = "pytest-github-actions-annotate-failures" }, { name = "pytest-github-actions-annotate-failures" },
{ name = "pytest-randomly" }, { name = "pytest-randomly" },
{ name = "pytest-subtests" },
{ name = "pytest-timeout" }, { name = "pytest-timeout" },
{ name = "requests-mock" }, { name = "requests-mock" },
{ name = "ruff" }, { name = "ruff" },
@ -359,7 +358,6 @@ dev = [
{ name = "pytest-django", specifier = "==4.11.1" }, { name = "pytest-django", specifier = "==4.11.1" },
{ name = "pytest-github-actions-annotate-failures", specifier = "==0.3.0" }, { name = "pytest-github-actions-annotate-failures", specifier = "==0.3.0" },
{ name = "pytest-randomly", specifier = "==3.16.0" }, { name = "pytest-randomly", specifier = "==3.16.0" },
{ name = "pytest-subtests", specifier = ">=0.14.1" },
{ name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-timeout", specifier = "==2.4.0" },
{ name = "requests-mock", specifier = "==1.12.1" }, { name = "requests-mock", specifier = "==1.12.1" },
{ name = "ruff", specifier = "==0.11.9" }, { name = "ruff", specifier = "==0.11.9" },
@ -2687,19 +2685,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/70/b31577d7c46d8e2f9baccfed5067dd8475262a2331ffb0bfdf19361c9bde/pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6", size = 8396, upload-time = "2024-10-25T15:45:32.78Z" }, { url = "https://files.pythonhosted.org/packages/22/70/b31577d7c46d8e2f9baccfed5067dd8475262a2331ffb0bfdf19361c9bde/pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6", size = 8396, upload-time = "2024-10-25T15:45:32.78Z" },
] ]
[[package]]
name = "pytest-subtests"
version = "0.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/4c/ba9eab21a2250c2d46c06c0e3cd316850fde9a90da0ac8d0202f074c6817/pytest_subtests-0.14.1.tar.gz", hash = "sha256:350c00adc36c3aff676a66135c81aed9e2182e15f6c3ec8721366918bbbf7580", size = 17632, upload-time = "2024-12-10T00:21:04.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/b7/7ca948d35642ae72500efda6ba6fa61dcb6683feb596d19c4747c63c0789/pytest_subtests-0.14.1-py3-none-any.whl", hash = "sha256:e92a780d98b43118c28a16044ad9b841727bd7cb6a417073b38fd2d7ccdf052d", size = 8833, upload-time = "2024-12-10T00:20:58.873Z" },
]
[[package]] [[package]]
name = "pytest-timeout" name = "pytest-timeout"
version = "2.4.0" version = "2.4.0"

304
web/package-lock.json generated
View File

@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.11", "@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.7.11", "@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@goauthentik/api": "^2025.6.2-1750801939", "@goauthentik/api": "^2025.6.2-1750246811",
"@lit/context": "^1.1.2", "@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2", "@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
@ -34,7 +34,7 @@
"@openlayers-elements/maps": "^0.4.0", "@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0", "@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2", "@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^9.31.0", "@sentry/browser": "^9.30.0",
"@spotlightjs/spotlight": "^3.0.1", "@spotlightjs/spotlight": "^3.0.1",
"@webcomponents/webcomponentsjs": "^2.8.0", "@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
@ -126,7 +126,7 @@
"storybook-addon-mock": "^5.0.0", "storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3", "turnstile-types": "^1.2.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.35.0", "typescript-eslint": "^8.34.1",
"vite-plugin-lit-css": "^2.0.0", "vite-plugin-lit-css": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1", "vite-tsconfig-paths": "^5.0.1",
"wireit": "^0.14.12" "wireit": "^0.14.12"
@ -1731,9 +1731,9 @@
} }
}, },
"node_modules/@goauthentik/api": { "node_modules/@goauthentik/api": {
"version": "2025.6.2-1750801939", "version": "2025.6.2-1750246811",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750801939.tgz", "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750246811.tgz",
"integrity": "sha512-3s0pE6enhLEWVMD+zClORktBhUAw1vO/lCG0ATqm6xqbTfqGxPYWj5XMzYuX7+a2axxn1BFE134afWmdzDhThw==" "integrity": "sha512-ENHEi3kGAodf5tKQb5kziUrT1EcJw3z8tp2mU7LWqNlXr4eoAI15BjDfH5DW56l4jy3xKqTd+R2Ntnj4hiVhHw=="
}, },
"node_modules/@goauthentik/core": { "node_modules/@goauthentik/core": {
"resolved": "packages/core", "resolved": "packages/core",
@ -4561,75 +4561,75 @@
"dev": true "dev": true
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.30.0.tgz",
"integrity": "sha512-rviu/jUmeQbY4rSO8l4pubOtRIhFtH5Gu/ryRNMTlpJRdomp4uxddqthHUDH5g6xCXZsMTyJEIdx0aTqbgr/GQ==", "integrity": "sha512-e6ZlN8oWheCB0YJSGlBNUlh6UPnY5Ecj1P+/cgeKBhNm7c3bIx4J50485hB8LQsu+b7Q11L2o/wucZ//Pb6FCg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.31.0" "@sentry/core": "9.30.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.30.0.tgz",
"integrity": "sha512-Ygi/8UZ7p2B4DhXQjZDtOc45vNUHkfk2XETBTBGkByEQkE8vygzSiKhgRcnVpzwq+8xKFMRy+PxvpcCo+PNQew==", "integrity": "sha512-qAZ7xxLqZM7GlEvmSUmTHnoueg+fc7esMQD4vH8pS7HI3n9C5MjGn3HHlndRpD8lL7iUUQ0TPZQgU6McbzMDyw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.31.0" "@sentry/core": "9.30.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.30.0.tgz",
"integrity": "sha512-V5rvcO/xSj8JMw4ZnZT2cBYC+UOuIiZ2Flj4EoIurxMrTgowE1uMXUBA32EBfuB5/vQSJXB6W5uAudhk7LjBPQ==", "integrity": "sha512-+6wkqQGLJuFUzvGRzbh3iIhFGyxQx/Oxc0ODDKmz9ag2xYRjCYb3UUQXmQX9navAF0HXUsq8ajoJPm2L1ZyWVg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.31.0", "@sentry-internal/browser-utils": "9.30.0",
"@sentry/core": "9.31.0" "@sentry/core": "9.30.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.30.0.tgz",
"integrity": "sha512-VGqfvQCIuXQZeecrBf8bd4sj8lYGzUA/2CffTAkad1nB1Onyz0Kzo54qLWemivCxA3ufHf6DCpNA3Loa/0ywFQ==", "integrity": "sha512-I4MxS27rfV7vnOU29L80y4baZ4I1XqpnYvC/yLN7C17nA8eDCufQ8WVomli41y8JETnfcxlm68z7CS0sO4RCSA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.31.0", "@sentry-internal/replay": "9.30.0",
"@sentry/core": "9.31.0" "@sentry/core": "9.30.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.30.0.tgz",
"integrity": "sha512-DzG72JJTqHzE0Qo2fHeHm3xgFs97InaSQStmTMxOA59yPqvAXbweNPcsgCNu1q76+jZyaJcoy1qOwahnLuEVDg==", "integrity": "sha512-sRyW6A9nIieTTI26MYXk1DmWEhmphTjZevusNWla+vvUigCmSjuH+xZw19w43OyvF3bu261Skypnm/mAalOTwg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.31.0", "@sentry-internal/browser-utils": "9.30.0",
"@sentry-internal/feedback": "9.31.0", "@sentry-internal/feedback": "9.30.0",
"@sentry-internal/replay": "9.31.0", "@sentry-internal/replay": "9.30.0",
"@sentry-internal/replay-canvas": "9.31.0", "@sentry-internal/replay-canvas": "9.30.0",
"@sentry/core": "9.31.0" "@sentry/core": "9.30.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.31.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.31.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.30.0.tgz",
"integrity": "sha512-6JeoPGvBgT9m2YFIf2CrW+KrrOYzUqb9+Xwr/Dw25kPjVKy+WJjWqK8DKCNLgkBA22OCmSOmHuRwFR0YxGVdZQ==", "integrity": "sha512-JfEpeQ8a1qVJEb9DxpFTFy1J1gkNdlgKAPiqYGNnm4yQbnfl2Kb/iEo1if70FkiHc52H8fJwISEF90pzMm6lPg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7415,17 +7415,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
"integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "integrity": "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/type-utils": "8.35.0", "@typescript-eslint/type-utils": "8.34.1",
"@typescript-eslint/utils": "8.35.0", "@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -7439,7 +7439,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.35.0", "@typescript-eslint/parser": "^8.34.1",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <5.9.0"
} }
@ -7455,16 +7455,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.1.tgz",
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -7480,14 +7480,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.1.tgz",
"integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "integrity": "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.35.0", "@typescript-eslint/tsconfig-utils": "^8.34.1",
"@typescript-eslint/types": "^8.35.0", "@typescript-eslint/types": "^8.34.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -7502,14 +7502,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.1.tgz",
"integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "integrity": "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0" "@typescript-eslint/visitor-keys": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7520,9 +7520,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.1.tgz",
"integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "integrity": "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7537,14 +7537,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.1.tgz",
"integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "integrity": "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/utils": "8.35.0", "@typescript-eslint/utils": "8.34.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@ -7561,9 +7561,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz",
"integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7575,16 +7575,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.1.tgz",
"integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "integrity": "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.35.0", "@typescript-eslint/project-service": "8.34.1",
"@typescript-eslint/tsconfig-utils": "8.35.0", "@typescript-eslint/tsconfig-utils": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.35.0", "@typescript-eslint/visitor-keys": "8.34.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -7604,16 +7604,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.1.tgz",
"integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "integrity": "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0" "@typescript-eslint/typescript-estree": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7628,13 +7628,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.1.tgz",
"integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "integrity": "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.34.1",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -10380,20 +10380,18 @@
} }
}, },
"node_modules/array-includes": { "node_modules/array-includes": {
"version": "3.1.9", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.7",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1", "define-properties": "^1.2.1",
"es-abstract": "^1.24.0", "es-abstract": "^1.23.2",
"es-object-atoms": "^1.1.1", "es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.3.0", "get-intrinsic": "^1.2.4",
"is-string": "^1.1.1", "is-string": "^1.0.7"
"math-intrinsics": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -13644,9 +13642,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.0", "version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
"integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -13654,18 +13652,18 @@
"arraybuffer.prototype.slice": "^1.0.4", "arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7", "available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8", "call-bind": "^1.0.8",
"call-bound": "^1.0.4", "call-bound": "^1.0.3",
"data-view-buffer": "^1.0.2", "data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2", "data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1", "data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1", "es-object-atoms": "^1.0.0",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0", "es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8", "function.prototype.name": "^1.1.8",
"get-intrinsic": "^1.3.0", "get-intrinsic": "^1.2.7",
"get-proto": "^1.0.1", "get-proto": "^1.0.0",
"get-symbol-description": "^1.1.0", "get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4", "globalthis": "^1.0.4",
"gopd": "^1.2.0", "gopd": "^1.2.0",
@ -13677,24 +13675,21 @@
"is-array-buffer": "^3.0.5", "is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-data-view": "^1.0.2", "is-data-view": "^1.0.2",
"is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1", "is-regex": "^1.2.1",
"is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4", "is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1", "is-string": "^1.1.1",
"is-typed-array": "^1.1.15", "is-typed-array": "^1.1.15",
"is-weakref": "^1.1.1", "is-weakref": "^1.1.0",
"math-intrinsics": "^1.1.0", "math-intrinsics": "^1.1.0",
"object-inspect": "^1.13.4", "object-inspect": "^1.13.3",
"object-keys": "^1.1.1", "object-keys": "^1.1.1",
"object.assign": "^4.1.7", "object.assign": "^4.1.7",
"own-keys": "^1.0.1", "own-keys": "^1.0.1",
"regexp.prototype.flags": "^1.5.4", "regexp.prototype.flags": "^1.5.3",
"safe-array-concat": "^1.1.3", "safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0", "safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0", "safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0", "set-proto": "^1.0.0",
"stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10", "string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9", "string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8", "string.prototype.trimstart": "^1.0.8",
@ -13703,7 +13698,7 @@
"typed-array-byte-offset": "^1.0.4", "typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7", "typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0", "unbox-primitive": "^1.1.0",
"which-typed-array": "^1.1.19" "which-typed-array": "^1.1.18"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -14628,9 +14623,9 @@
} }
}, },
"node_modules/eslint-module-utils": { "node_modules/eslint-module-utils": {
"version": "2.12.1", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -14656,30 +14651,30 @@
} }
}, },
"node_modules/eslint-plugin-import": { "node_modules/eslint-plugin-import": {
"version": "2.32.0", "version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.8",
"array.prototype.findlastindex": "^1.2.6", "array.prototype.findlastindex": "^1.2.5",
"array.prototype.flat": "^1.3.3", "array.prototype.flat": "^1.3.2",
"array.prototype.flatmap": "^1.3.3", "array.prototype.flatmap": "^1.3.2",
"debug": "^3.2.7", "debug": "^3.2.7",
"doctrine": "^2.1.0", "doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9", "eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.12.1", "eslint-module-utils": "^2.12.0",
"hasown": "^2.0.2", "hasown": "^2.0.2",
"is-core-module": "^2.16.1", "is-core-module": "^2.15.1",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"object.fromentries": "^2.0.8", "object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3", "object.groupby": "^1.0.3",
"object.values": "^1.2.1", "object.values": "^1.2.0",
"semver": "^6.3.1", "semver": "^6.3.1",
"string.prototype.trimend": "^1.0.9", "string.prototype.trimend": "^1.0.8",
"tsconfig-paths": "^3.15.0" "tsconfig-paths": "^3.15.0"
}, },
"engines": { "engines": {
@ -17387,10 +17382,9 @@
} }
}, },
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.15.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"hasown": "^2.0.2" "hasown": "^2.0.2"
}, },
@ -17569,19 +17563,6 @@
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true "dev": true
}, },
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
"integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -24040,17 +24021,14 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
}, },
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.7",
"define-properties": "^1.2.1", "define-properties": "^1.2.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2" "set-function-name": "^2.0.2"
}, },
"engines": { "engines": {
@ -25552,20 +25530,6 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"internal-slot": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/storybook": { "node_modules/storybook": {
"version": "8.6.14", "version": "8.6.14",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz",
@ -27217,15 +27181,15 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.35.0", "version": "8.34.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.1.tgz",
"integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "integrity": "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/parser": "8.35.0", "@typescript-eslint/parser": "8.34.1",
"@typescript-eslint/utils": "8.35.0" "@typescript-eslint/utils": "8.34.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -93,7 +93,7 @@
"@floating-ui/dom": "^1.6.11", "@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.7.11", "@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@goauthentik/api": "^2025.6.2-1750801939", "@goauthentik/api": "^2025.6.2-1750246811",
"@lit/context": "^1.1.2", "@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2", "@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
@ -105,7 +105,7 @@
"@openlayers-elements/maps": "^0.4.0", "@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0", "@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2", "@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^9.31.0", "@sentry/browser": "^9.30.0",
"@spotlightjs/spotlight": "^3.0.1", "@spotlightjs/spotlight": "^3.0.1",
"@webcomponents/webcomponentsjs": "^2.8.0", "@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
@ -197,7 +197,7 @@
"storybook-addon-mock": "^5.0.0", "storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3", "turnstile-types": "^1.2.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.35.0", "typescript-eslint": "^8.34.1",
"vite-plugin-lit-css": "^2.0.0", "vite-plugin-lit-css": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1", "vite-tsconfig-paths": "^5.0.1",
"wireit": "^0.14.12" "wireit": "^0.14.12"

View File

@ -64,7 +64,7 @@ export const EntryPoint = /** @type {const} */ ({
in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"), in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"),
out: resolve(DistDirectory, "flow", "FlowInterface"), out: resolve(DistDirectory, "flow", "FlowInterface"),
}, },
StandaloneAPI: { Standalone: {
in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"), in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"),
out: resolve(DistDirectory, "standalone", "api-browser", "index"), out: resolve(DistDirectory, "standalone", "api-browser", "index"),
}, },

View File

@ -64,7 +64,7 @@ export class AdminOverviewPage extends AdminOverviewBase {
} }
quickActions: QuickAction[] = [ quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createWizard: true })], [msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")], [msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true], [msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")], [msg("Manage users"), paramURL("/identity/users")],

View File

@ -89,7 +89,7 @@ export class RecentEventsCard extends Table<Event> {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span>${msg("No Events found.")}</span> ><span slot="header">${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -112,7 +112,7 @@ export class ApplicationViewPage extends AKElement {
renderApp(): TemplateResult { renderApp(): TemplateResult {
if (!this.application) { if (!this.application) {
return html`<ak-empty-state default-label></ak-empty-state>`; return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`;
} }
return html`<ak-tabs> return html`<ak-tabs>
${this.missingOutpost ${this.missingOutpost

View File

@ -118,12 +118,13 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module" html`<ak-empty-state
><span>${msg("No app entitlements created.")}</span> header=${msg("No app entitlements created.")}
icon="pf-icon-module"
>
<div slot="body"> <div slot="body">
${msg( ${msg(
"This application does currently not have any application entitlements defined.", "This application does currently not have any application entitlement defined.",
)} )}
</div> </div>
<div slot="primary"></div> <div slot="primary"></div>

View File

@ -116,7 +116,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
.content=${[]} .content=${[]}
></ak-select-table> ></ak-select-table>
<ak-empty-state icon="pf-icon-module" <ak-empty-state icon="pf-icon-module"
><span>${msg("No bound policies.")}</span> ><span slot="header">${msg("No bound policies.")} </span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div> <div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary"> <div slot="primary">
<button <button

View File

@ -83,7 +83,7 @@ export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(Appl
}} }}
></ak-wizard-page-type-create> ></ak-wizard-page-type-create>
</form>` </form>`
: html`<ak-empty-state default-label></ak-empty-state>`; : html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`;
} }
} }

View File

@ -109,8 +109,10 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
return super.renderEmpty(html` return super.renderEmpty(html`
${inner ${inner
? inner ? inner
: html`<ak-empty-state icon=${this.pageIcon()} : html`<ak-empty-state
><span>${msg("No licenses found.")}</span> icon=${this.pageIcon()}
header="${msg("No licenses found.")}"
>
<div slot="body"> <div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``} ${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div> </div>

View File

@ -136,7 +136,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module"> html`<ak-empty-state icon="pf-icon-module">
<span>${msg("No Stages bound")}</span> <span slot="header">${msg("No Stages bound")}</span>
<div slot="body">${msg("No stages are currently bound to this flow.")}</div> <div slot="body">${msg("No stages are currently bound to this flow.")}</div>
<div slot="primary"> <div slot="primary">
<ak-stage-wizard <ak-stage-wizard

View File

@ -199,7 +199,7 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module" html`<ak-empty-state icon="pf-icon-module"
><span>${msg("No Policies bound.")}</span> ><span slot="header">${msg("No Policies bound.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div> <div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary"> <div slot="primary">
<ak-policy-wizard <ak-policy-wizard

View File

@ -42,7 +42,7 @@ export class ProviderViewPage extends AKElement {
renderProvider(): TemplateResult { renderProvider(): TemplateResult {
if (!this.provider) { if (!this.provider) {
return html`<ak-empty-state loading full-height></ak-empty-state>`; return html`<ak-empty-state loading fullHeight></ak-empty-state>`;
} }
switch (this.provider?.component) { switch (this.provider?.component) {
case "ak-provider-saml-form": case "ak-provider-saml-form":

View File

@ -34,7 +34,7 @@ export class SourceViewPage extends AKElement {
renderSource(): TemplateResult { renderSource(): TemplateResult {
if (!this.source) { if (!this.source) {
return html`<ak-empty-state loading full-height></ak-empty-state>`; return html`<ak-empty-state loading fullHeight></ak-empty-state>`;
} }
switch (this.source?.component) { switch (this.source?.component) {
case "ak-source-kerberos-form": case "ak-source-kerberos-form":

View File

@ -2,7 +2,6 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils"; import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import { DataProvision } from "@goauthentik/elements/ak-dual-select/types"; import { DataProvision } from "@goauthentik/elements/ak-dual-select/types";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -166,15 +165,6 @@ export class AuthenticatorWebAuthnStageForm extends BaseStageForm<AuthenticatorW
> >
</ak-radio> </ak-radio>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-number-input
label=${msg("Maximum registration attempts")}
required
name="maxAttempts"
value="${this.instance?.maxAttempts || 0}"
help=${msg(
"Maximum allowed registration attempts. When set to 0 attempts, attempts are not limited.",
)}
></ak-number-input>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Device type restrictions")} label=${msg("Device type restrictions")}
name="deviceTypeRestrictions" name="deviceTypeRestrictions"

View File

@ -7,7 +7,7 @@ import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { AuthenticatorsApi, Device } from "@goauthentik/api"; import { AuthenticatorsApi, Device } from "@goauthentik/api";
@ -104,11 +104,8 @@ export class UserDeviceTable extends Table<Device> {
row(item: Device): TemplateResult[] { row(item: Device): TemplateResult[] {
return [ return [
html`${item.name}`, html`${item.name}`,
html`<div> html`${deviceTypeName(item)}
${deviceTypeName(item)} ${item.extraDescription ? ` - ${item.extraDescription}` : ""}`,
${item.extraDescription ? ` - ${item.extraDescription}` : ""}
</div>
${item.externalId ? html` <small>${item.externalId}</small> ` : nothing} `,
html`${item.confirmed ? msg("Yes") : msg("No")}`, html`${item.confirmed ? msg("Yes") : msg("No")}`,
html`${item.created.getTime() > 0 html`${item.created.getTime() > 0
? html`<div>${formatElapsedTime(item.created)}</div> ? html`<div>${formatElapsedTime(item.created)}</div>

View File

@ -133,7 +133,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> { async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({ const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
pathStartswith: this.activePath, pathStartswith: getURLParam("path", ""),
isActive: this.hideDeactivated ? true : undefined, isActive: this.hideDeactivated ? true : undefined,
includeGroups: false, includeGroups: false,
}); });

View File

@ -94,7 +94,7 @@ export class ObjectChangelog extends Table<Event> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span>${msg("No Events found.")}</span> ><span slot="header">${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -67,7 +67,7 @@ export class UserEvents extends Table<Event> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span>${msg("No Events found.")}</span> ><span slot="header">${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -148,31 +148,5 @@ export class AKElement extends LitElement implements AKElementProps {
return this.#styleRoot; return this.#styleRoot;
} }
protected hasSlotted(name: string | null) {
const isNotNestedSlot = (start: Element) => {
let node = start.parentNode;
while (node && node !== this) {
if (node instanceof Element && node.hasAttribute("slot")) {
return false;
}
node = node.parentNode;
}
return true;
};
// All child slots accessible from the component's LightDOM that match the request
const allChildSlotRequests =
typeof name === "string"
? [...this.querySelectorAll(`[slot="${name}"]`)]
: [...this.children].filter((child) => {
const slotAttr = child.getAttribute("slot");
return !slotAttr || slotAttr === "";
});
// All child slots accessible from the LightDom that match the request *and* are not nested
// within another slotted element.
return allChildSlotRequests.filter((node) => isNotNestedSlot(node)).length > 0;
}
//#endregion //#endregion
} }

View File

@ -3,63 +3,38 @@ import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Spinner";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types"; import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
import { SlotController } from "@patternfly/pfe-core/controllers/slot-controller.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { css, html, nothing, render } from "lit"; import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* Props for the EmptyState component
*/
export interface IEmptyState { export interface IEmptyState {
/** Font Awesome icon class (e.g., "fa-user", "fa-folder") to display */
icon?: string; icon?: string;
/** When true, will automatically show the loading spinner. Overrides `icon`. */
loading?: boolean; loading?: boolean;
/**
* When true, will automatically fill the header with the "Loading" message and show the loading
* spinner. Overrides 'loading'.
*/
defaultLabel?: boolean;
/** Whether the empty state should take up the full height of its container */
fullHeight?: boolean; fullHeight?: boolean;
header?: string;
} }
/**
* @element ak-empty-state
* @class EmptyState
*
* A component for displaying empty states with optional icons, headings, body text, and actions.
* Follows PatternFly design patterns for empty state presentations.
*
* ## Slots
*
* @slot - The main heading text for the empty state
* @slot body - Descriptive text explaining the empty state or what the user can do
* @slot primary - Primary action buttons or other interactive elements
*
*/
@customElement("ak-empty-state") @customElement("ak-empty-state")
export class EmptyState extends AKElement implements IEmptyState { export class EmptyState extends AKElement implements IEmptyState {
@property({ type: String }) @property({ type: String })
public icon = ""; icon = "";
@property({ type: Boolean, reflect: true }) @property({ type: Boolean })
public loading = false; loading = false;
@property({ type: Boolean, reflect: true, attribute: "default-label" }) @property({ type: Boolean })
public defaultLabel = false; fullHeight = false;
@property({ type: Boolean, attribute: "full-height" }) @property()
public fullHeight = false; header?: string;
slots = new SlotController(this, "header", "body", "primary");
static get styles() { static get styles() {
return [ return [
@ -75,49 +50,32 @@ export class EmptyState extends AKElement implements IEmptyState {
]; ];
} }
willUpdate() {
if (this.defaultLabel && this.querySelector("span:not([slot])") === null) {
render(html`<span>${msg("Loading")}</span>`, this);
}
}
get localAriaLabel() {
const result = this.querySelector("span:not([slot])");
return result instanceof HTMLElement ? result.innerText || undefined : undefined;
}
render() { render() {
const hasHeading = this.hasSlotted(null); const showHeader = this.loading || this.slots.hasSlotted("header");
const loading = this.loading || this.defaultLabel; const header = () =>
const classes = { this.slots.hasSlotted("header")
"pf-c-empty-state": true, ? html`<slot name="header"></slot>`
"pf-m-full-height": this.fullHeight, : html`<span>${msg("Loading")}</span>`;
};
return html`<div aria-label=${this.localAriaLabel ?? nothing} class="${classMap(classes)}"> return html`<div class="pf-c-empty-state ${this.fullHeight && "pf-m-full-height"}">
<div class="pf-c-empty-state__content" role="progressbar"> <div class="pf-c-empty-state__content">
${loading ${this.loading
? html`<div part="spinner" class="pf-c-empty-state__icon"> ? html`<div class="pf-c-empty-state__icon">
<ak-spinner size=${PFSize.XLarge}></ak-spinner> <ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>` </div>`
: html`<i : html`<i
part="icon"
class="pf-icon fa ${this.icon || class="pf-icon fa ${this.icon ||
"fa-question-circle"} pf-c-empty-state__icon" "fa-question-circle"} pf-c-empty-state__icon"
aria-hidden="true" aria-hidden="true"
></i>`} ></i>`}
${hasHeading ${showHeader ? html` <h1 class="pf-c-title pf-m-lg">${header()}</h1>` : nothing}
? html` <h1 part="heading" class="pf-c-title pf-m-lg" id="empty-state-heading"> ${this.slots.hasSlotted("body")
<slot></slot> ? html` <div class="pf-c-empty-state__body">
</h1>`
: nothing}
${this.hasSlotted("body")
? html` <div part="body" class="pf-c-empty-state__body">
<slot name="body"></slot> <slot name="body"></slot>
</div>` </div>`
: nothing} : nothing}
${this.hasSlotted("primary") ${this.slots.hasSlotted("primary")
? html` <div part="primary" class="pf-c-empty-state__primary"> ? html` <div class="pf-c-empty-state__primary">
<slot name="primary"></slot> <slot name="primary"></slot>
</div>` </div>`
: nothing} : nothing}
@ -126,37 +84,10 @@ export class EmptyState extends AKElement implements IEmptyState {
} }
} }
interface IEmptyStateContent { export function akEmptyState(properties: IEmptyState, content: SlottedTemplateResult = nothing) {
heading?: SlottedTemplateResult; const message =
body?: SlottedTemplateResult; typeof content === "string" ? html`<span slot="body">${content}</span>` : content;
primary?: SlottedTemplateResult; return html`<ak-empty-state ${spread(properties as Spread)}>${message}</ak-empty-state>`;
}
type ContentKey = keyof IEmptyStateContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Generate `<ak-empty-state>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-empty-state>`
* @returns TemplateResult for the ak-empty-state element
*
*/
export function akEmptyState(properties: IEmptyState = {}, content: IEmptyStateContent = {}) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.
const stringToSlot = (name: string, c: ContentValue) =>
name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-empty-state ${spread(properties as Spread)}>${items}</ak-empty-state>`;
} }
declare global { declare global {

View File

@ -5,59 +5,30 @@ import { spread } from "@open-wc/lit-helpers";
import { css, html, nothing } from "lit"; import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface ILoadingOverlay { export interface ILoadingOverlay {
/**
* Whether this overlay should appear above all other overlays (z-index: 999)
*/
topmost?: boolean; topmost?: boolean;
/**
* Whether to show the loading spinner animation
*/
noSpinner?: boolean;
/**
* Icon name to display instead of the default loading spinner
*/
icon?: string;
} }
/**
* @element ak-loading-overlay
* @class LoadingOverlay
*
* A component for for showing a loading message above a darkening background, in order
* to pause interaction while dynamically importing a major component.
*
* ## Slots
*
* @slot - The main heading text for the loading state
* @slot body - Descriptive text explaining the loading state
*
*/
@customElement("ak-loading-overlay") @customElement("ak-loading-overlay")
export class LoadingOverlay extends AKElement implements ILoadingOverlay { export class LoadingOverlay extends AKElement implements ILoadingOverlay {
// Do not camelize: https://www.merriam-webster.com/dictionary/topmost // Do not camelize: https://www.merriam-webster.com/dictionary/topmost
@property({ type: Boolean, attribute: "topmost" }) @property({ type: Boolean, attribute: "topmost" })
topmost = false; topmost = false;
@property({ type: Boolean, attribute: "no-spinner" }) @property({ type: Boolean })
noSpinner = false; loading = true;
@property({ type: String }) @property({ type: String })
icon?: string; icon = "";
static get styles() { static get styles() {
return [ return [
PFBase, PFBase,
css` css`
:host { :host {
top: 0;
left: 0;
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -75,49 +46,20 @@ export class LoadingOverlay extends AKElement implements ILoadingOverlay {
} }
render() { render() {
// Nested slots. Can get a little cognitively heavy, so be careful if you're editing here... return html`<ak-empty-state ?loading=${this.loading} header="" icon=${this.icon}>
return html`<ak-empty-state ?loading=${!this.noSpinner} icon=${ifDefined(this.icon)}> <span slot="body"><slot></slot></span>
${this.hasSlotted(null) ? html`<span><slot></slot></span>` : nothing}
${this.hasSlotted("body")
? html`<span slot="body"><slot name="body"></slot></span>`
: nothing}
</ak-empty-state>`; </ak-empty-state>`;
} }
} }
interface ILoadingOverlayContent {
heading?: SlottedTemplateResult;
body?: SlottedTemplateResult;
}
type ContentKey = keyof ILoadingOverlayContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Function to create `<ak-loading-overlay>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-loading-overlay>`
* @returns TemplateResult for the ak-loading-overlay element
*
*/
export function akLoadingOverlay( export function akLoadingOverlay(
properties: ILoadingOverlay = {}, properties: ILoadingOverlay,
content: ILoadingOverlayContent = {}, content: SlottedTemplateResult = nothing,
) { ) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete const message = typeof content === "string" ? html`<span>${content}</span>` : content;
// slot-name. return html`<ak-loading-overlay ${spread(properties as Spread)}
const stringToSlot = (name: string, c: ContentValue) => >${message}</ak-loading-overlay
name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`; >`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-loading-overlay ${spread(properties as Spread)}>${items}</ak-loading-overlay>`;
} }
declare global { declare global {

View File

@ -32,8 +32,8 @@ import {
} from "./types.js"; } from "./types.js";
function localeComparator(a: DualSelectPair, b: DualSelectPair) { function localeComparator(a: DualSelectPair, b: DualSelectPair) {
const aSortBy = String(a[2] || a[0]); const aSortBy = a[2] || a[0];
const bSortBy = String(b[2] || b[0]); const bSortBy = b[2] || b[0];
return aSortBy.localeCompare(bSortBy); return aSortBy.localeCompare(bSortBy);
} }

View File

@ -201,7 +201,7 @@ export abstract class AKChart<T> extends AKElement {
${this.error ${this.error
? html` ? html`
<ak-empty-state icon="fa-times" <ak-empty-state icon="fa-times"
><span>${msg("Failed to fetch data.")}</span> ><span slot="header">${msg("Failed to fetch data.")}</span>
<p slot="body">${pluckErrorDetail(this.error)}</p> <p slot="body">${pluckErrorDetail(this.error)}</p>
</ak-empty-state> </ak-empty-state>
` `

View File

@ -40,7 +40,9 @@ export class LogViewer extends Table<LogEvent> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state><span>${msg("No log messages.")}</span> </ak-empty-state>`, html`<ak-empty-state
><span slot="header">${msg("No log messages.")}</span>
</ak-empty-state>`,
); );
} }

View File

@ -164,7 +164,7 @@ export class NotificationDrawer extends AKElement {
renderEmpty() { renderEmpty() {
return html`<ak-empty-state return html`<ak-empty-state
><span>${msg("No notifications found.")}</span> ><span slot="header">${msg("No notifications found.")}</span>
<div slot="body">${msg("You don't have any notifications currently.")}</div> <div slot="body">${msg("You don't have any notifications currently.")}</div>
</ak-empty-state>`; </ak-empty-state>`;
} }

View File

@ -0,0 +1,59 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as EmptyStateStories from "./EmptyState.stories";
<Meta of={EmptyStateStories} />
# EmptyState
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has two named slots, `body` and `primary`, to communicate further details about the current state
this element is meant to display.
## Usage
```Typescript
import "@goauthentik/elements/EmptyState.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-empty-state icon="fa-eject"
><span slot="primary">This would display in the "primary" slot</span></ak-empty-state
>
```
## Demo
### Default: Loading
The default state is _loading_
<Story of={EmptyStateStories.DefaultStory} />
### Done
<Story of={EmptyStateStories.DefaultAndLoadingDone} />
### Alternative "Done" Icon
This also shows the "header" attribute filled, which is rendered in a large, dark typeface.
<Story of={EmptyStateStories.DoneWithAlternativeIcon} />
### The Body Slot Filled
The body content slot is rendered in a lighter typeface at default size.
<Story of={EmptyStateStories.WithBodySlotFilled} />
### The Body and Primary Slot Filled
The primary content is rendered in the normal dark typeface at default size. It is also spaced
significantly below the spinner itself.
<Story of={EmptyStateStories.WithBodyAndPrimarySlotsFilled} />

View File

@ -1,254 +1,108 @@
import type { Meta, StoryObj } from "@storybook/web-components"; import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { EmptyState, type IEmptyState } from "../EmptyState.js";
import "../EmptyState.js"; import "../EmptyState.js";
import { type EmptyState, type IEmptyState, akEmptyState } from "../EmptyState.js";
type StoryArgs = IEmptyState & { const metadata: Meta<EmptyState> = {
headingText?: string | TemplateResult; title: "Elements/<ak-empty-state>",
bodyText?: string | TemplateResult;
primaryButtonText?: string | TemplateResult;
};
const metadata: Meta<StoryArgs> = {
title: "Elements / <ak-empty-state>",
component: "ak-empty-state", component: "ak-empty-state",
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: "Our empty state spinner",
component: `
# Empty State Component
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has three named slots:
- The default slot: The heading (renders larger and more bold)
- **body**: Any text to describe the state
- **primary**: Action buttons or other interactive elements
For the loading attributes:
- The attribute \`loading\` will show the spinner
- The attribute \`default\` will show the spinner and the default header of "Loading"
If either of these attributes is active and the element contains content not assigned to one of the
named slots, it will be shown in the header. This overrides the default text of \`default\`. You
do not need both attributes for \`default\` to work; it assumes loading.
`,
},
}, },
layout: "padded",
}, },
argTypes: { argTypes: {
icon: { icon: { control: "text" },
control: "text", loading: { control: "boolean" },
description: "Font Awesome icon class (without 'fa-' prefix)", fullHeight: { control: "boolean" },
}, header: { control: "text" },
loading: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
defaultLabel: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
fullHeight: {
control: "boolean",
description: "Fill the full height of container",
},
headingText: {
control: "text",
description: "Text for heading slot (for demo purposes)",
},
bodyText: {
control: "text",
description: "Text for body slot (for demo purposes)",
},
primaryButtonText: {
control: "text",
description: "Text for primary button (for demo purposes)",
},
}, },
}; };
export default metadata; export default metadata;
type Story = StoryObj<StoryArgs>; const container = (content: TemplateResult) =>
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
const Template: Story = { export const DefaultStory: StoryObj = {
args: { args: {
icon: "fa-circle-radiation", icon: undefined,
loading: false, loading: true,
defaultLabel: false,
fullHeight: false, fullHeight: false,
header: undefined,
}, },
render: (args) => html`
<ak-empty-state
icon=${ifDefined(args.icon)}
?loading=${args.loading}
?default=${args.defaultLabel}
?full-height=${args.fullHeight}
>
${args.headingText ? html`<span>${args.headingText}</span>` : nothing}
${args.bodyText ? html`<span slot="body">${args.bodyText}</span>` : nothing}
${args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: nothing}
</ak-empty-state>
`,
};
export const Basic: Story = { render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
...Template, container(
args: { html` <ak-empty-state
icon: "fa-folder-open", ?loading=${loading}
headingText: "No files found", ?fullHeight=${fullHeight}
bodyText: "This folder is empty. Upload some files to get started.", icon=${ifDefined(icon)}
}, header=${ifDefined(header)}
}; >
</ak-empty-state>`,
export const Empty: Story = {
...Template,
args: {
icon: "",
},
render: () =>
html`<p>Note that a completely empty &lt;ak-empty-state&gt; is just that: empty.</p>
<ak-empty-state></ak-empty-state>`,
};
export const WithAction: Story = {
...Template,
args: {
icon: "fa-users",
headingText: "No users yet",
bodyText: "Get started by creating your first user account.",
primaryButtonText: html`<button>Create User</button>`,
},
};
export const Loading: Story = {
...Template,
args: {
loading: true,
},
};
export const LoadingWithCustomMessage: Story = {
...Template,
args: {
loading: true,
headingText: html`<span>I <em>know</em> it's here, somewhere...</span>`,
},
};
export const LoadingWithDefaultMessage: Story = {
...Template,
args: {
defaultLabel: true,
},
};
export const LoadingDefaultWithOverride: Story = {
...Template,
args: {
defaultLabel: true,
headingText: html`<span>Have they got a chance? Eh. It would take a miracle.</span>`,
},
};
export const LoadingDefaultWithButton: Story = {
...Template,
args: {
defaultLabel: true,
primaryButtonText: html`<button>Cancel</button>`,
},
};
export const FullHeight: Story = {
...Template,
args: {
icon: "fa-search",
headingText: "No search results",
bodyText: "Try adjusting your search criteria or browse our categories.",
fullHeight: true,
primaryButtonText: html`<button>Go back</button>`,
},
};
export const ProgrammaticUsage: Story = {
...Template,
args: {
icon: "fa-beer",
headingText: "Hold My Beer",
bodyText: "I saw this in a cartoon once. I'm sure I can pull it off.",
primaryButtonText: html`<button>Leave The Scene Immediately</button>`,
},
render: (args) =>
akEmptyState(
{
icon: args.icon,
},
{
heading: args.headingText,
body: args.bodyText,
primary: args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: undefined,
},
), ),
}; };
export const IconShowcase: Story = { export const DefaultAndLoadingDone = {
args: {}, ...DefaultStory,
render: () => html` args: { ...DefaultStory, ...{ loading: false } },
<div };
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;"
> export const DoneWithAlternativeIcon = {
<ak-empty-state icon="fa-users"> ...DefaultStory,
<span>Users</span> args: {
<span slot="body">No users found</span> ...DefaultStory,
</ak-empty-state> ...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
<ak-empty-state icon="fa-database"> };
<span>Database</span>
<span slot="body">No records</span> export const WithBodySlotFilled = {
</ak-empty-state> ...DefaultStory,
args: {
<ak-empty-state icon="fa-envelope"> ...DefaultStory,
<span>Messages</span> ...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
<span slot="body">No messages</span> },
</ak-empty-state> render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(html`
<ak-empty-state icon="fa-chart-bar"> <ak-empty-state
<span>Analytics</span> ?loading=${loading}
<span slot="body">No data to display</span> ?fullHeight=${fullHeight}
</ak-empty-state> icon=${ifDefined(icon)}
header=${ifDefined(header)}
<ak-empty-state icon="fa-cog"> >
<span>Settings</span> <span slot="body">This is the body content</span>
<span slot="body">No configuration</span> </ak-empty-state>
</ak-empty-state> `),
};
<ak-empty-state icon="fa-shield-alt">
<span>Security</span> export const WithBodyAndPrimarySlotsFilled = {
<span slot="body">No alerts</span> ...DefaultStory,
</ak-empty-state> args: {
</div> ...DefaultStory,
`, ...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content slot</span>
<span slot="primary">This is the primary content slot</span>
</ak-empty-state>`,
),
}; };

View File

@ -0,0 +1,36 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as LoadingOverlayStories from "./LoadingOverlay.stories";
<Meta of={LoadingOverlayStories} />
# LoadingOverlay
The LoadingOverlay is meant to cover the container element completely, hiding the content behind a
dimming filter, while content loads.
It has a single named slot, "body" into which messages about the loading process can be included.
## Usage
```Typescript
import "@goauthentik/elements/LoadingOverlay.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-loading-overlay topmost>
<span>This would display below the loading spinner</span>
</ak-loading-overlay>
```
## Demo
### Default
<Story of={LoadingOverlayStories.DefaultStory} />
### With a message
<Story of={LoadingOverlayStories.WithAMessage} />

View File

@ -1,154 +1,74 @@
import type { Meta, StoryObj } from "@storybook/web-components"; import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit"; import { LitElement, TemplateResult, css, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js"; import { customElement, property } from "lit/decorators.js";
import { type ILoadingOverlay, LoadingOverlay } from "../LoadingOverlay.js";
import "../LoadingOverlay.js"; import "../LoadingOverlay.js";
import { type ILoadingOverlay, LoadingOverlay, akLoadingOverlay } from "../LoadingOverlay.js";
type StoryArgs = ILoadingOverlay & { const metadata: Meta<LoadingOverlay> = {
headingText?: string; title: "Elements/<ak-loading-overlay>",
bodyText?: string;
noSpinner: boolean;
};
const metadata: Meta<StoryArgs> = {
title: "Elements/ <ak-loading-overlay>",
component: "ak-loading-overlay", component: "ak-loading-overlay",
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: "Our empty state spinner",
component: `
# Loading Overlay Component
A full-screen overlay component that displays a loading state with optional heading and body content.
A variant of the EmptyState component that includes a protective background for load or import
operations during which the user should be prevented from interacting with the page.
It has two named slots, both optional:
- **heading**: Main title (renders in an \`<h1>\`)
- **body**: Any text to describe the state
`,
},
}, },
}, },
argTypes: { argTypes: {
topmost: { topmost: { control: "boolean" },
control: "boolean", // @ts-ignore
description: message: { control: "text" },
"Whether this overlay should appear above all other overlays (z-index: 999)",
defaultValue: false,
},
noSpinner: {
control: "boolean",
description: "Disable the loading spinner animation",
defaultValue: false,
},
icon: {
control: "text",
description: "Icon name to display instead of the default loading spinner",
},
headingText: {
control: "text",
description: "Heading text displayed above the loading indicator",
},
bodyText: {
control: "text",
description: "Body text displayed below the loading indicator",
},
}, },
decorators: [
(story) => html`
<div
style="position: relative; height: 400px; width: 100%; border: 1px solid #ccc; background: #f5f5f5;"
>
<div style="padding: 20px;">
<h3>Content Behind Overlay</h3>
<p>authentik is awesome (or will be if something were actually loading)</p>
<button>Sample Button</button>
</div>
${story()}
</div>
`,
],
}; };
export default metadata; export default metadata;
type Story = StoryObj<StoryArgs>; @customElement("ak-storybook-demo-container")
export class Container extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: relative;
height: 25vh;
width: 75vw;
}
#main-container {
position: relative;
width: 100%;
height: 100%;
}
`;
}
export const Default: Story = { @property({ type: Object, attribute: false })
render: () => html`<ak-loading-overlay></ak-loading-overlay>`, content!: TemplateResult;
};
export const WithHeading: Story = { render() {
return html` <div id="main-container">${this.content}</div>`;
}
}
export const DefaultStory: StoryObj = {
args: { args: {
headingText: "Loading Data", topmost: undefined,
// @ts-ignore
message: undefined,
},
// @ts-ignore
render: ({ topmost, message }: ILoadingOverlay) => {
message = typeof message === "string" ? html`<span>${message}</span>` : message;
const content = html` <ak-loading-overlay ?topmost=${topmost}
>${message ?? ""}
</ak-loading-overlay>`;
return html`<ak-storybook-demo-container
.content=${content}
></ak-storybook-demo-container>`;
}, },
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
</ak-loading-overlay>`,
}; };
export const WithHeadingAndBody: Story = { export const WithAMessage: StoryObj = {
args: { ...DefaultStory,
headingText: "Loading Data", args: { ...DefaultStory.args, message: html`<p>Overlay with a message</p>` },
bodyText: "Please wait while we fetch your information...",
},
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const NoSpinner: Story = {
args: {
headingText: "Static Message",
bodyText: "This overlay shows without a spinner animation.",
},
render: (args) =>
html`<ak-loading-overlay no-spinner>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const WithCustomIcon: Story = {
args: {
icon: "fa-info-circle",
headingText: "Processing",
bodyText: "Your request is being processed...",
},
render: (args) =>
html`<ak-loading-overlay no-spinner icon=${ifDefined(args.icon)}>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const ProgrammaticUsage: Story = {
args: {
topmost: false,
noSpinner: false,
icon: "",
headingText: "Programmatic Loading",
bodyText: "This overlay was created using the akLoadingOverlay function.",
},
render: (args) =>
akLoadingOverlay(
{
topmost: args.topmost,
noSpinner: args.noSpinner,
icon: args.icon || undefined,
},
{
heading: args.headingText,
body: args.bodyText,
},
),
}; };

View File

@ -299,7 +299,9 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
return html`<tr role="row"> return html`<tr role="row">
<td role="cell" colspan="25"> <td role="cell" colspan="25">
<div class="pf-l-bullseye"> <div class="pf-l-bullseye">
<ak-empty-state default-label></ak-empty-state> <ak-empty-state loading
><span slot="header">${msg("Loading")}</span></ak-empty-state
>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@ -312,7 +314,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
<div class="pf-l-bullseye"> <div class="pf-l-bullseye">
${inner ?? ${inner ??
html`<ak-empty-state html`<ak-empty-state
><span>${msg("No objects found.")}</span> ><span slot="header">${msg("No objects found.")}</span> >
<div slot="primary">${this.renderObjectCreate()}</div> <div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`} </ak-empty-state>`}
</div> </div>
@ -329,7 +331,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
if (!this.error) return nothing; if (!this.error) return nothing;
return html`<ak-empty-state icon="fa-ban" return html`<ak-empty-state icon="fa-ban"
><span>${msg("Failed to fetch objects.")}</span> ><span slot="header">${msg("Failed to fetch objects.")}</span>
<div slot="body">${pluckErrorDetail(this.error)}</div> <div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`; </ak-empty-state>`;
} }

View File

@ -42,8 +42,7 @@ export abstract class TablePage<T> extends Table<T> {
return super.renderEmpty(html` return super.renderEmpty(html`
${inner ${inner
? inner ? inner
: html`<ak-empty-state icon=${this.pageIcon()} : html`<ak-empty-state icon=${this.pageIcon()} header="${msg("No objects found.")}">
><span>${msg("No objects found.")}</span>
<div slot="body"> <div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``} ${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div> </div>

View File

@ -19,7 +19,11 @@ describe("ak-empty-state", () => {
}); });
it("should render the default loader", async () => { it("should render the default loader", async () => {
render(html`<ak-empty-state default-label></ak-empty-state>`); render(
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
@ -29,17 +33,25 @@ describe("ak-empty-state", () => {
}); });
it("should handle standard boolean", async () => { it("should handle standard boolean", async () => {
render(html`<ak-empty-state loading>Waiting</ak-empty-state>`); render(
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
const header = await $("ak-empty-state").$(">>>.pf-c-title"); const header = await $("ak-empty-state").$(">>>.pf-c-title");
await expect(header).toHaveText("Waiting"); await expect(header).toHaveText("Loading");
}); });
it("should render a static empty state", async () => { it("should render a static empty state", async () => {
render(html`<ak-empty-state><span>${msg("No messages found")}</span> </ak-empty-state>`); render(
html`<ak-empty-state
><span slot="header">${msg("No messages found")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
@ -52,7 +64,7 @@ describe("ak-empty-state", () => {
it("should render a slotted message", async () => { it("should render a slotted message", async () => {
render( render(
html`<ak-empty-state html`<ak-empty-state
><span>${msg("No messages found")}</span> ><span slot="header">${msg("No messages found")}</span>
<p slot="body">Try again with a different filter</p> <p slot="body">Try again with a different filter</p>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -115,9 +115,9 @@ export class UserSourceSettingsPage extends AKElement {
${this.sourceSettings ${this.sourceSettings
? html` ? html`
${this.sourceSettings.length < 1 ${this.sourceSettings.length < 1
? html`<ak-empty-state> ? html`<ak-empty-state
<span>${msg("No services available.")}</span></ak-empty-state header=${msg("No services available.")}
>` ></ak-empty-state>`
: html` : html`
${this.sourceSettings.map((source) => { ${this.sourceSettings.map((source) => {
return html`<li class="pf-c-data-list__item"> return html`<li class="pf-c-data-list__item">
@ -139,7 +139,7 @@ export class UserSourceSettingsPage extends AKElement {
})} })}
`} `}
` `
: html`<ak-empty-state default-label></ak-empty-state>`} : html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`}
</ul>`; </ul>`;
} }
} }

View File

@ -304,7 +304,7 @@ export class FlowExecutor
async renderChallenge(): Promise<TemplateResult> { async renderChallenge(): Promise<TemplateResult> {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading default-label> </ak-empty-state>`; return html`<ak-empty-state loading> </ak-empty-state>`;
} }
switch (this.challenge?.component) { switch (this.challenge?.component) {
case "ak-stage-access-denied": case "ak-stage-access-denied":

View File

@ -24,7 +24,7 @@ export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
render(): TemplateResult { render(): TemplateResult {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state default-label></ak-empty-state>`; return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`;
} }
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>

Some files were not shown because too many files have changed in this diff Show More