Compare commits
	
		
			58 Commits
		
	
	
		
			fix/issue_
			...
			sfe-packag
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a9373d60d0 | |||
| 82fadf587b | |||
| 220378b3f2 | |||
| 363d655378 | |||
| e93b2a1a75 | |||
| 76665cf65e | |||
| 3ad7f4dc24 | |||
| c5045e8792 | |||
| a8c9b3a8ba | |||
| 148506639a | |||
| 53814d9919 | |||
| 08b04c32f5 | |||
| 1c1d97339d | |||
| cafa9c1737 | |||
| 5f64347ba1 | |||
| 45ef54480a | |||
| a3dc8af4c6 | |||
| 36933a0aca | |||
| 8f689890df | |||
| ec49b2e0e0 | |||
| 22ebe05706 | |||
| f0e58a6f49 | |||
| a3d642c08e | |||
| 5d42cb9185 | |||
| 1fd0cc5bb5 | |||
| deef365ff5 | |||
| d1ae6287f2 | |||
| 2e152cd264 | |||
| f5941e403b | |||
| ff3cf8c10e | |||
| bfa6328172 | |||
| 4c9691c932 | |||
| a0f1566b4c | |||
| 46261a4f42 | |||
| 8b42ff1e97 | |||
| ca4cb0d251 | |||
| a5a0fa79dd | |||
| c06a871f61 | |||
| 4a3df67134 | |||
| 422ccf61fa | |||
| d989f23907 | |||
| 059180edef | |||
| 22f30634a8 | |||
| 35ff418c42 | |||
| 7826e7a605 | |||
| 64f1b8207d | |||
| b2c13f0614 | |||
| 6965628020 | |||
| 608f63e9a2 | |||
| 22fa3a7fba | |||
| bcfd6fefa7 | |||
| eae18d0016 | |||
| 4a12a57c5f | |||
| 71294b7deb | |||
| 5af907db0c | |||
| 63a118a2ba | |||
| d9a3c34a44 | |||
| 23bdad7574 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2025.2.2 | ||||
| current_version = 2025.2.3 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||
| @ -17,6 +17,8 @@ optional_value = final | ||||
|  | ||||
| [bumpversion:file:pyproject.toml] | ||||
|  | ||||
| [bumpversion:file:uv.lock] | ||||
|  | ||||
| [bumpversion:file:package.json] | ||||
|  | ||||
| [bumpversion:file:docker-compose.yml] | ||||
|  | ||||
							
								
								
									
										5
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -36,11 +36,6 @@ jobs: | ||||
|         run: | | ||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||
|           npm i @goauthentik/api@$VERSION | ||||
|       - name: Upgrade /web/packages/sfe | ||||
|         working-directory: web/packages/sfe | ||||
|         run: | | ||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||
|           npm i @goauthentik/api@$VERSION | ||||
|       - uses: peter-evans/create-pull-request@v7 | ||||
|         id: cpr | ||||
|         with: | ||||
|  | ||||
| @ -30,7 +30,6 @@ WORKDIR /work/web | ||||
|  | ||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||
|     --mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \ | ||||
|     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ | ||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||
|     npm ci --include=dev | ||||
| @ -43,7 +42,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||
| RUN npm run build | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| ARG TARGETARCH | ||||
| @ -76,7 +75,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum | ||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ | ||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/authentik ./cmd/server | ||||
|  | ||||
| # Stage 4: MaxMind GeoIP | ||||
| @ -94,9 +93,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||
|     /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||
|  | ||||
| # Stage 5: Download uv | ||||
| FROM ghcr.io/astral-sh/uv:0.6.10 AS uv | ||||
| FROM ghcr.io/astral-sh/uv:0.6.12 AS uv | ||||
| # Stage 6: Base python image | ||||
| FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base | ||||
| FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base | ||||
|  | ||||
| ENV VENV_PATH="/ak-root/.venv" \ | ||||
|     PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2025.2.2" | ||||
| __version__ = "2025.2.3" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -46,7 +46,7 @@ LOGGER = get_logger() | ||||
|  | ||||
| def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str: | ||||
|     """Cache key where application list for user is saved""" | ||||
|     key = f"{CACHE_PREFIX}/app_access/{user_pk}" | ||||
|     key = f"{CACHE_PREFIX}app_access/{user_pk}" | ||||
|     if page_number: | ||||
|         key += f"/{page_number}" | ||||
|     return key | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
| from django_filters.filters import BooleanFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from rest_framework import mixins | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.fields import ReadOnlyField, SerializerMethodField | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.core.api.object_types import TypesMixin | ||||
| @ -18,10 +18,10 @@ from authentik.core.models import Provider | ||||
| class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
|     """Provider Serializer""" | ||||
|  | ||||
|     assigned_application_slug = SerializerMethodField() | ||||
|     assigned_application_name = SerializerMethodField() | ||||
|     assigned_backchannel_application_slug = SerializerMethodField() | ||||
|     assigned_backchannel_application_name = SerializerMethodField() | ||||
|     assigned_application_slug = ReadOnlyField(source="application.slug") | ||||
|     assigned_application_name = ReadOnlyField(source="application.name") | ||||
|     assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug") | ||||
|     assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name") | ||||
|  | ||||
|     component = SerializerMethodField() | ||||
|  | ||||
| @ -31,38 +31,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
|             return "" | ||||
|         return obj.component | ||||
|  | ||||
|     def get_assigned_application_slug(self, obj: Provider) -> str: | ||||
|         """Get application slug, return empty string if no application exists""" | ||||
|         try: | ||||
|             return obj.application.slug | ||||
|         except Provider.application.RelatedObjectDoesNotExist: | ||||
|             return "" | ||||
|  | ||||
|     def get_assigned_application_name(self, obj: Provider) -> str: | ||||
|         """Get application name, return empty string if no application exists""" | ||||
|         try: | ||||
|             return obj.application.name | ||||
|         except Provider.application.RelatedObjectDoesNotExist: | ||||
|             return "" | ||||
|  | ||||
|     def get_assigned_backchannel_application_slug(self, obj: Provider) -> str: | ||||
|         """Get backchannel application slug. | ||||
|  | ||||
|         Returns an empty string if no backchannel application exists. | ||||
|         """ | ||||
|         if not obj.backchannel_application: | ||||
|             return "" | ||||
|         return obj.backchannel_application.slug or "" | ||||
|  | ||||
|     def get_assigned_backchannel_application_name(self, obj: Provider) -> str: | ||||
|         """Get backchannel application name. | ||||
|  | ||||
|         Returns an empty string if no backchannel application exists. | ||||
|         """ | ||||
|         if not obj.backchannel_application: | ||||
|             return "" | ||||
|         return obj.backchannel_application.name or "" | ||||
|  | ||||
|     class Meta: | ||||
|         model = Provider | ||||
|         fields = [ | ||||
|  | ||||
| @ -179,10 +179,13 @@ class UserSourceConnectionSerializer(SourceSerializer): | ||||
|             "user", | ||||
|             "source", | ||||
|             "source_obj", | ||||
|             "identifier", | ||||
|             "created", | ||||
|             "last_updated", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "created": {"read_only": True}, | ||||
|             "last_updated": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @ -199,7 +202,7 @@ class UserSourceConnectionViewSet( | ||||
|     queryset = UserSourceConnection.objects.all() | ||||
|     serializer_class = UserSourceConnectionSerializer | ||||
|     filterset_fields = ["user", "source__slug"] | ||||
|     search_fields = ["source__slug"] | ||||
|     search_fields = ["user__username", "source__slug", "identifier"] | ||||
|     ordering = ["source__slug", "pk"] | ||||
|     owner_field = "user" | ||||
|  | ||||
| @ -218,9 +221,11 @@ class GroupSourceConnectionSerializer(SourceSerializer): | ||||
|             "source_obj", | ||||
|             "identifier", | ||||
|             "created", | ||||
|             "last_updated", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "created": {"read_only": True}, | ||||
|             "last_updated": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @ -237,6 +242,5 @@ class GroupSourceConnectionViewSet( | ||||
|     queryset = GroupSourceConnection.objects.all() | ||||
|     serializer_class = GroupSourceConnectionSerializer | ||||
|     filterset_fields = ["group", "source__slug"] | ||||
|     search_fields = ["source__slug"] | ||||
|     search_fields = ["group__name", "source__slug", "identifier"] | ||||
|     ordering = ["source__slug", "pk"] | ||||
|     owner_field = "user" | ||||
|  | ||||
| @ -1,13 +1,14 @@ | ||||
| """User API Views""" | ||||
|  | ||||
| from datetime import timedelta | ||||
| from importlib import import_module | ||||
| from json import loads | ||||
| from typing import Any | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth import update_session_auth_hash | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||
| from django.core.cache import cache | ||||
| from django.contrib.sessions.backends.base import SessionBase | ||||
| from django.db.models.functions import ExtractHour | ||||
| from django.db.transaction import atomic | ||||
| from django.db.utils import IntegrityError | ||||
| @ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore | ||||
|  | ||||
|  | ||||
| class UserGroupSerializer(ModelSerializer): | ||||
| @ -373,7 +375,7 @@ class UsersFilter(FilterSet): | ||||
|         method="filter_attributes", | ||||
|     ) | ||||
|  | ||||
|     is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") | ||||
|     is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") | ||||
|     uuid = UUIDFilter(field_name="uuid") | ||||
|  | ||||
|     path = CharFilter(field_name="path") | ||||
| @ -391,6 +393,11 @@ class UsersFilter(FilterSet): | ||||
|         queryset=Group.objects.all().order_by("name"), | ||||
|     ) | ||||
|  | ||||
|     def filter_is_superuser(self, queryset, name, value): | ||||
|         if value: | ||||
|             return queryset.filter(ak_groups__is_superuser=True).distinct() | ||||
|         return queryset.exclude(ak_groups__is_superuser=True).distinct() | ||||
|  | ||||
|     def filter_attributes(self, queryset, name, value): | ||||
|         """Filter attributes by query args""" | ||||
|         try: | ||||
| @ -769,7 +776,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         if not instance.is_active: | ||||
|             sessions = AuthenticatedSession.objects.filter(user=instance) | ||||
|             session_ids = sessions.values_list("session_key", flat=True) | ||||
|             cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids) | ||||
|             for session in session_ids: | ||||
|                 SessionStore(session).delete() | ||||
|             sessions.delete() | ||||
|             LOGGER.debug("Deleted user's sessions", user=instance.username) | ||||
|         return response | ||||
|  | ||||
| @ -0,0 +1,19 @@ | ||||
| # Generated by Django 5.0.13 on 2025-04-07 14:04 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0043_alter_group_options"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="usersourceconnection", | ||||
|             name="new_identifier", | ||||
|             field=models.TextField(default=""), | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,30 @@ | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0044_usersourceconnection_new_identifier"), | ||||
|         ("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"), | ||||
|         ("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"), | ||||
|         ("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"), | ||||
|         ("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RenameField( | ||||
|             model_name="usersourceconnection", | ||||
|             old_name="new_identifier", | ||||
|             new_name="identifier", | ||||
|         ), | ||||
|         migrations.AddIndex( | ||||
|             model_name="usersourceconnection", | ||||
|             index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"), | ||||
|         ), | ||||
|         migrations.AddIndex( | ||||
|             model_name="usersourceconnection", | ||||
|             index=models.Index( | ||||
|                 fields=["source", "identifier"], name="authentik_c_source__649e04_idx" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -761,11 +761,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|     @property | ||||
|     def component(self) -> str: | ||||
|         """Return component used to edit this object""" | ||||
|         if self.managed == self.MANAGED_INBUILT: | ||||
|             return "" | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     @property | ||||
|     def property_mapping_type(self) -> "type[PropertyMapping]": | ||||
|         """Return property mapping type used by this object""" | ||||
|         if self.managed == self.MANAGED_INBUILT: | ||||
|             from authentik.core.models import PropertyMapping | ||||
|  | ||||
|             return PropertyMapping | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def ui_login_button(self, request: HttpRequest) -> UILoginButton | None: | ||||
| @ -780,10 +786,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|  | ||||
|     def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: | ||||
|         """Get base properties for a user to build final properties upon.""" | ||||
|         if self.managed == self.MANAGED_INBUILT: | ||||
|             return {} | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: | ||||
|         """Get base properties for a group to build final properties upon.""" | ||||
|         if self.managed == self.MANAGED_INBUILT: | ||||
|             return {} | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def __str__(self): | ||||
| @ -814,6 +824,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): | ||||
|  | ||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||
|     source = models.ForeignKey(Source, on_delete=models.CASCADE) | ||||
|     identifier = models.TextField() | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
|  | ||||
| @ -827,6 +838,10 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = (("user", "source"),) | ||||
|         indexes = ( | ||||
|             models.Index(fields=("identifier",)), | ||||
|             models.Index(fields=("source", "identifier")), | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): | ||||
|  | ||||
| @ -1,7 +1,10 @@ | ||||
| """authentik core signals""" | ||||
|  | ||||
| from importlib import import_module | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.signals import user_logged_in, user_logged_out | ||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||
| from django.contrib.sessions.backends.base import SessionBase | ||||
| from django.core.cache import cache | ||||
| from django.core.signals import Signal | ||||
| from django.db.models import Model | ||||
| @ -25,6 +28,7 @@ password_changed = Signal() | ||||
| login_failed = Signal() | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Application) | ||||
| @ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_): | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): | ||||
|     """Delete session when authenticated session is deleted""" | ||||
|     cache_key = f"{KEY_PREFIX}{instance.session_key}" | ||||
|     cache.delete(cache_key) | ||||
|     SessionStore(instance.session_key).delete() | ||||
|  | ||||
|  | ||||
| @receiver(pre_save) | ||||
|  | ||||
| @ -36,6 +36,7 @@ from authentik.flows.planner import ( | ||||
| ) | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET | ||||
| from authentik.lib.utils.urls import is_url_absolute | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.utils import delete_none_values | ||||
| @ -209,6 +210,8 @@ class SourceFlowManager: | ||||
|         final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||
|             NEXT_ARG_NAME, "authentik_core:if-user" | ||||
|         ) | ||||
|         if not is_url_absolute(final_redirect): | ||||
|             final_redirect = "authentik_core:if-user" | ||||
|         flow_context.update( | ||||
|             { | ||||
|                 # Since we authenticate the user by their token, they have no backend set | ||||
|  | ||||
| @ -133,8 +133,6 @@ class TestApplicationsAPI(APITestCase): | ||||
|                         "provider_obj": { | ||||
|                             "assigned_application_name": "allowed", | ||||
|                             "assigned_application_slug": "allowed", | ||||
|                             "assigned_backchannel_application_name": "", | ||||
|                             "assigned_backchannel_application_slug": "", | ||||
|                             "authentication_flow": None, | ||||
|                             "invalidation_flow": None, | ||||
|                             "authorization_flow": str(self.provider.authorization_flow.pk), | ||||
| @ -188,8 +186,6 @@ class TestApplicationsAPI(APITestCase): | ||||
|                         "provider_obj": { | ||||
|                             "assigned_application_name": "allowed", | ||||
|                             "assigned_application_slug": "allowed", | ||||
|                             "assigned_backchannel_application_name": "", | ||||
|                             "assigned_backchannel_application_slug": "", | ||||
|                             "authentication_flow": None, | ||||
|                             "invalidation_flow": None, | ||||
|                             "authorization_flow": str(self.provider.authorization_flow.pk), | ||||
|  | ||||
| @ -3,8 +3,7 @@ | ||||
| from django.urls import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.api.providers import ProviderSerializer | ||||
| from authentik.core.models import Application, PropertyMapping, Provider | ||||
| from authentik.core.models import PropertyMapping | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
|  | ||||
|  | ||||
| @ -25,51 +24,3 @@ class TestProvidersAPI(APITestCase): | ||||
|             reverse("authentik_api:provider-types"), | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_provider_serializer_without_application(self): | ||||
|         """Test that Provider serializer handles missing application gracefully""" | ||||
|         # Create a provider without an application | ||||
|         provider = Provider.objects.create(name="test-provider") | ||||
|  | ||||
|         serializer = ProviderSerializer(instance=provider) | ||||
|         serialized_data = serializer.data | ||||
|  | ||||
|         # Check that fields return empty strings when no application exists | ||||
|         self.assertEqual(serialized_data["assigned_application_slug"], "") | ||||
|         self.assertEqual(serialized_data["assigned_application_name"], "") | ||||
|         self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "") | ||||
|         self.assertEqual(serialized_data["assigned_backchannel_application_name"], "") | ||||
|  | ||||
|     def test_provider_serializer_with_application(self): | ||||
|         """Test that Provider serializer correctly includes application data""" | ||||
|         # Create an application | ||||
|         app = Application.objects.create(name="Test App", slug="test-app") | ||||
|  | ||||
|         # Create a provider with an application | ||||
|         provider = Provider.objects.create(name="test-provider-with-app") | ||||
|         app.provider = provider | ||||
|         app.save() | ||||
|  | ||||
|         serializer = ProviderSerializer(instance=provider) | ||||
|         serialized_data = serializer.data | ||||
|  | ||||
|         # Check that fields return correct values when application exists | ||||
|         self.assertEqual(serialized_data["assigned_application_slug"], "test-app") | ||||
|         self.assertEqual(serialized_data["assigned_application_name"], "Test App") | ||||
|         self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "") | ||||
|         self.assertEqual(serialized_data["assigned_backchannel_application_name"], "") | ||||
|  | ||||
|     def test_provider_api_response(self): | ||||
|         """Test that the API response includes empty strings for missing applications""" | ||||
|         # Create a provider without an application | ||||
|         provider = Provider.objects.create(name="test-provider-api") | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:provider-detail", kwargs={"pk": provider.pk}), | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.data["assigned_application_slug"], "") | ||||
|         self.assertEqual(response.data["assigned_application_name"], "") | ||||
|         self.assertEqual(response.data["assigned_backchannel_application_slug"], "") | ||||
|         self.assertEqual(response.data["assigned_backchannel_application_name"], "") | ||||
|  | ||||
							
								
								
									
										19
									
								
								authentik/core/tests/test_source_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								authentik/core/tests/test_source_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| from django.apps import apps | ||||
| from django.urls import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
|  | ||||
|  | ||||
| class TestSourceAPI(APITestCase): | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_admin_user() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|     def test_builtin_source_used_by(self): | ||||
|         """Test Providers's types endpoint""" | ||||
|         apps.get_app_config("authentik_core").source_inbuilt() | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}), | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
| @ -1,6 +1,7 @@ | ||||
| """Test Users API""" | ||||
|  | ||||
| from datetime import datetime | ||||
| from json import loads | ||||
|  | ||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||
| from django.core.cache import cache | ||||
| @ -15,7 +16,12 @@ from authentik.core.models import ( | ||||
|     User, | ||||
|     UserTypes, | ||||
| ) | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow | ||||
| from authentik.core.tests.utils import ( | ||||
|     create_test_admin_user, | ||||
|     create_test_brand, | ||||
|     create_test_flow, | ||||
|     create_test_user, | ||||
| ) | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.stages.email.models import EmailStage | ||||
| @ -26,7 +32,7 @@ class TestUsersAPI(APITestCase): | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.admin = create_test_admin_user() | ||||
|         self.user = User.objects.create(username="test-user") | ||||
|         self.user = create_test_user() | ||||
|  | ||||
|     def test_filter_type(self): | ||||
|         """Test API filtering by type""" | ||||
| @ -41,6 +47,35 @@ class TestUsersAPI(APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_filter_is_superuser(self): | ||||
|         """Test API filtering by superuser status""" | ||||
|         User.objects.all().delete() | ||||
|         admin = create_test_admin_user() | ||||
|         self.client.force_login(admin) | ||||
|         # Test superuser | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:user-list"), | ||||
|             data={ | ||||
|                 "is_superuser": True, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         body = loads(response.content) | ||||
|         self.assertEqual(len(body["results"]), 1) | ||||
|         self.assertEqual(body["results"][0]["username"], admin.username) | ||||
|         # Test non-superuser | ||||
|         user = create_test_user() | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:user-list"), | ||||
|             data={ | ||||
|                 "is_superuser": False, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         body = loads(response.content) | ||||
|         self.assertEqual(len(body["results"]), 1, body) | ||||
|         self.assertEqual(body["results"][0]["username"], user.username) | ||||
|  | ||||
|     def test_list_with_groups(self): | ||||
|         """Test listing with groups""" | ||||
|         self.client.force_login(self.admin) | ||||
| @ -99,6 +134,8 @@ class TestUsersAPI(APITestCase): | ||||
|     def test_recovery_email_no_flow(self): | ||||
|         """Test user recovery link (no recovery flow set)""" | ||||
|         self.client.force_login(self.admin) | ||||
|         self.user.email = "" | ||||
|         self.user.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}) | ||||
|         ) | ||||
|  | ||||
| @ -13,7 +13,11 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | ||||
| from authentik.core.api.groups import GroupViewSet | ||||
| from authentik.core.api.property_mappings import PropertyMappingViewSet | ||||
| from authentik.core.api.providers import ProviderViewSet | ||||
| from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet | ||||
| from authentik.core.api.sources import ( | ||||
|     GroupSourceConnectionViewSet, | ||||
|     SourceViewSet, | ||||
|     UserSourceConnectionViewSet, | ||||
| ) | ||||
| from authentik.core.api.tokens import TokenViewSet | ||||
| from authentik.core.api.transactional_applications import TransactionalApplicationView | ||||
| from authentik.core.api.users import UserViewSet | ||||
| @ -81,6 +85,7 @@ api_urlpatterns = [ | ||||
|     ("core/tokens", TokenViewSet), | ||||
|     ("sources/all", SourceViewSet), | ||||
|     ("sources/user_connections/all", UserSourceConnectionViewSet), | ||||
|     ("sources/group_connections/all", GroupSourceConnectionViewSet), | ||||
|     ("providers/all", ProviderViewSet), | ||||
|     ("propertymappings/all", PropertyMappingViewSet), | ||||
|     ("authenticators/all", DeviceViewSet, "device"), | ||||
|  | ||||
| @ -49,6 +49,6 @@ | ||||
|         </main> | ||||
|         <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> | ||||
|       </div> | ||||
|       <script src="{% static 'dist/sfe/index.js' %}"></script> | ||||
|       <script src="{% static 'dist/sfe/main.js' %}"></script> | ||||
|     </body> | ||||
| </html> | ||||
|  | ||||
| @ -69,6 +69,7 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre" | ||||
| SESSION_KEY_GET = "authentik/flows/get" | ||||
| SESSION_KEY_POST = "authentik/flows/post" | ||||
| SESSION_KEY_HISTORY = "authentik/flows/history" | ||||
| SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started" | ||||
| QS_KEY_TOKEN = "flow_token"  # nosec | ||||
| QS_QUERY = "query" | ||||
|  | ||||
| @ -453,6 +454,7 @@ class FlowExecutorView(APIView): | ||||
|             SESSION_KEY_APPLICATION_PRE, | ||||
|             SESSION_KEY_PLAN, | ||||
|             SESSION_KEY_GET, | ||||
|             SESSION_KEY_AUTH_STARTED, | ||||
|             # We might need the initial POST payloads for later requests | ||||
|             # SESSION_KEY_POST, | ||||
|             # We don't delete the history on purpose, as a user might | ||||
|  | ||||
| @ -6,14 +6,22 @@ from django.shortcuts import get_object_or_404 | ||||
| from ua_parser.user_agent_parser import Parse | ||||
|  | ||||
| from authentik.core.views.interface import InterfaceView | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED | ||||
|  | ||||
|  | ||||
| class FlowInterfaceView(InterfaceView): | ||||
|     """Flow interface""" | ||||
|  | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||
|         flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||
|         kwargs["flow"] = flow | ||||
|         if ( | ||||
|             not self.request.user.is_authenticated | ||||
|             and flow.designation == FlowDesignation.AUTHENTICATION | ||||
|         ): | ||||
|             self.request.session[SESSION_KEY_AUTH_STARTED] = True | ||||
|             self.request.session.save() | ||||
|         kwargs["inspector"] = "inspector" in self.request.GET | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
| @ -18,6 +18,15 @@ class SerializerModel(models.Model): | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         """Get serializer for this model""" | ||||
|         # Special handling for built-in source | ||||
|         if ( | ||||
|             hasattr(self, "managed") | ||||
|             and hasattr(self, "MANAGED_INBUILT") | ||||
|             and self.managed == self.MANAGED_INBUILT | ||||
|         ): | ||||
|             from authentik.core.api.sources import SourceSerializer | ||||
|  | ||||
|             return SourceSerializer | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -35,3 +35,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig): | ||||
|     label = "authentik_policies" | ||||
|     verbose_name = "authentik Policies" | ||||
|     default = True | ||||
|     mountpoint = "policy/" | ||||
|  | ||||
							
								
								
									
										89
									
								
								authentik/policies/templates/policies/buffer.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								authentik/policies/templates/policies/buffer.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,89 @@ | ||||
| {% extends 'login/base_full.html' %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head %} | ||||
| {{ block.super }} | ||||
| <script> | ||||
|   let redirecting = false; | ||||
|   const checkAuth = async () => { | ||||
|     if (redirecting) return true; | ||||
|     const url = "{{ check_auth_url }}"; | ||||
|     console.debug("authentik/policies/buffer: Checking authentication..."); | ||||
|     try { | ||||
|       const result = await fetch(url, { | ||||
|         method: "HEAD", | ||||
|       }); | ||||
|       if (result.status >= 400) { | ||||
|         return false | ||||
|       } | ||||
|       console.debug("authentik/policies/buffer: Continuing"); | ||||
|       redirecting = true; | ||||
|       if ("{{ auth_req_method }}" === "post") { | ||||
|         document.querySelector("form").submit(); | ||||
|       } else { | ||||
|         window.location.assign("{{ continue_url|escapejs }}"); | ||||
|       } | ||||
|     } catch { | ||||
|       return false; | ||||
|     } | ||||
|   }; | ||||
|   let timeout = 100; | ||||
|   let offset = 20; | ||||
|   let attempt = 0; | ||||
|   const main = async () => { | ||||
|     attempt += 1; | ||||
|     await checkAuth(); | ||||
|     console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`); | ||||
|     setTimeout(main, timeout); | ||||
|     timeout += (offset * attempt); | ||||
|     if (timeout >= 2000) { | ||||
|       timeout = 2000; | ||||
|     } | ||||
|   } | ||||
|   document.addEventListener("visibilitychange", async () => { | ||||
|     if (document.hidden) return; | ||||
|     console.debug("authentik/policies/buffer: Checking authentication on tab activate..."); | ||||
|     await checkAuth(); | ||||
|   }); | ||||
|   main(); | ||||
| </script> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block title %} | ||||
| {% trans 'Waiting for authentication...' %} - {{ brand.branding_title }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card_title %} | ||||
| {% trans 'Waiting for authentication...' %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card %} | ||||
| <form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}"> | ||||
|   {% if auth_req_method == "post" %} | ||||
|     {% for key, value in auth_req_body.items %} | ||||
|       <input type="hidden" name="{{ key }}" value="{{ value }}" /> | ||||
|     {% endfor %} | ||||
|   {% endif %} | ||||
|   <div class="pf-c-empty-state"> | ||||
|     <div class="pf-c-empty-state__content"> | ||||
|       <div class="pf-c-empty-state__icon"> | ||||
|         <span class="pf-c-spinner pf-m-xl" role="progressbar"> | ||||
|           <span class="pf-c-spinner__clipper"></span> | ||||
|           <span class="pf-c-spinner__lead-ball"></span> | ||||
|           <span class="pf-c-spinner__tail-ball"></span> | ||||
|         </span> | ||||
|       </div> | ||||
|       <h1 class="pf-c-title pf-m-lg"> | ||||
|         {% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %} | ||||
|       </h1> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="pf-c-form__group pf-m-action"> | ||||
|     <a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|       {% trans "Authenticate in this tab" %} | ||||
|     </a> | ||||
|   </div> | ||||
| </form> | ||||
| {% endblock %} | ||||
							
								
								
									
										121
									
								
								authentik/policies/tests/test_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								authentik/policies/tests/test_views.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,121 @@ | ||||
| from django.contrib.auth.models import AnonymousUser | ||||
| from django.contrib.sessions.middleware import SessionMiddleware | ||||
| from django.http import HttpResponse | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application, Provider | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.flows.planner import FlowPlan | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import dummy_get_response | ||||
| from authentik.policies.views import ( | ||||
|     QS_BUFFER_ID, | ||||
|     SESSION_KEY_BUFFER, | ||||
|     BufferedPolicyAccessView, | ||||
|     BufferView, | ||||
|     PolicyAccessView, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class TestPolicyViews(TestCase): | ||||
|     """Test PolicyAccessView""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         self.factory = RequestFactory() | ||||
|         self.user = create_test_user() | ||||
|  | ||||
|     def test_pav(self): | ||||
|         """Test simple policy access view""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|  | ||||
|         class TestView(PolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/") | ||||
|         req.user = self.user | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertEqual(res.content, b"foo") | ||||
|  | ||||
|     def test_pav_buffer(self): | ||||
|         """Test simple policy access view""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|  | ||||
|         class TestView(BufferedPolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk) | ||||
|         req.session.save() | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer"))) | ||||
|  | ||||
|     def test_pav_buffer_skip(self): | ||||
|         """Test simple policy access view (skip buffer)""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|  | ||||
|         class TestView(BufferedPolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/?skip_buffer=true") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk) | ||||
|         req.session.save() | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication"))) | ||||
|  | ||||
|     def test_buffer(self): | ||||
|         """Test buffer view""" | ||||
|         uid = generate_id() | ||||
|         req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         ts = generate_id() | ||||
|         req.session[SESSION_KEY_BUFFER % uid] = { | ||||
|             "method": "get", | ||||
|             "body": {}, | ||||
|             "url": f"/{ts}", | ||||
|         } | ||||
|         req.session.save() | ||||
|  | ||||
|         res = BufferView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertIn(ts, res.render().content.decode()) | ||||
| @ -1,7 +1,14 @@ | ||||
| """API URLs""" | ||||
|  | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.policies.api.bindings import PolicyBindingViewSet | ||||
| from authentik.policies.api.policies import PolicyViewSet | ||||
| from authentik.policies.views import BufferView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("buffer", BufferView.as_view(), name="buffer"), | ||||
| ] | ||||
|  | ||||
| api_urlpatterns = [ | ||||
|     ("policies/all", PolicyViewSet), | ||||
|  | ||||
| @ -1,23 +1,37 @@ | ||||
| """authentik access helper classes""" | ||||
|  | ||||
| from typing import Any | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from django.contrib import messages | ||||
| from django.contrib.auth.mixins import AccessMixin | ||||
| from django.contrib.auth.views import redirect_to_login | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.http import HttpRequest, HttpResponse, QueryDict | ||||
| from django.shortcuts import redirect | ||||
| from django.urls import reverse | ||||
| from django.utils.http import urlencode | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic.base import View | ||||
| from django.views.generic.base import TemplateView, View | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Application, Provider, User | ||||
| from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.flows.planner import FlowPlan | ||||
| from authentik.flows.views.executor import ( | ||||
|     SESSION_KEY_APPLICATION_PRE, | ||||
|     SESSION_KEY_AUTH_STARTED, | ||||
|     SESSION_KEY_PLAN, | ||||
|     SESSION_KEY_POST, | ||||
| ) | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| QS_BUFFER_ID = "af_bf_id" | ||||
| QS_SKIP_BUFFER = "skip_buffer" | ||||
| SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s" | ||||
|  | ||||
|  | ||||
| class RequestValidationError(SentryIgnoredException): | ||||
| @ -125,3 +139,65 @@ class PolicyAccessView(AccessMixin, View): | ||||
|             for message in result.messages: | ||||
|                 messages.error(self.request, _(message)) | ||||
|         return result | ||||
|  | ||||
|  | ||||
| def url_with_qs(url: str, **kwargs): | ||||
|     """Update/set querystring of `url` with the parameters in `kwargs`. Original query string | ||||
|     parameters are retained""" | ||||
|     if "?" not in url: | ||||
|         return url + f"?{urlencode(kwargs)}" | ||||
|     url, _, qs = url.partition("?") | ||||
|     qs = QueryDict(qs, mutable=True) | ||||
|     qs.update(kwargs) | ||||
|     return url + f"?{urlencode(qs.items())}" | ||||
|  | ||||
|  | ||||
| class BufferView(TemplateView): | ||||
|     """Buffer view""" | ||||
|  | ||||
|     template_name = "policies/buffer.html" | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         buf_id = self.request.GET.get(QS_BUFFER_ID) | ||||
|         buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id) | ||||
|         kwargs["auth_req_method"] = buffer["method"] | ||||
|         kwargs["auth_req_body"] = buffer["body"] | ||||
|         kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True}) | ||||
|         kwargs["check_auth_url"] = reverse("authentik_api:user-me") | ||||
|         kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id}) | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
| class BufferedPolicyAccessView(PolicyAccessView): | ||||
|     """PolicyAccessView which buffers access requests in case the user is not logged in""" | ||||
|  | ||||
|     def handle_no_permission(self): | ||||
|         plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN) | ||||
|         authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED) | ||||
|         if plan: | ||||
|             flow = Flow.objects.filter(pk=plan.flow_pk).first() | ||||
|             if not flow or flow.designation != FlowDesignation.AUTHENTICATION: | ||||
|                 LOGGER.debug("Not buffering request, no flow or flow not for authentication") | ||||
|                 return super().handle_no_permission() | ||||
|         if not plan and authenticating is None: | ||||
|             LOGGER.debug("Not buffering request, no flow plan active") | ||||
|             return super().handle_no_permission() | ||||
|         if self.request.GET.get(QS_SKIP_BUFFER): | ||||
|             LOGGER.debug("Not buffering request, explicit skip") | ||||
|             return super().handle_no_permission() | ||||
|         buffer_id = str(uuid4()) | ||||
|         LOGGER.debug("Buffering access request", bf_id=buffer_id) | ||||
|         self.request.session[SESSION_KEY_BUFFER % buffer_id] = { | ||||
|             "body": self.request.POST, | ||||
|             "url": self.request.build_absolute_uri(self.request.get_full_path()), | ||||
|             "method": self.request.method.lower(), | ||||
|         } | ||||
|         return redirect( | ||||
|             url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id}) | ||||
|         ) | ||||
|  | ||||
|     def dispatch(self, request, *args, **kwargs): | ||||
|         response = super().dispatch(request, *args, **kwargs) | ||||
|         if QS_BUFFER_ID in self.request.GET: | ||||
|             self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None) | ||||
|         return response | ||||
|  | ||||
| @ -30,7 +30,7 @@ from authentik.flows.stage import StageView | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.views import PolicyAccessView, RequestValidationError | ||||
| from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError | ||||
| from authentik.providers.oauth2.constants import ( | ||||
|     PKCE_METHOD_PLAIN, | ||||
|     PKCE_METHOD_S256, | ||||
| @ -328,7 +328,7 @@ class OAuthAuthorizationParams: | ||||
|         return code | ||||
|  | ||||
|  | ||||
| class AuthorizationFlowInitView(PolicyAccessView): | ||||
| class AuthorizationFlowInitView(BufferedPolicyAccessView): | ||||
|     """OAuth2 Flow initializer, checks access to application and starts flow""" | ||||
|  | ||||
|     params: OAuthAuthorizationParams | ||||
|  | ||||
| @ -74,8 +74,6 @@ class TestEndpointsAPI(APITestCase): | ||||
|                             "component": "ak-provider-rac-form", | ||||
|                             "assigned_application_slug": self.app.slug, | ||||
|                             "assigned_application_name": self.app.name, | ||||
|                             "assigned_backchannel_application_slug": "", | ||||
|                             "assigned_backchannel_application_name": "", | ||||
|                             "verbose_name": "RAC Provider", | ||||
|                             "verbose_name_plural": "RAC Providers", | ||||
|                             "meta_model_name": "authentik_providers_rac.racprovider", | ||||
| @ -126,8 +124,6 @@ class TestEndpointsAPI(APITestCase): | ||||
|                             "component": "ak-provider-rac-form", | ||||
|                             "assigned_application_slug": self.app.slug, | ||||
|                             "assigned_application_name": self.app.name, | ||||
|                             "assigned_backchannel_application_slug": "", | ||||
|                             "assigned_backchannel_application_name": "", | ||||
|                             "connection_expiry": "hours=8", | ||||
|                             "delete_token_on_disconnect": False, | ||||
|                             "verbose_name": "RAC Provider", | ||||
| @ -157,8 +153,6 @@ class TestEndpointsAPI(APITestCase): | ||||
|                             "component": "ak-provider-rac-form", | ||||
|                             "assigned_application_slug": self.app.slug, | ||||
|                             "assigned_application_name": self.app.name, | ||||
|                             "assigned_backchannel_application_slug": "", | ||||
|                             "assigned_backchannel_application_name": "", | ||||
|                             "connection_expiry": "hours=8", | ||||
|                             "delete_token_on_disconnect": False, | ||||
|                             "verbose_name": "RAC Provider", | ||||
|  | ||||
| @ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import RedirectStage | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.policies.views import BufferedPolicyAccessView | ||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider | ||||
|  | ||||
|  | ||||
| class RACStartView(PolicyAccessView): | ||||
| class RACStartView(BufferedPolicyAccessView): | ||||
|     """Start a RAC connection by checking access and creating a connection token""" | ||||
|  | ||||
|     endpoint: Endpoint | ||||
|  | ||||
| @ -0,0 +1,22 @@ | ||||
| # Generated by Django 5.0.13 on 2025-03-31 13:50 | ||||
|  | ||||
| import authentik.lib.models | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="samlprovider", | ||||
|             name="acs_url", | ||||
|             field=models.TextField( | ||||
|                 validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))], | ||||
|                 verbose_name="ACS URL", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -10,6 +10,7 @@ from structlog.stdlib import get_logger | ||||
| from authentik.core.api.object_types import CreatableType | ||||
| from authentik.core.models import PropertyMapping, Provider | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.models import DomainlessURLValidator | ||||
| from authentik.lib.utils.time import timedelta_string_validator | ||||
| from authentik.sources.saml.processors.constants import ( | ||||
|     DSA_SHA1, | ||||
| @ -40,7 +41,9 @@ class SAMLBindings(models.TextChoices): | ||||
| class SAMLProvider(Provider): | ||||
|     """SAML 2.0 Endpoint for applications which support SAML.""" | ||||
|  | ||||
|     acs_url = models.URLField(verbose_name=_("ACS URL")) | ||||
|     acs_url = models.TextField( | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL") | ||||
|     ) | ||||
|     audience = models.TextField( | ||||
|         default="", | ||||
|         blank=True, | ||||
|  | ||||
| @ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.views.executor import SESSION_KEY_POST | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.policies.views import BufferedPolicyAccessView | ||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||
| from authentik.providers.saml.models import SAMLBindings, SAMLProvider | ||||
| from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser | ||||
| @ -35,7 +35,7 @@ from authentik.stages.consent.stage import ( | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class SAMLSSOView(PolicyAccessView): | ||||
| class SAMLSSOView(BufferedPolicyAccessView): | ||||
|     """SAML SSO Base View, which plans a flow and injects our final stage. | ||||
|     Calls get/post handler.""" | ||||
|  | ||||
| @ -83,7 +83,7 @@ class SAMLSSOView(PolicyAccessView): | ||||
|  | ||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||
|         """GET and POST use the same handler, but we can't | ||||
|         override .dispatch easily because PolicyAccessView's dispatch""" | ||||
|         override .dispatch easily because BufferedPolicyAccessView's dispatch""" | ||||
|         return self.get(request, application_slug) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,13 +1,11 @@ | ||||
| """Kerberos Source Serializer""" | ||||
|  | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.sources import ( | ||||
|     GroupSourceConnectionSerializer, | ||||
|     GroupSourceConnectionViewSet, | ||||
|     UserSourceConnectionSerializer, | ||||
|     UserSourceConnectionViewSet, | ||||
| ) | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.sources.kerberos.models import ( | ||||
|     GroupKerberosSourceConnection, | ||||
|     UserKerberosSourceConnection, | ||||
| @ -15,33 +13,20 @@ from authentik.sources.kerberos.models import ( | ||||
|  | ||||
|  | ||||
| class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|     """Kerberos Source Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|     class Meta(UserSourceConnectionSerializer.Meta): | ||||
|         model = UserKerberosSourceConnection | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"] | ||||
|  | ||||
|  | ||||
| class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Source Viewset""" | ||||
|  | ||||
| class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): | ||||
|     queryset = UserKerberosSourceConnection.objects.all() | ||||
|     serializer_class = UserKerberosSourceConnectionSerializer | ||||
|     filterset_fields = ["source__slug"] | ||||
|     search_fields = ["source__slug"] | ||||
|     ordering = ["source__slug"] | ||||
|     owner_field = "user" | ||||
|  | ||||
|  | ||||
| class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer): | ||||
|     """OAuth Group-Source connection Serializer""" | ||||
|  | ||||
|     class Meta(GroupSourceConnectionSerializer.Meta): | ||||
|         model = GroupKerberosSourceConnection | ||||
|  | ||||
|  | ||||
| class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet): | ||||
|     """Group-source connection Viewset""" | ||||
|  | ||||
| class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): | ||||
|     queryset = GroupKerberosSourceConnection.objects.all() | ||||
|     serializer_class = GroupKerberosSourceConnectionSerializer | ||||
|  | ||||
| @ -0,0 +1,28 @@ | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_identifier(apps, schema_editor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     UserKerberosSourceConnection = apps.get_model( | ||||
|         "authentik_sources_kerberos", "UserKerberosSourceConnection" | ||||
|     ) | ||||
|  | ||||
|     for connection in UserKerberosSourceConnection.objects.using(db_alias).all(): | ||||
|         connection.new_identifier = connection.identifier | ||||
|         connection.save(using=db_alias) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_sources_kerberos", "0002_kerberossource_kadmin_type"), | ||||
|         ("authentik_core", "0044_usersourceconnection_new_identifier"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop), | ||||
|         migrations.RemoveField( | ||||
|             model_name="userkerberossourceconnection", | ||||
|             name="identifier", | ||||
|         ), | ||||
|     ] | ||||
| @ -372,8 +372,6 @@ class KerberosSourcePropertyMapping(PropertyMapping): | ||||
| class UserKerberosSourceConnection(UserSourceConnection): | ||||
|     """Connection to configured Kerberos Sources.""" | ||||
|  | ||||
|     identifier = models.TextField() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.sources.kerberos.api.source_connection import ( | ||||
|  | ||||
| @ -1,5 +1,3 @@ | ||||
| """OAuth Source Serializer""" | ||||
|  | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.sources import ( | ||||
| @ -12,11 +10,9 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth | ||||
|  | ||||
|  | ||||
| class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|     """OAuth Source Serializer""" | ||||
|  | ||||
|     class Meta(UserSourceConnectionSerializer.Meta): | ||||
|         model = UserOAuthSourceConnection | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"] | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"] | ||||
|         extra_kwargs = { | ||||
|             **UserSourceConnectionSerializer.Meta.extra_kwargs, | ||||
|             "access_token": {"write_only": True}, | ||||
| @ -24,21 +20,15 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|  | ||||
|  | ||||
| class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): | ||||
|     """Source Viewset""" | ||||
|  | ||||
|     queryset = UserOAuthSourceConnection.objects.all() | ||||
|     serializer_class = UserOAuthSourceConnectionSerializer | ||||
|  | ||||
|  | ||||
| class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer): | ||||
|     """OAuth Group-Source connection Serializer""" | ||||
|  | ||||
|     class Meta(GroupSourceConnectionSerializer.Meta): | ||||
|         model = GroupOAuthSourceConnection | ||||
|  | ||||
|  | ||||
| class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): | ||||
|     """Group-source connection Viewset""" | ||||
|  | ||||
|     queryset = GroupOAuthSourceConnection.objects.all() | ||||
|     serializer_class = GroupOAuthSourceConnectionSerializer | ||||
|  | ||||
| @ -0,0 +1,28 @@ | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_identifier(apps, schema_editor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     UserOAuthSourceConnection = apps.get_model( | ||||
|         "authentik_sources_oauth", "UserOAuthSourceConnection" | ||||
|     ) | ||||
|  | ||||
|     for connection in UserOAuthSourceConnection.objects.using(db_alias).all(): | ||||
|         connection.new_identifier = connection.identifier | ||||
|         connection.save(using=db_alias) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_sources_oauth", "0008_groupoauthsourceconnection_and_more"), | ||||
|         ("authentik_core", "0044_usersourceconnection_new_identifier"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop), | ||||
|         migrations.RemoveField( | ||||
|             model_name="useroauthsourceconnection", | ||||
|             name="identifier", | ||||
|         ), | ||||
|     ] | ||||
| @ -286,7 +286,6 @@ class OAuthSourcePropertyMapping(PropertyMapping): | ||||
| class UserOAuthSourceConnection(UserSourceConnection): | ||||
|     """Authorized remote OAuth provider.""" | ||||
|  | ||||
|     identifier = models.CharField(max_length=255) | ||||
|     access_token = models.TextField(blank=True, null=True, default=None) | ||||
|  | ||||
|     @property | ||||
|  | ||||
| @ -1,5 +1,3 @@ | ||||
| """Plex Source connection Serializer""" | ||||
|  | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.sources import ( | ||||
| @ -12,14 +10,9 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou | ||||
|  | ||||
|  | ||||
| class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|     """Plex Source connection Serializer""" | ||||
|  | ||||
|     class Meta(UserSourceConnectionSerializer.Meta): | ||||
|         model = UserPlexSourceConnection | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + [ | ||||
|             "identifier", | ||||
|             "plex_token", | ||||
|         ] | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"] | ||||
|         extra_kwargs = { | ||||
|             **UserSourceConnectionSerializer.Meta.extra_kwargs, | ||||
|             "plex_token": {"write_only": True}, | ||||
| @ -27,21 +20,15 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|  | ||||
|  | ||||
| class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): | ||||
|     """Plex Source connection Serializer""" | ||||
|  | ||||
|     queryset = UserPlexSourceConnection.objects.all() | ||||
|     serializer_class = UserPlexSourceConnectionSerializer | ||||
|  | ||||
|  | ||||
| class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer): | ||||
|     """Plex Group-Source connection Serializer""" | ||||
|  | ||||
|     class Meta(GroupSourceConnectionSerializer.Meta): | ||||
|         model = GroupPlexSourceConnection | ||||
|  | ||||
|  | ||||
| class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): | ||||
|     """Group-source connection Viewset""" | ||||
|  | ||||
|     queryset = GroupPlexSourceConnection.objects.all() | ||||
|     serializer_class = GroupPlexSourceConnectionSerializer | ||||
|  | ||||
| @ -0,0 +1,29 @@ | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_identifier(apps, schema_editor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     UserPlexSourceConnection = apps.get_model("authentik_sources_plex", "UserPlexSourceConnection") | ||||
|  | ||||
|     for connection in UserPlexSourceConnection.objects.using(db_alias).all(): | ||||
|         connection.new_identifier = connection.identifier | ||||
|         connection.save(using=db_alias) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_sources_plex", | ||||
|             "0004_groupplexsourceconnection_plexsourcepropertymapping_and_more", | ||||
|         ), | ||||
|         ("authentik_core", "0044_usersourceconnection_new_identifier"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop), | ||||
|         migrations.RemoveField( | ||||
|             model_name="userplexsourceconnection", | ||||
|             name="identifier", | ||||
|         ), | ||||
|     ] | ||||
| @ -141,7 +141,6 @@ class UserPlexSourceConnection(UserSourceConnection): | ||||
|     """Connect user and plex source""" | ||||
|  | ||||
|     plex_token = models.TextField() | ||||
|     identifier = models.TextField() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|  | ||||
| @ -1,5 +1,3 @@ | ||||
| """SAML Source Serializer""" | ||||
|  | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.sources import ( | ||||
| @ -12,29 +10,20 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou | ||||
|  | ||||
|  | ||||
| class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer): | ||||
|     """SAML Source Serializer""" | ||||
|  | ||||
|     class Meta(UserSourceConnectionSerializer.Meta): | ||||
|         model = UserSAMLSourceConnection | ||||
|         fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"] | ||||
|  | ||||
|  | ||||
| class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): | ||||
|     """Source Viewset""" | ||||
|  | ||||
|     queryset = UserSAMLSourceConnection.objects.all() | ||||
|     serializer_class = UserSAMLSourceConnectionSerializer | ||||
|  | ||||
|  | ||||
| class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer): | ||||
|     """OAuth Group-Source connection Serializer""" | ||||
|  | ||||
|     class Meta(GroupSourceConnectionSerializer.Meta): | ||||
|         model = GroupSAMLSourceConnection | ||||
|  | ||||
|  | ||||
| class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet): | ||||
|     """Group-source connection Viewset""" | ||||
|  | ||||
| class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): | ||||
|     queryset = GroupSAMLSourceConnection.objects.all() | ||||
|     serializer_class = GroupSAMLSourceConnectionSerializer | ||||
|  | ||||
| @ -0,0 +1,35 @@ | ||||
| # Generated by Django 5.0.13 on 2025-03-31 13:53 | ||||
|  | ||||
| import authentik.lib.models | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_sources_saml", "0017_fix_x509subjectname"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="samlsource", | ||||
|             name="slo_url", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 default=None, | ||||
|                 help_text="Optional URL if your IDP supports Single-Logout.", | ||||
|                 null=True, | ||||
|                 validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))], | ||||
|                 verbose_name="SLO URL", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="samlsource", | ||||
|             name="sso_url", | ||||
|             field=models.TextField( | ||||
|                 help_text="URL that the initial Login request is sent to.", | ||||
|                 validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))], | ||||
|                 verbose_name="SSO URL", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,26 @@ | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_identifier(apps, schema_editor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     UserSAMLSourceConnection = apps.get_model("authentik_sources_saml", "UserSAMLSourceConnection") | ||||
|  | ||||
|     for connection in UserSAMLSourceConnection.objects.using(db_alias).all(): | ||||
|         connection.new_identifier = connection.identifier | ||||
|         connection.save(using=db_alias) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_sources_saml", "0018_alter_samlsource_slo_url_alter_samlsource_sso_url"), | ||||
|         ("authentik_core", "0044_usersourceconnection_new_identifier"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop), | ||||
|         migrations.RemoveField( | ||||
|             model_name="usersamlsourceconnection", | ||||
|             name="identifier", | ||||
|         ), | ||||
|     ] | ||||
| @ -20,6 +20,7 @@ from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.flows.challenge import RedirectChallenge | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.expression.evaluator import BaseEvaluator | ||||
| from authentik.lib.models import DomainlessURLValidator | ||||
| from authentik.lib.utils.time import timedelta_string_validator | ||||
| from authentik.sources.saml.processors.constants import ( | ||||
|     DSA_SHA1, | ||||
| @ -91,11 +92,13 @@ class SAMLSource(Source): | ||||
|         help_text=_("Also known as Entity ID. Defaults the Metadata URL."), | ||||
|     ) | ||||
|  | ||||
|     sso_url = models.URLField( | ||||
|     sso_url = models.TextField( | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))], | ||||
|         verbose_name=_("SSO URL"), | ||||
|         help_text=_("URL that the initial Login request is sent to."), | ||||
|     ) | ||||
|     slo_url = models.URLField( | ||||
|     slo_url = models.TextField( | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))], | ||||
|         default=None, | ||||
|         blank=True, | ||||
|         null=True, | ||||
| @ -315,8 +318,6 @@ class SAMLSourcePropertyMapping(PropertyMapping): | ||||
| class UserSAMLSourceConnection(UserSourceConnection): | ||||
|     """Connection to configured SAML Sources.""" | ||||
|  | ||||
|     identifier = models.TextField() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> Serializer: | ||||
|         from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer | ||||
|  | ||||
| @ -33,6 +33,7 @@ from authentik.flows.planner import ( | ||||
| ) | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import is_url_absolute | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.providers.saml.utils.encoding import nice64 | ||||
| from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat | ||||
| @ -73,6 +74,8 @@ class InitiateView(View): | ||||
|         final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||
|             NEXT_ARG_NAME, "authentik_core:if-user" | ||||
|         ) | ||||
|         if not is_url_absolute(final_redirect): | ||||
|             final_redirect = "authentik_core:if-user" | ||||
|         kwargs.update( | ||||
|             { | ||||
|                 PLAN_CONTEXT_SSO: True, | ||||
|  | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -104,6 +104,13 @@ def send_mail( | ||||
|         # can't be converted to json) | ||||
|         message_object.attach(logo_data()) | ||||
|  | ||||
|         if ( | ||||
|             message_object.to | ||||
|             and isinstance(message_object.to[0], str) | ||||
|             and "=?utf-8?" in message_object.to[0] | ||||
|         ): | ||||
|             message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")] | ||||
|  | ||||
|         LOGGER.debug("Sending mail", to=message_object.to) | ||||
|         backend.send_messages([message_object]) | ||||
|         Event.new( | ||||
|  | ||||
| @ -97,6 +97,37 @@ class TestEmailStageSending(FlowTestCase): | ||||
|             self.assertEqual(mail.outbox[0].subject, "authentik") | ||||
|             self.assertEqual(mail.outbox[0].to, [f"Test User   Many Words   <{long_user.email}>"]) | ||||
|  | ||||
|     def test_utf8_name(self): | ||||
|         """Test with pending user""" | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         utf8_user = create_test_user() | ||||
|         utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ " | ||||
|         utf8_user.email = "cyrillic@authentik.local" | ||||
|         utf8_user.save() | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         Event.objects.filter(action=EventAction.EMAIL_SENT).delete() | ||||
|  | ||||
|         url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         with patch( | ||||
|             "authentik.stages.email.models.EmailStage.backend_class", | ||||
|             PropertyMock(return_value=EmailBackend), | ||||
|         ): | ||||
|             response = self.client.post(url) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertStageResponse( | ||||
|                 response, | ||||
|                 self.flow, | ||||
|                 response_errors={ | ||||
|                     "non_field_errors": [{"string": "email-sent", "code": "email-sent"}] | ||||
|                 }, | ||||
|             ) | ||||
|             self.assertEqual(len(mail.outbox), 1) | ||||
|             self.assertEqual(mail.outbox[0].subject, "authentik") | ||||
|             self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"]) | ||||
|  | ||||
|     def test_pending_fake_user(self): | ||||
|         """Test with pending (fake) user""" | ||||
|         self.flow.designation = FlowDesignation.RECOVERY | ||||
|  | ||||
| @ -142,8 +142,18 @@ class IdentificationChallengeResponse(ChallengeResponse): | ||||
|             raise ValidationError("Failed to authenticate.") | ||||
|         self.pre_user = pre_user | ||||
|  | ||||
|         # Captcha check | ||||
|         if captcha_stage := current_stage.captcha_stage: | ||||
|             captcha_token = attrs.get("captcha_token", None) | ||||
|             if not captcha_token: | ||||
|                 self.stage.logger.warning("Token not set for captcha attempt") | ||||
|             verify_captcha_token(captcha_stage, captcha_token, client_ip) | ||||
|  | ||||
|         # Password check | ||||
|         if current_stage.password_stage: | ||||
|         if not current_stage.password_stage: | ||||
|             # No password stage select, don't validate the password | ||||
|             return attrs | ||||
|  | ||||
|         password = attrs.get("password", None) | ||||
|         if not password: | ||||
|             self.stage.logger.warning("Password not set for ident+auth attempt") | ||||
| @ -164,13 +174,6 @@ class IdentificationChallengeResponse(ChallengeResponse): | ||||
|             self.pre_user = user | ||||
|         except PermissionDenied as exc: | ||||
|             raise ValidationError(str(exc)) from exc | ||||
|  | ||||
|         # Captcha check | ||||
|         if captcha_stage := current_stage.captcha_stage: | ||||
|             captcha_token = attrs.get("captcha_token", None) | ||||
|             if not captcha_token: | ||||
|                 self.stage.logger.warning("Token not set for captcha attempt") | ||||
|             verify_captcha_token(captcha_stage, captcha_token, client_ip) | ||||
|         return attrs | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|     "$schema": "http://json-schema.org/draft-07/schema", | ||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||
|     "type": "object", | ||||
|     "title": "authentik 2025.2.2 Blueprint schema", | ||||
|     "title": "authentik 2025.2.3 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
| @ -6423,8 +6423,6 @@ | ||||
|                 }, | ||||
|                 "acs_url": { | ||||
|                     "type": "string", | ||||
|                     "format": "uri", | ||||
|                     "maxLength": 200, | ||||
|                     "minLength": 1, | ||||
|                     "title": "ACS URL" | ||||
|                 }, | ||||
| @ -8233,7 +8231,6 @@ | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "maxLength": 255, | ||||
|                     "minLength": 1, | ||||
|                     "title": "Identifier" | ||||
|                 }, | ||||
| @ -8733,8 +8730,6 @@ | ||||
|                 }, | ||||
|                 "sso_url": { | ||||
|                     "type": "string", | ||||
|                     "format": "uri", | ||||
|                     "maxLength": 200, | ||||
|                     "minLength": 1, | ||||
|                     "title": "SSO URL", | ||||
|                     "description": "URL that the initial Login request is sent to." | ||||
| @ -8744,8 +8739,6 @@ | ||||
|                         "string", | ||||
|                         "null" | ||||
|                     ], | ||||
|                     "format": "uri", | ||||
|                     "maxLength": 200, | ||||
|                     "title": "SLO URL", | ||||
|                     "description": "Optional URL if your IDP supports Single-Logout." | ||||
|                 }, | ||||
|  | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} | ||||
|     restart: unless-stopped | ||||
|     command: server | ||||
|     environment: | ||||
| @ -54,7 +54,7 @@ services: | ||||
|       redis: | ||||
|         condition: service_healthy | ||||
|   worker: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} | ||||
|     restart: unless-stopped | ||||
|     command: worker | ||||
|     environment: | ||||
|  | ||||
							
								
								
									
										7
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								go.mod
									
									
									
									
									
								
							| @ -1,9 +1,6 @@ | ||||
| module goauthentik.io | ||||
|  | ||||
| go 1.23.0 | ||||
|  | ||||
| toolchain go1.24.0 | ||||
|  | ||||
| go 1.24.0 | ||||
| require ( | ||||
| 	beryju.io/ldap v0.1.0 | ||||
| 	github.com/coreos/go-oidc/v3 v3.13.0 | ||||
| @ -29,7 +26,7 @@ require ( | ||||
| 	github.com/spf13/cobra v1.9.1 | ||||
| 	github.com/stretchr/testify v1.10.0 | ||||
| 	github.com/wwt/guac v1.3.2 | ||||
| 	goauthentik.io/api/v3 v3.2025022.6 | ||||
| 	goauthentik.io/api/v3 v3.2025023.2 | ||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||
| 	golang.org/x/oauth2 v0.28.0 | ||||
| 	golang.org/x/sync v0.12.0 | ||||
|  | ||||
							
								
								
									
										4
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.sum
									
									
									
									
									
								
							| @ -299,8 +299,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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||
| goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg= | ||||
| goauthentik.io/api/v3 v3.2025022.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2025023.2 h1:4XHlnykN5jQH78liQ4cp2Jf8eigvQImIJp+A+bsq1nA= | ||||
| goauthentik.io/api/v3 v3.2025023.2/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
|  | ||||
| @ -29,4 +29,4 @@ func UserAgent() string { | ||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||
| } | ||||
|  | ||||
| const VERSION = "2025.2.2" | ||||
| const VERSION = "2025.2.3" | ||||
|  | ||||
| @ -1,5 +0,0 @@ | ||||
| //go:build requirefips | ||||
|  | ||||
| package backend | ||||
|  | ||||
| var FipsEnabled = true | ||||
| @ -1,5 +0,0 @@ | ||||
| //go:build !requirefips | ||||
|  | ||||
| package backend | ||||
|  | ||||
| var FipsEnabled = false | ||||
| @ -2,6 +2,7 @@ package ak | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/fips140" | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"net/http" | ||||
| @ -203,7 +204,7 @@ func (a *APIController) getWebsocketPingArgs() map[string]interface{} { | ||||
| 		"golangVersion":  runtime.Version(), | ||||
| 		"opensslEnabled": cryptobackend.OpensslEnabled, | ||||
| 		"opensslVersion": cryptobackend.OpensslVersion(), | ||||
| 		"fipsEnabled":    cryptobackend.FipsEnabled, | ||||
| 		"fipsEnabled":    fips140.Enabled(), | ||||
| 	} | ||||
| 	hostname, err := os.Hostname() | ||||
| 	if err == nil { | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| # syntax=docker/dockerfile:1 | ||||
|  | ||||
| # Stage 1: Build | ||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| ARG TARGETARCH | ||||
| @ -27,7 +27,7 @@ COPY . . | ||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ | ||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/ldap ./cmd/ldap | ||||
|  | ||||
| # Stage 2: Run | ||||
|  | ||||
							
								
								
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -9,7 +9,7 @@ | ||||
|             "version": "0.0.0", | ||||
|             "license": "MIT", | ||||
|             "devDependencies": { | ||||
|                 "aws-cdk": "^2.1006.0", | ||||
|                 "aws-cdk": "^2.1007.0", | ||||
|                 "cross-env": "^7.0.3" | ||||
|             }, | ||||
|             "engines": { | ||||
| @ -17,9 +17,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/aws-cdk": { | ||||
|             "version": "2.1006.0", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1006.0.tgz", | ||||
|             "integrity": "sha512-6qYnCt4mBN+3i/5F+FC2yMETkDHY/IL7gt3EuqKVPcaAO4jU7oXfVSlR60CYRkZWL4fnAurUV14RkJuJyVG/IA==", | ||||
|             "version": "2.1007.0", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz", | ||||
|             "integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "bin": { | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         "node": ">=20" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "aws-cdk": "^2.1006.0", | ||||
|         "aws-cdk": "^2.1007.0", | ||||
|         "cross-env": "^7.0.3" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -26,7 +26,7 @@ Parameters: | ||||
|     Description: authentik Docker image | ||||
|   AuthentikVersion: | ||||
|     Type: String | ||||
|     Default: 2025.2.2 | ||||
|     Default: 2025.2.3 | ||||
|     Description: authentik Docker image tag | ||||
|   AuthentikServerCPU: | ||||
|     Type: Number | ||||
|  | ||||
| @ -8,7 +8,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-03-22 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-03-31 00:10+0000\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @ -1220,6 +1220,20 @@ msgstr "" | ||||
| msgid "Reputation Scores" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Waiting for authentication..." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "" | ||||
| "You're already authenticating in another tab. This page will refresh once " | ||||
| "authentication is completed." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Authenticate in this tab" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/policies/templates/policies/denied.html | ||||
| msgid "Permission denied" | ||||
| msgstr "" | ||||
|  | ||||
| @ -10,8 +10,8 @@ | ||||
| # Manuel Viens, 2023 | ||||
| # Mordecai, 2023 | ||||
| # nerdinator <florian.dupret@gmail.com>, 2024 | ||||
| # Tina, 2024 | ||||
| # Charles Leclerc, 2025 | ||||
| # Tina, 2025 | ||||
| # Marc Schmitt, 2025 | ||||
| #  | ||||
| #, fuzzy | ||||
| @ -19,7 +19,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-03-22 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-03-31 00:10+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: Marc Schmitt, 2025\n" | ||||
| "Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n" | ||||
| @ -1347,6 +1347,22 @@ msgstr "Score de Réputation" | ||||
| msgid "Reputation Scores" | ||||
| msgstr "Scores de Réputation" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Waiting for authentication..." | ||||
| msgstr "En attente de l'authentification..." | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "" | ||||
| "You're already authenticating in another tab. This page will refresh once " | ||||
| "authentication is completed." | ||||
| msgstr "" | ||||
| "Vous êtes déjà en cours d'authentification dans un autre onglet. Cette page " | ||||
| "se rafraîchira lorsque l'authentification sera terminée." | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Authenticate in this tab" | ||||
| msgstr "S'authentifier dans cet onglet" | ||||
|  | ||||
| #: authentik/policies/templates/policies/denied.html | ||||
| msgid "Permission denied" | ||||
| msgstr "Permission refusée" | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							| @ -14,7 +14,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-03-22 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-03-31 00:10+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: deluxghost, 2025\n" | ||||
| "Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n" | ||||
| @ -1234,6 +1234,20 @@ msgstr "信誉分数" | ||||
| msgid "Reputation Scores" | ||||
| msgstr "信誉分数" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Waiting for authentication..." | ||||
| msgstr "正在等待身份验证…" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "" | ||||
| "You're already authenticating in another tab. This page will refresh once " | ||||
| "authentication is completed." | ||||
| msgstr "您正在另一个标签页中验证身份。身份验证完成后,此页面会刷新。" | ||||
|  | ||||
| #: authentik/policies/templates/policies/buffer.html | ||||
| msgid "Authenticate in this tab" | ||||
| msgstr "在此标签页中验证身份" | ||||
|  | ||||
| #: authentik/policies/templates/policies/denied.html | ||||
| msgid "Permission denied" | ||||
| msgstr "权限被拒绝" | ||||
|  | ||||
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2025.2.1", | ||||
|     "version": "2025.2.3", | ||||
|     "lockfileVersion": 3, | ||||
|     "requires": true, | ||||
|     "packages": { | ||||
|         "": { | ||||
|             "name": "@goauthentik/authentik", | ||||
|             "version": "2025.2.1" | ||||
|             "version": "2025.2.3" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2025.2.2", | ||||
|     "version": "2025.2.3", | ||||
|     "private": true | ||||
| } | ||||
|  | ||||
| @ -17,7 +17,7 @@ COPY web . | ||||
| RUN npm run build-proxy | ||||
|  | ||||
| # Stage 2: Build | ||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| ARG TARGETARCH | ||||
| @ -43,7 +43,7 @@ COPY . . | ||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ | ||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/proxy ./cmd/proxy | ||||
|  | ||||
| # Stage 3: Run | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| [project] | ||||
| name = "authentik" | ||||
| version = "2025.2.2" | ||||
| version = "2025.2.3" | ||||
| description = "" | ||||
| authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }] | ||||
| requires-python = "==3.12.*" | ||||
| @ -52,7 +52,7 @@ dependencies = [ | ||||
|     "pydantic-scim", | ||||
|     "pyjwt", | ||||
|     "pyrad", | ||||
|     "python-kadmin-rs ==0.5.3", | ||||
|     "python-kadmin-rs ==0.6.0", | ||||
|     "pyyaml", | ||||
|     "requests-oauthlib", | ||||
|     "scim2-filter-parser", | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| # syntax=docker/dockerfile:1 | ||||
|  | ||||
| # Stage 1: Build | ||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| ARG TARGETARCH | ||||
| @ -27,7 +27,7 @@ COPY . . | ||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ | ||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/rac ./cmd/rac | ||||
|  | ||||
| # Stage 2: Run | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| # syntax=docker/dockerfile:1 | ||||
|  | ||||
| # Stage 1: Build | ||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| ARG TARGETARCH | ||||
| @ -27,7 +27,7 @@ COPY . . | ||||
| RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ | ||||
|     if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ | ||||
|     CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/radius ./cmd/radius | ||||
|  | ||||
| # Stage 2: Run | ||||
|  | ||||
							
								
								
									
										714
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										714
									
								
								schema.yml
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -410,3 +410,77 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): | ||||
|             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, | ||||
|             "Permission denied", | ||||
|         ) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
|         "default/flow-default-invalidation-flow.yaml", | ||||
|     ) | ||||
|     @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") | ||||
|     @apply_blueprint("system/providers-oauth2.yaml") | ||||
|     @reconcile_app("authentik_crypto") | ||||
|     def test_authorization_consent_implied_parallel(self): | ||||
|         """test OpenID Provider flow (default authorization flow with implied consent)""" | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             slug="default-provider-authorization-implicit-consent" | ||||
|         ) | ||||
|         provider = OAuth2Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|             client_type=ClientTypes.CONFIDENTIAL, | ||||
|             client_id=self.client_id, | ||||
|             client_secret=self.client_secret, | ||||
|             signing_key=create_test_cert(), | ||||
|             redirect_uris=[ | ||||
|                 RedirectURI( | ||||
|                     RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth" | ||||
|                 ) | ||||
|             ], | ||||
|             authorization_flow=authorization_flow, | ||||
|         ) | ||||
|         provider.property_mappings.set( | ||||
|             ScopeMapping.objects.filter( | ||||
|                 scope_name__in=[ | ||||
|                     SCOPE_OPENID, | ||||
|                     SCOPE_OPENID_EMAIL, | ||||
|                     SCOPE_OPENID_PROFILE, | ||||
|                     SCOPE_OFFLINE_ACCESS, | ||||
|                 ] | ||||
|             ) | ||||
|         ) | ||||
|         Application.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=self.app_slug, | ||||
|             provider=provider, | ||||
|         ) | ||||
|  | ||||
|         self.driver.get(self.live_server_url) | ||||
|         login_window = self.driver.current_window_handle | ||||
|  | ||||
|         self.driver.switch_to.new_window("tab") | ||||
|         grafana_window = self.driver.current_window_handle | ||||
|         self.driver.get("http://localhost:3000") | ||||
|         self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() | ||||
|  | ||||
|         self.driver.switch_to.window(login_window) | ||||
|         self.login() | ||||
|  | ||||
|         self.driver.switch_to.window(grafana_window) | ||||
|         self.wait_for_url("http://localhost:3000/?orgId=1") | ||||
|         self.driver.get("http://localhost:3000/profile") | ||||
|         self.assertEqual( | ||||
|             self.driver.find_element(By.CLASS_NAME, "page-header__title").text, | ||||
|             self.user.name, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"), | ||||
|             self.user.name, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"), | ||||
|             self.user.email, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"), | ||||
|             self.user.email, | ||||
|         ) | ||||
|  | ||||
| @ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry | ||||
| class TestProviderSAML(SeleniumTestCase): | ||||
|     """test SAML Provider flow""" | ||||
|  | ||||
|     def setup_client(self, provider: SAMLProvider, force_post: bool = False): | ||||
|     def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs): | ||||
|         """Setup client saml-sp container which we test SAML against""" | ||||
|         metadata_url = ( | ||||
|             self.url( | ||||
| @ -40,6 +40,7 @@ class TestProviderSAML(SeleniumTestCase): | ||||
|                 "SP_ENTITY_ID": provider.issuer, | ||||
|                 "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", | ||||
|                 "SP_METADATA_URL": metadata_url, | ||||
|                 **kwargs, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -111,6 +112,74 @@ class TestProviderSAML(SeleniumTestCase): | ||||
|             [self.user.email], | ||||
|         ) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
|         "default/flow-default-invalidation-flow.yaml", | ||||
|     ) | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-provider-authorization-implicit-consent.yaml", | ||||
|     ) | ||||
|     @apply_blueprint( | ||||
|         "system/providers-saml.yaml", | ||||
|     ) | ||||
|     @reconcile_app("authentik_crypto") | ||||
|     def test_sp_initiated_implicit_post(self): | ||||
|         """test SAML Provider flow SP-initiated flow (implicit consent)""" | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             slug="default-provider-authorization-implicit-consent" | ||||
|         ) | ||||
|         provider: SAMLProvider = SAMLProvider.objects.create( | ||||
|             name="saml-test", | ||||
|             acs_url="http://localhost:9009/saml/acs", | ||||
|             audience="authentik-e2e", | ||||
|             issuer="authentik-e2e", | ||||
|             sp_binding=SAMLBindings.POST, | ||||
|             authorization_flow=authorization_flow, | ||||
|             signing_kp=create_test_cert(), | ||||
|         ) | ||||
|         provider.property_mappings.set(SAMLPropertyMapping.objects.all()) | ||||
|         provider.save() | ||||
|         Application.objects.create( | ||||
|             name="SAML", | ||||
|             slug="authentik-saml", | ||||
|             provider=provider, | ||||
|         ) | ||||
|         self.setup_client(provider, True) | ||||
|         self.driver.get("http://localhost:9009") | ||||
|         self.login() | ||||
|         self.wait_for_url("http://localhost:9009/") | ||||
|  | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], | ||||
|             [self.user.name], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"][ | ||||
|                 "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" | ||||
|             ], | ||||
|             [self.user.username], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"], | ||||
|             [self.user.username], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"], | ||||
|             [str(self.user.pk)], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"], | ||||
|             [self.user.email], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"], | ||||
|             [self.user.email], | ||||
|         ) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
| @ -450,3 +519,81 @@ class TestProviderSAML(SeleniumTestCase): | ||||
|             lambda driver: driver.current_url.startswith(should_url), | ||||
|             f"URL {self.driver.current_url} doesn't match expected URL {should_url}", | ||||
|         ) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
|         "default/flow-default-invalidation-flow.yaml", | ||||
|     ) | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-provider-authorization-implicit-consent.yaml", | ||||
|     ) | ||||
|     @apply_blueprint( | ||||
|         "system/providers-saml.yaml", | ||||
|     ) | ||||
|     @reconcile_app("authentik_crypto") | ||||
|     def test_sp_initiated_implicit_post_buffer(self): | ||||
|         """test SAML Provider flow SP-initiated flow (implicit consent)""" | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             slug="default-provider-authorization-implicit-consent" | ||||
|         ) | ||||
|         provider: SAMLProvider = SAMLProvider.objects.create( | ||||
|             name="saml-test", | ||||
|             acs_url=f"http://{self.host}:9009/saml/acs", | ||||
|             audience="authentik-e2e", | ||||
|             issuer="authentik-e2e", | ||||
|             sp_binding=SAMLBindings.POST, | ||||
|             authorization_flow=authorization_flow, | ||||
|             signing_kp=create_test_cert(), | ||||
|         ) | ||||
|         provider.property_mappings.set(SAMLPropertyMapping.objects.all()) | ||||
|         provider.save() | ||||
|         Application.objects.create( | ||||
|             name="SAML", | ||||
|             slug="authentik-saml", | ||||
|             provider=provider, | ||||
|         ) | ||||
|         self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009") | ||||
|  | ||||
|         self.driver.get(self.live_server_url) | ||||
|         login_window = self.driver.current_window_handle | ||||
|         self.driver.switch_to.new_window("tab") | ||||
|         client_window = self.driver.current_window_handle | ||||
|         # We need to access the SP on the same host as the IdP for SameSite cookies | ||||
|         self.driver.get(f"http://{self.host}:9009") | ||||
|  | ||||
|         self.driver.switch_to.window(login_window) | ||||
|         self.login() | ||||
|         self.driver.switch_to.window(client_window) | ||||
|  | ||||
|         self.wait_for_url(f"http://{self.host}:9009/") | ||||
|  | ||||
|         body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], | ||||
|             [self.user.name], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"][ | ||||
|                 "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" | ||||
|             ], | ||||
|             [self.user.username], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"], | ||||
|             [self.user.username], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"], | ||||
|             [str(self.user.pk)], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"], | ||||
|             [self.user.email], | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"], | ||||
|             [self.user.email], | ||||
|         ) | ||||
|  | ||||
							
								
								
									
										20
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										20
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @ -162,7 +162,7 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "authentik" | ||||
| version = "2025.2.2" | ||||
| version = "2025.2.3" | ||||
| source = { editable = "." } | ||||
| dependencies = [ | ||||
|     { name = "argon2-cffi" }, | ||||
| @ -310,7 +310,7 @@ requires-dist = [ | ||||
|     { name = "pydantic-scim" }, | ||||
|     { name = "pyjwt" }, | ||||
|     { name = "pyrad" }, | ||||
|     { name = "python-kadmin-rs", specifier = "==0.5.3" }, | ||||
|     { name = "python-kadmin-rs", specifier = "==0.6.0" }, | ||||
|     { name = "pyyaml" }, | ||||
|     { name = "requests-oauthlib" }, | ||||
|     { name = "scim2-filter-parser" }, | ||||
| @ -2599,16 +2599,16 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "python-kadmin-rs" | ||||
| version = "0.5.3" | ||||
| version = "0.6.0" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/e7/95/07b708623f13874ad86dc603f2fe36e980a5f5890edea87286d13f2b0b81/python_kadmin_rs-0.5.3.tar.gz", hash = "sha256:4f46fd854af622896136c3ac4fc5e6a37d37bfffb5b2023e438001ffa62ab7e3", size = 89865 } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/b9/ac/df3a093b1e186cd68a6f38778fac025450e5c5e9859c4790e00c2ed0ff62/python_kadmin_rs-0.6.0.tar.gz", hash = "sha256:dadd3d4ef542b829c1dcde97360a6b6a10700a4b5686f12f24b10f6cf5ca6e6c", size = 89318 } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/96/46/1bbfd7d6819851c300b991d7340452fba8edc3d2fe68b33271279eb74887/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:54b5e1c2e22da0d16c1418eb2b46da8baa11699a5db8db2afc52dbfd02d14958", size = 1416637 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/be/34/fd7f5c324aaf1b9ad3dd5050ac2059230618c29adc452d676d2af4d5ae79/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d1dc7ad1f07bbfd09baeb1fb0dfc45c87776ed717052081e63d3bdba340a250e", size = 1503018 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/e5/29/3931502534e07806cf7c70631374452cfcbafa44e75c5403416372b701c7/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86404a1060ece916088ae4a0d188e9309fd46e0b3003779ee7a8dc7493176779", size = 3268475 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/ba/5d/f18ca5df97a4241711555987eb308c6e6c5505883514ac7f18d7aebd52f2/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7aa62a618af2b2112f708fd44f9cc3cf25e28f1562ea66a2036fb3cd1a47e649", size = 3371699 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/91/d3/42c4d57414cfdf4e4ff528dd8e72428908ee67aeeae6a63fe2f5dbcd04bc/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80813af82dfbcc6a90505183c822eab11de77b6703e5691e37ed77d292224dd9", size = 1584049 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/9a/65/705f179cf4bf4d16fc1daeac0810def57da2f4514a5b79ca60f24d7efb90/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6799a0faddb4ccf200acfa87da38e5fa2af54970d066b2c876e752bbf794b204", size = 1590360 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/12/6d/59fefe1c4c11177c4feb8ad65dd6a265e9cc5fc83682a928acdccb170000/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0069fbd656096b98853f8cdc6d5e24f754829fa9cb4a716dac33777f0305d37a", size = 1418187 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a6/12/c00a71c0fc17f5d208b4bb5e570002d74f0bc414e35194537d46ea32080f/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:cfcfe9982e969705dee62f2b97c8d7c249b55b2a97e2bc981408061ea7182b96", size = 1501759 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a0/b5/06cf809cfaaeded84e6634bf07116264ab4f8fd5eccca7523114e197f424/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:920df382e7a554d2f6fd160436a64adf1251f3262ec16bccd6d3b9f7e039d5fa", size = 3262691 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/e6/72/99884dbc1856440a548ea8bf2ff1232c7f2823b6cb1a62bbb4d902a34609/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:94509b7470b18105c27fcaf5e6af894644614a687af74a43499735c405217e01", size = 3382996 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/bd/4f/5d7e5be27cd466affc00fcab71fb94ea0420aee95306188988faf270b129/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f89e7fbcb7220a42c143a1b008685f98ca0a72ecc55c30f85b72c9d1ba9c3b9", size = 1572007 }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a6/1e/fdd7d6cd2ebc4cc654112329311380d1c03c681511973e32ae6ab90f261c/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:775ce07ffd47a50ba27c8d74c20baacb56acfc7a8c56a8b02f2207ed9829156e", size = 1618897 }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|  | ||||
							
								
								
									
										206
									
								
								web/authentication/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								web/authentication/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,206 @@ | ||||
| /** | ||||
|  * @file WebAuthn utilities. | ||||
|  */ | ||||
| import { fromByteArray } from "base64-js"; | ||||
|  | ||||
| //@ts-check | ||||
|  | ||||
| //#region Type Definitions | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} Assertion | ||||
|  * @property {string} id | ||||
|  * @property {string} rawId | ||||
|  * @property {string} type | ||||
|  * @property {string} registrationClientExtensions | ||||
|  * @property {object} response | ||||
|  * @property {string} response.clientDataJSON | ||||
|  * @property {string} response.attestationObject | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} AuthAssertion | ||||
|  * @property {string} id | ||||
|  * @property {string} rawId | ||||
|  * @property {string} type | ||||
|  * @property {string} assertionClientExtensions | ||||
|  * @property {object} response | ||||
|  * @property {string} response.clientDataJSON | ||||
|  * @property {string} response.authenticatorData | ||||
|  * @property {string} response.signature | ||||
|  * @property {string | null} response.userHandle | ||||
|  */ | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Encoding/Decoding | ||||
|  | ||||
| /** | ||||
|  * Encodes a byte array into a URL-safe base64 string. | ||||
|  * | ||||
|  * @param {Uint8Array} buffer | ||||
|  * @returns {string} | ||||
|  */ | ||||
| export function encodeBase64(buffer) { | ||||
|     return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Encodes a byte array into a base64 string without URL-safe encoding, i.e., with padding. | ||||
|  * @param {Uint8Array} buffer | ||||
|  * @returns {string} | ||||
|  */ | ||||
| export function encodeBase64Raw(buffer) { | ||||
|     return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_"); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Decodes a base64 string into a byte array. | ||||
|  * | ||||
|  * @param {string} input | ||||
|  * @returns {Uint8Array} | ||||
|  */ | ||||
| export function decodeBase64(input) { | ||||
|     return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) => | ||||
|         c.charCodeAt(0), | ||||
|     ); | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Utility Functions | ||||
|  | ||||
| /** | ||||
|  * Checks if the browser supports WebAuthn. | ||||
|  * | ||||
|  * @returns {boolean} | ||||
|  */ | ||||
| export function isWebAuthnSupported() { | ||||
|     if ("credentials" in navigator) return true; | ||||
|  | ||||
|     if (window.location.protocol === "http:" && window.location.hostname !== "localhost") { | ||||
|         console.warn("WebAuthn requires this page to be accessed via HTTPS."); | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     console.warn("WebAuthn not supported by browser."); | ||||
|     return false; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Asserts that the browser supports WebAuthn and that we're in a secure context. | ||||
|  * | ||||
|  * @throws {Error} If WebAuthn is not supported. | ||||
|  */ | ||||
| export function assertWebAuthnSupport() { | ||||
|     // Is the navigator exposing the credentials API? | ||||
|     if ("credentials" in navigator) return; | ||||
|  | ||||
|     if (window.location.protocol === "http:" && window.location.hostname !== "localhost") { | ||||
|         throw new Error("WebAuthn requires this page to be accessed via HTTPS."); | ||||
|     } | ||||
|     throw new Error("WebAuthn not supported by browser."); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Transforms items in the credentialCreateOptions generated on the server | ||||
|  * into byte arrays expected by the navigator.credentials.create() call | ||||
|  * @param {PublicKeyCredentialCreationOptions} credentialCreateOptions | ||||
|  * @param {string} userID | ||||
|  * @returns {PublicKeyCredentialCreationOptions} | ||||
|  */ | ||||
| export function transformCredentialCreateOptions(credentialCreateOptions, userID) { | ||||
|     const user = credentialCreateOptions.user; | ||||
|     // Because json can't contain raw bytes, the server base64-encodes the User ID | ||||
|     // So to get the base64 encoded byte array, we first need to convert it to a regular | ||||
|     // string, then a byte array, re-encode it and wrap that in an array. | ||||
|     const stringId = decodeURIComponent(window.atob(userID)); | ||||
|  | ||||
|     user.id = decodeBase64(encodeBase64(decodeBase64(stringId))); | ||||
|     const challenge = decodeBase64(credentialCreateOptions.challenge.toString()); | ||||
|  | ||||
|     return { | ||||
|         ...credentialCreateOptions, | ||||
|         challenge, | ||||
|         user, | ||||
|     }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Transforms the binary data in the credential into base64 strings | ||||
|  * for posting to the server. | ||||
|  * | ||||
|  * @param {PublicKeyCredential} newAssertion | ||||
|  * @returns {Assertion} | ||||
|  */ | ||||
| export function transformNewAssertionForServer(newAssertion) { | ||||
|     const response = /** @type {AuthenticatorAttestationResponse} */ (newAssertion.response); | ||||
|  | ||||
|     const attObj = new Uint8Array(response.attestationObject); | ||||
|     const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); | ||||
|     const rawId = new Uint8Array(newAssertion.rawId); | ||||
|  | ||||
|     const registrationClientExtensions = newAssertion.getClientExtensionResults(); | ||||
|  | ||||
|     return { | ||||
|         id: newAssertion.id, | ||||
|         rawId: encodeBase64(rawId), | ||||
|         type: newAssertion.type, | ||||
|         registrationClientExtensions: JSON.stringify(registrationClientExtensions), | ||||
|         response: { | ||||
|             clientDataJSON: encodeBase64(clientDataJSON), | ||||
|             attestationObject: encodeBase64(attObj), | ||||
|         }, | ||||
|     }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  *  Transforms  the items in the credentialRequestOptions generated on the server | ||||
|  * | ||||
|  * @param {PublicKeyCredentialRequestOptions} credentialRequestOptions | ||||
|  * @returns {PublicKeyCredentialRequestOptions} | ||||
|  */ | ||||
| export function transformCredentialRequestOptions(credentialRequestOptions) { | ||||
|     const challenge = decodeBase64(credentialRequestOptions.challenge.toString()); | ||||
|  | ||||
|     const allowCredentials = (credentialRequestOptions.allowCredentials || []).map( | ||||
|         (credentialDescriptor) => { | ||||
|             const id = decodeBase64(credentialDescriptor.id.toString()); | ||||
|             return Object.assign({}, credentialDescriptor, { id }); | ||||
|         }, | ||||
|     ); | ||||
|  | ||||
|     return Object.assign({}, credentialRequestOptions, { | ||||
|         challenge, | ||||
|         allowCredentials, | ||||
|     }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Encodes the binary data in the assertion into strings for posting to the server. | ||||
|  * @param {PublicKeyCredential} newAssertion | ||||
|  * @returns {AuthAssertion} | ||||
|  */ | ||||
| export function transformAssertionForServer(newAssertion) { | ||||
|     const response = /** @type {AuthenticatorAssertionResponse} */ (newAssertion.response); | ||||
|  | ||||
|     const authData = new Uint8Array(response.authenticatorData); | ||||
|     const clientDataJSON = new Uint8Array(response.clientDataJSON); | ||||
|     const rawId = new Uint8Array(newAssertion.rawId); | ||||
|     const sig = new Uint8Array(response.signature); | ||||
|     const assertionClientExtensions = newAssertion.getClientExtensionResults(); | ||||
|  | ||||
|     return { | ||||
|         id: newAssertion.id, | ||||
|         rawId: encodeBase64(rawId), | ||||
|         type: newAssertion.type, | ||||
|         assertionClientExtensions: JSON.stringify(assertionClientExtensions), | ||||
|  | ||||
|         response: { | ||||
|             clientDataJSON: encodeBase64Raw(clientDataJSON), | ||||
|             signature: encodeBase64Raw(sig), | ||||
|             authenticatorData: encodeBase64Raw(authData), | ||||
|             userHandle: null, | ||||
|         }, | ||||
|     }; | ||||
| } | ||||
| @ -48,6 +48,9 @@ export default [ | ||||
|             "lit/no-template-bind": "error", | ||||
|             "no-unused-vars": "off", | ||||
|             "no-console": ["error", { allow: ["debug", "warn", "error"] }], | ||||
|             // TODO: TypeScript already handles this. | ||||
|             // Remove after project-wide ESLint config is properly set up. | ||||
|             "no-undef": "off", | ||||
|             "@typescript-eslint/ban-ts-comment": "off", | ||||
|             "@typescript-eslint/no-unused-vars": [ | ||||
|                 "error", | ||||
| @ -71,8 +74,18 @@ export default [ | ||||
|                 ...globals.node, | ||||
|             }, | ||||
|         }, | ||||
|         files: ["scripts/**/*.mjs", "*.ts", "*.mjs"], | ||||
|         files: [ | ||||
|             // TODO:Remove after project-wide ESLint config is properly set up. | ||||
|             "scripts/**/*.mjs", | ||||
|             "authentication/**/*.js", | ||||
|             "sfe/**/*.js", | ||||
|             "*.ts", | ||||
|             "*.mjs", | ||||
|         ], | ||||
|         rules: { | ||||
|             "no-undef": "off", | ||||
|             // TODO: TypeScript already handles this. | ||||
|             // Remove after project-wide ESLint config is properly set up. | ||||
|             "no-unused-vars": "off", | ||||
|             // We WANT our scripts to output to the console! | ||||
|             "no-console": "off", | ||||
|  | ||||
							
								
								
									
										2028
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2028
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -12,7 +12,7 @@ | ||||
|         "@floating-ui/dom": "^1.6.11", | ||||
|         "@formatjs/intl-listformat": "^7.5.7", | ||||
|         "@fortawesome/fontawesome-free": "^6.6.0", | ||||
|         "@goauthentik/api": "^2025.2.2-1742585853", | ||||
|         "@goauthentik/api": "^2025.2.3-1743464496", | ||||
|         "@lit-labs/ssr": "^3.2.2", | ||||
|         "@lit/context": "^1.1.2", | ||||
|         "@lit/localize": "^0.12.2", | ||||
| @ -57,9 +57,14 @@ | ||||
|         "ts-pattern": "^5.4.0", | ||||
|         "unist-util-visit": "^5.0.0", | ||||
|         "webcomponent-qr-code": "^1.2.0", | ||||
|         "yaml": "^2.5.1" | ||||
|         "yaml": "^2.5.1", | ||||
|         "bootstrap": "^4.6.1", | ||||
|         "formdata-polyfill": "^4.0.10", | ||||
|         "jquery": "^3.7.1", | ||||
|         "weakmap-polyfill": "^2.0.4" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@types/jquery": "^3.5.31", | ||||
|         "@eslint/js": "^9.11.1", | ||||
|         "@hcaptcha/types": "^1.0.4", | ||||
|         "@lit/localize-tools": "^0.8.0", | ||||
| @ -90,6 +95,8 @@ | ||||
|         "@wdio/spec-reporter": "^9.1.2", | ||||
|         "chromedriver": "^131.0.1", | ||||
|         "esbuild": "^0.25.0", | ||||
|         "esbuild-plugin-copy": "^2.1.1", | ||||
|         "esbuild-plugin-es5": "^2.1.1", | ||||
|         "esbuild-plugin-polyfill-node": "^0.3.0", | ||||
|         "esbuild-plugins-node-modules-polyfill": "^1.7.0", | ||||
|         "eslint": "^9.11.1", | ||||
| @ -161,6 +168,12 @@ | ||||
|         "watch": "run-s build-locales esbuild:watch" | ||||
|     }, | ||||
|     "type": "module", | ||||
|     "exports": { | ||||
|         "./package.json": "./package.json", | ||||
|         "./paths": "./paths.js", | ||||
|         "./authentication": "./authentication/index.js", | ||||
|         "./scripts/*": "./scripts/*.mjs" | ||||
|     }, | ||||
|     "wireit": { | ||||
|         "build": { | ||||
|             "#comment": [ | ||||
| @ -193,8 +206,7 @@ | ||||
|                 "./dist/patternfly.min.css" | ||||
|             ], | ||||
|             "dependencies": [ | ||||
|                 "build-locales", | ||||
|                 "./packages/sfe:build" | ||||
|                 "build-locales" | ||||
|             ], | ||||
|             "env": { | ||||
|                 "NODE_RUNNER": { | ||||
| @ -204,12 +216,7 @@ | ||||
|             } | ||||
|         }, | ||||
|         "build:sfe": { | ||||
|             "dependencies": [ | ||||
|                 "./packages/sfe:build" | ||||
|             ], | ||||
|             "files": [ | ||||
|                 "./packages/sfe/**/*.ts" | ||||
|             ] | ||||
|             "command": "node scripts/build-sfe.mjs" | ||||
|         }, | ||||
|         "build-proxy": { | ||||
|             "command": "node scripts/build-web.mjs --proxy", | ||||
| @ -242,11 +249,6 @@ | ||||
|                 "lint:package" | ||||
|             ] | ||||
|         }, | ||||
|         "format:packages": { | ||||
|             "dependencies": [ | ||||
|                 "./packages/sfe:prettier" | ||||
|             ] | ||||
|         }, | ||||
|         "lint": { | ||||
|             "command": "eslint --max-warnings 0 --fix", | ||||
|             "env": { | ||||
| @ -274,11 +276,6 @@ | ||||
|             "shell": true, | ||||
|             "command": "sh ./scripts/lint-lockfile.sh package-lock.json" | ||||
|         }, | ||||
|         "lint:lockfiles": { | ||||
|             "dependencies": [ | ||||
|                 "./packages/sfe:lint:lockfile" | ||||
|             ] | ||||
|         }, | ||||
|         "lint:package": { | ||||
|             "command": "syncpack format -i '    '" | ||||
|         }, | ||||
| @ -314,9 +311,7 @@ | ||||
|                 "lint:spelling", | ||||
|                 "lint:package", | ||||
|                 "lint:lockfile", | ||||
|                 "lint:lockfiles", | ||||
|                 "lint:precommit", | ||||
|                 "format:packages" | ||||
|                 "lint:precommit" | ||||
|             ] | ||||
|         }, | ||||
|         "prettier": { | ||||
|  | ||||
| @ -1,23 +0,0 @@ | ||||
| { | ||||
|     "arrowParens": "always", | ||||
|     "bracketSpacing": true, | ||||
|     "embeddedLanguageFormatting": "auto", | ||||
|     "htmlWhitespaceSensitivity": "css", | ||||
|     "insertPragma": false, | ||||
|     "jsxSingleQuote": false, | ||||
|     "printWidth": 100, | ||||
|     "proseWrap": "preserve", | ||||
|     "quoteProps": "consistent", | ||||
|     "requirePragma": false, | ||||
|     "semi": true, | ||||
|     "singleQuote": false, | ||||
|     "tabWidth": 4, | ||||
|     "trailingComma": "all", | ||||
|     "useTabs": false, | ||||
|     "vueIndentScriptAndStyle": false, | ||||
|     "plugins": ["@trivago/prettier-plugin-sort-imports"], | ||||
|     "importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"], | ||||
|     "importOrderSeparation": true, | ||||
|     "importOrderSortSpecifiers": true, | ||||
|     "importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"] | ||||
| } | ||||
| @ -1,18 +0,0 @@ | ||||
| The MIT License (MIT) | ||||
|  | ||||
| Copyright (c) 2024 Authentik Security, Inc. | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | ||||
| associated documentation files (the "Software"), to deal in the Software without restriction, | ||||
| including without limitation the rights to use, copy, modify, merge, publish, distribute, | ||||
| sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all copies or substantial | ||||
| portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | ||||
| NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||||
| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES | ||||
| OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN | ||||
| CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||
| @ -1,68 +0,0 @@ | ||||
| { | ||||
|     "name": "@goauthentik/web-sfe", | ||||
|     "version": "0.0.0", | ||||
|     "dependencies": { | ||||
|         "@goauthentik/api": "^2024.6.0-1719577139", | ||||
|         "base64-js": "^1.5.1", | ||||
|         "bootstrap": "^4.6.1", | ||||
|         "formdata-polyfill": "^4.0.10", | ||||
|         "jquery": "^3.7.1", | ||||
|         "weakmap-polyfill": "^2.0.4" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@rollup/plugin-commonjs": "^28.0.0", | ||||
|         "@rollup/plugin-node-resolve": "^15.3.0", | ||||
|         "@rollup/plugin-swc": "^0.4.0", | ||||
|         "@swc/cli": "^0.4.0", | ||||
|         "@swc/core": "^1.7.28", | ||||
|         "@trivago/prettier-plugin-sort-imports": "^4.3.0", | ||||
|         "@types/jquery": "^3.5.31", | ||||
|         "lockfile-lint": "^4.14.0", | ||||
|         "prettier": "^3.3.2", | ||||
|         "rollup": "^4.23.0", | ||||
|         "rollup-plugin-copy": "^3.5.0", | ||||
|         "wireit": "^0.14.9" | ||||
|     }, | ||||
|     "license": "MIT", | ||||
|     "optionalDependencies": { | ||||
|         "@swc/core": "^1.7.28", | ||||
|         "@swc/core-darwin-arm64": "^1.6.13", | ||||
|         "@swc/core-darwin-x64": "^1.6.13", | ||||
|         "@swc/core-linux-arm-gnueabihf": "^1.6.13", | ||||
|         "@swc/core-linux-arm64-gnu": "^1.6.13", | ||||
|         "@swc/core-linux-arm64-musl": "^1.6.13", | ||||
|         "@swc/core-linux-x64-gnu": "^1.6.13", | ||||
|         "@swc/core-linux-x64-musl": "^1.6.13", | ||||
|         "@swc/core-win32-arm64-msvc": "^1.6.13", | ||||
|         "@swc/core-win32-ia32-msvc": "^1.6.13", | ||||
|         "@swc/core-win32-x64-msvc": "^1.6.13" | ||||
|     }, | ||||
|     "private": true, | ||||
|     "scripts": { | ||||
|         "build": "wireit", | ||||
|         "lint:lockfile": "wireit", | ||||
|         "prettier": "prettier --write ./src ./tsconfig.json ./rollup.config.js ./package.json", | ||||
|         "watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs" | ||||
|     }, | ||||
|     "wireit": { | ||||
|         "build:sfe": { | ||||
|             "command": "rollup -c rollup.config.js --bundleConfigAsCjs", | ||||
|             "files": [ | ||||
|                 "../../node_modules/bootstrap/dist/css/bootstrap.min.css", | ||||
|                 "src/index.ts" | ||||
|             ], | ||||
|             "output": [ | ||||
|                 "./dist/sfe/*" | ||||
|             ] | ||||
|         }, | ||||
|         "build": { | ||||
|             "command": "mkdir -p ../../dist/sfe && cp -r dist/sfe/* ../../dist/sfe", | ||||
|             "dependencies": [ | ||||
|                 "build:sfe" | ||||
|             ] | ||||
|         }, | ||||
|         "lint:lockfile": { | ||||
|             "command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https" | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -1,43 +0,0 @@ | ||||
| import commonjs from "@rollup/plugin-commonjs"; | ||||
| import resolve from "@rollup/plugin-node-resolve"; | ||||
| import swc from "@rollup/plugin-swc"; | ||||
| import copy from "rollup-plugin-copy"; | ||||
|  | ||||
| export default { | ||||
|     input: "src/index.ts", | ||||
|     output: { | ||||
|         dir: "./dist/sfe", | ||||
|         format: "cjs", | ||||
|     }, | ||||
|     context: "window", | ||||
|     plugins: [ | ||||
|         copy({ | ||||
|             targets: [ | ||||
|                 { | ||||
|                     src: "../../node_modules/bootstrap/dist/css/bootstrap.min.css", | ||||
|                     dest: "./dist/sfe", | ||||
|                 }, | ||||
|             ], | ||||
|         }), | ||||
|         resolve({ browser: true }), | ||||
|         commonjs(), | ||||
|         swc({ | ||||
|             swc: { | ||||
|                 jsc: { | ||||
|                     loose: false, | ||||
|                     externalHelpers: false, | ||||
|                     // Requires v1.2.50 or upper and requires target to be es2016 or upper. | ||||
|                     keepClassNames: false, | ||||
|                 }, | ||||
|                 minify: false, | ||||
|                 env: { | ||||
|                     targets: { | ||||
|                         edge: "17", | ||||
|                         ie: "11", | ||||
|                     }, | ||||
|                     mode: "entry", | ||||
|                 }, | ||||
|             }, | ||||
|         }), | ||||
|     ], | ||||
| }; | ||||
| @ -1,527 +0,0 @@ | ||||
| import { fromByteArray } from "base64-js"; | ||||
| import "formdata-polyfill"; | ||||
| import $ from "jquery"; | ||||
| import "weakmap-polyfill"; | ||||
|  | ||||
| import { | ||||
|     type AuthenticatorValidationChallenge, | ||||
|     type AutosubmitChallenge, | ||||
|     type ChallengeTypes, | ||||
|     ChallengeTypesFromJSON, | ||||
|     type ContextualFlowInfo, | ||||
|     type DeviceChallenge, | ||||
|     type ErrorDetail, | ||||
|     type IdentificationChallenge, | ||||
|     type PasswordChallenge, | ||||
|     type RedirectChallenge, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| interface GlobalAuthentik { | ||||
|     brand: { | ||||
|         branding_logo: string; | ||||
|     }; | ||||
|     api: { | ||||
|         base: string; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| function ak(): GlobalAuthentik { | ||||
|     return ( | ||||
|         window as unknown as { | ||||
|             authentik: GlobalAuthentik; | ||||
|         } | ||||
|     ).authentik; | ||||
| } | ||||
|  | ||||
| class SimpleFlowExecutor { | ||||
|     challenge?: ChallengeTypes; | ||||
|     flowSlug: string; | ||||
|     container: HTMLDivElement; | ||||
|  | ||||
|     constructor(container: HTMLDivElement) { | ||||
|         this.flowSlug = window.location.pathname.split("/")[3]; | ||||
|         this.container = container; | ||||
|     } | ||||
|  | ||||
|     get apiURL() { | ||||
|         return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`; | ||||
|     } | ||||
|  | ||||
|     start() { | ||||
|         $.ajax({ | ||||
|             type: "GET", | ||||
|             url: this.apiURL, | ||||
|             success: (data) => { | ||||
|                 this.challenge = ChallengeTypesFromJSON(data); | ||||
|                 this.renderChallenge(); | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     submit(data: { [key: string]: unknown } | FormData) { | ||||
|         $("button[type=submit]").addClass("disabled") | ||||
|             .html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> | ||||
|                 <span role="status">Loading...</span>`); | ||||
|         let finalData: { [key: string]: unknown } = {}; | ||||
|         if (data instanceof FormData) { | ||||
|             finalData = {}; | ||||
|             data.forEach((value, key) => { | ||||
|                 finalData[key] = value; | ||||
|             }); | ||||
|         } else { | ||||
|             finalData = data; | ||||
|         } | ||||
|         $.ajax({ | ||||
|             type: "POST", | ||||
|             url: this.apiURL, | ||||
|             data: JSON.stringify(finalData), | ||||
|             success: (data) => { | ||||
|                 this.challenge = ChallengeTypesFromJSON(data); | ||||
|                 this.renderChallenge(); | ||||
|             }, | ||||
|             contentType: "application/json", | ||||
|             dataType: "json", | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     renderChallenge() { | ||||
|         switch (this.challenge?.component) { | ||||
|             case "ak-stage-identification": | ||||
|                 new IdentificationStage(this, this.challenge).render(); | ||||
|                 return; | ||||
|             case "ak-stage-password": | ||||
|                 new PasswordStage(this, this.challenge).render(); | ||||
|                 return; | ||||
|             case "xak-flow-redirect": | ||||
|                 new RedirectStage(this, this.challenge).render(); | ||||
|                 return; | ||||
|             case "ak-stage-autosubmit": | ||||
|                 new AutosubmitStage(this, this.challenge).render(); | ||||
|                 return; | ||||
|             case "ak-stage-authenticator-validate": | ||||
|                 new AuthenticatorValidateStage(this, this.challenge).render(); | ||||
|                 return; | ||||
|             default: | ||||
|                 this.container.innerText = "Unsupported stage: " + this.challenge?.component; | ||||
|                 return; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| export interface FlowInfoChallenge { | ||||
|     flowInfo?: ContextualFlowInfo; | ||||
|     responseErrors?: { | ||||
|         [key: string]: Array<ErrorDetail>; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| class Stage<T extends FlowInfoChallenge> { | ||||
|     constructor( | ||||
|         public executor: SimpleFlowExecutor, | ||||
|         public challenge: T, | ||||
|     ) {} | ||||
|  | ||||
|     error(fieldName: string) { | ||||
|         if (!this.challenge.responseErrors) { | ||||
|             return []; | ||||
|         } | ||||
|         return this.challenge.responseErrors[fieldName] || []; | ||||
|     } | ||||
|  | ||||
|     renderInputError(fieldName: string) { | ||||
|         return `${this.error(fieldName) | ||||
|             .map((error) => { | ||||
|                 return `<div class="invalid-feedback"> | ||||
|                     ${error.string} | ||||
|                 </div>`; | ||||
|             }) | ||||
|             .join("")}`; | ||||
|     } | ||||
|  | ||||
|     renderNonFieldErrors() { | ||||
|         return `${this.error("non_field_errors") | ||||
|             .map((error) => { | ||||
|                 return `<div class="alert alert-danger" role="alert"> | ||||
|                     ${error.string} | ||||
|                 </div>`; | ||||
|             }) | ||||
|             .join("")}`; | ||||
|     } | ||||
|  | ||||
|     html(html: string) { | ||||
|         this.executor.container.innerHTML = html; | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         throw new Error("Abstract method"); | ||||
|     } | ||||
| } | ||||
|  | ||||
| const IS_INVALID = "is-invalid"; | ||||
|  | ||||
| class IdentificationStage extends Stage<IdentificationChallenge> { | ||||
|     render() { | ||||
|         this.html(` | ||||
|             <form id="ident-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${ | ||||
|                     this.challenge.applicationPre | ||||
|                         ? `<p> | ||||
|                               Log in to continue to ${this.challenge.applicationPre}. | ||||
|                           </p>` | ||||
|                         : "" | ||||
|                 } | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username"> | ||||
|                 </div> | ||||
|                 ${ | ||||
|                     this.challenge.passwordFields | ||||
|                         ? `<div class="form-label-group my-3 has-validation"> | ||||
|                                 <input type="password" class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password"> | ||||
|                                 ${this.renderInputError("password")} | ||||
|                         </div>` | ||||
|                         : "" | ||||
|                 } | ||||
|                 ${this.renderNonFieldErrors()} | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button> | ||||
|             </form>`); | ||||
|         $("#ident-form input[name=uid_field]").trigger("focus"); | ||||
|         $("#ident-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|             const data = new FormData(ev.target as HTMLFormElement); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| class PasswordStage extends Stage<PasswordChallenge> { | ||||
|     render() { | ||||
|         this.html(` | ||||
|             <form id="password-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="password" autofocus class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password"> | ||||
|                     ${this.renderInputError("password")} | ||||
|                 </div> | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||
|             </form>`); | ||||
|         $("#password-form input").trigger("focus"); | ||||
|         $("#password-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|             const data = new FormData(ev.target as HTMLFormElement); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| class RedirectStage extends Stage<RedirectChallenge> { | ||||
|     render() { | ||||
|         window.location.assign(this.challenge.to); | ||||
|     } | ||||
| } | ||||
|  | ||||
| class AutosubmitStage extends Stage<AutosubmitChallenge> { | ||||
|     render() { | ||||
|         this.html(` | ||||
|             <form id="autosubmit-form" action="${this.challenge.url}" method="POST"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${Object.entries(this.challenge.attrs).map(([key, value]) => { | ||||
|                     return `<input | ||||
|                             type="hidden" | ||||
|                             name="${key}" | ||||
|                             value="${value}" | ||||
|                         />`; | ||||
|                 })} | ||||
|                 <div class="d-flex justify-content-center"> | ||||
|                     <div class="spinner-border" role="status"> | ||||
|                         <span class="sr-only">Loading...</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </form>`); | ||||
|         $("#autosubmit-form").submit(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export interface Assertion { | ||||
|     id: string; | ||||
|     rawId: string; | ||||
|     type: string; | ||||
|     registrationClientExtensions: string; | ||||
|     response: { | ||||
|         clientDataJSON: string; | ||||
|         attestationObject: string; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| export interface AuthAssertion { | ||||
|     id: string; | ||||
|     rawId: string; | ||||
|     type: string; | ||||
|     assertionClientExtensions: string; | ||||
|     response: { | ||||
|         clientDataJSON: string; | ||||
|         authenticatorData: string; | ||||
|         signature: string; | ||||
|         userHandle: string | null; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> { | ||||
|     deviceChallenge?: DeviceChallenge; | ||||
|  | ||||
|     b64enc(buf: Uint8Array): string { | ||||
|         return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); | ||||
|     } | ||||
|  | ||||
|     b64RawEnc(buf: Uint8Array): string { | ||||
|         return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_"); | ||||
|     } | ||||
|  | ||||
|     u8arr(input: string): Uint8Array { | ||||
|         return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) => | ||||
|             c.charCodeAt(0), | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     checkWebAuthnSupport(): boolean { | ||||
|         if ("credentials" in navigator) { | ||||
|             return true; | ||||
|         } | ||||
|         if (window.location.protocol === "http:" && window.location.hostname !== "localhost") { | ||||
|             console.warn("WebAuthn requires this page to be accessed via HTTPS."); | ||||
|             return false; | ||||
|         } | ||||
|         console.warn("WebAuthn not supported by browser."); | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Transforms items in the credentialCreateOptions generated on the server | ||||
|      * into byte arrays expected by the navigator.credentials.create() call | ||||
|      */ | ||||
|     transformCredentialCreateOptions( | ||||
|         credentialCreateOptions: PublicKeyCredentialCreationOptions, | ||||
|         userId: string, | ||||
|     ): PublicKeyCredentialCreationOptions { | ||||
|         const user = credentialCreateOptions.user; | ||||
|         // Because json can't contain raw bytes, the server base64-encodes the User ID | ||||
|         // So to get the base64 encoded byte array, we first need to convert it to a regular | ||||
|         // string, then a byte array, re-encode it and wrap that in an array. | ||||
|         const stringId = decodeURIComponent(window.atob(userId)); | ||||
|         user.id = this.u8arr(this.b64enc(this.u8arr(stringId))); | ||||
|         const challenge = this.u8arr(credentialCreateOptions.challenge.toString()); | ||||
|  | ||||
|         return Object.assign({}, credentialCreateOptions, { | ||||
|             challenge, | ||||
|             user, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Transforms the binary data in the credential into base64 strings | ||||
|      * for posting to the server. | ||||
|      * @param {PublicKeyCredential} newAssertion | ||||
|      */ | ||||
|     transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion { | ||||
|         const attObj = new Uint8Array( | ||||
|             (newAssertion.response as AuthenticatorAttestationResponse).attestationObject, | ||||
|         ); | ||||
|         const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); | ||||
|         const rawId = new Uint8Array(newAssertion.rawId); | ||||
|  | ||||
|         const registrationClientExtensions = newAssertion.getClientExtensionResults(); | ||||
|         return { | ||||
|             id: newAssertion.id, | ||||
|             rawId: this.b64enc(rawId), | ||||
|             type: newAssertion.type, | ||||
|             registrationClientExtensions: JSON.stringify(registrationClientExtensions), | ||||
|             response: { | ||||
|                 clientDataJSON: this.b64enc(clientDataJSON), | ||||
|                 attestationObject: this.b64enc(attObj), | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     transformCredentialRequestOptions( | ||||
|         credentialRequestOptions: PublicKeyCredentialRequestOptions, | ||||
|     ): PublicKeyCredentialRequestOptions { | ||||
|         const challenge = this.u8arr(credentialRequestOptions.challenge.toString()); | ||||
|  | ||||
|         const allowCredentials = (credentialRequestOptions.allowCredentials || []).map( | ||||
|             (credentialDescriptor) => { | ||||
|                 const id = this.u8arr(credentialDescriptor.id.toString()); | ||||
|                 return Object.assign({}, credentialDescriptor, { id }); | ||||
|             }, | ||||
|         ); | ||||
|  | ||||
|         return Object.assign({}, credentialRequestOptions, { | ||||
|             challenge, | ||||
|             allowCredentials, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Encodes the binary data in the assertion into strings for posting to the server. | ||||
|      * @param {PublicKeyCredential} newAssertion | ||||
|      */ | ||||
|     transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion { | ||||
|         const response = newAssertion.response as AuthenticatorAssertionResponse; | ||||
|         const authData = new Uint8Array(response.authenticatorData); | ||||
|         const clientDataJSON = new Uint8Array(response.clientDataJSON); | ||||
|         const rawId = new Uint8Array(newAssertion.rawId); | ||||
|         const sig = new Uint8Array(response.signature); | ||||
|         const assertionClientExtensions = newAssertion.getClientExtensionResults(); | ||||
|  | ||||
|         return { | ||||
|             id: newAssertion.id, | ||||
|             rawId: this.b64enc(rawId), | ||||
|             type: newAssertion.type, | ||||
|             assertionClientExtensions: JSON.stringify(assertionClientExtensions), | ||||
|  | ||||
|             response: { | ||||
|                 clientDataJSON: this.b64RawEnc(clientDataJSON), | ||||
|                 signature: this.b64RawEnc(sig), | ||||
|                 authenticatorData: this.b64RawEnc(authData), | ||||
|                 userHandle: null, | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         if (!this.deviceChallenge) { | ||||
|             return this.renderChallengePicker(); | ||||
|         } | ||||
|         switch (this.deviceChallenge.deviceClass) { | ||||
|             case "static": | ||||
|             case "totp": | ||||
|                 this.renderCodeInput(); | ||||
|                 break; | ||||
|             case "webauthn": | ||||
|                 this.renderWebauthn(); | ||||
|                 break; | ||||
|             default: | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     renderChallengePicker() { | ||||
|         const challenges = this.challenge.deviceChallenges.filter((challenge) => | ||||
|             challenge.deviceClass === "webauthn" && !this.checkWebAuthnSupport() | ||||
|                 ? undefined | ||||
|                 : challenge, | ||||
|         ); | ||||
|         this.html(`<form id="picker-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${ | ||||
|                     challenges.length > 0 | ||||
|                         ? "<p>Select an authentication method.</p>" | ||||
|                         : ` | ||||
|                     <p>No compatible authentication method available</p> | ||||
|                     ` | ||||
|                 } | ||||
|                 ${challenges | ||||
|                     .map((challenge) => { | ||||
|                         let label = undefined; | ||||
|                         switch (challenge.deviceClass) { | ||||
|                             case "static": | ||||
|                                 label = "Recovery keys"; | ||||
|                                 break; | ||||
|                             case "totp": | ||||
|                                 label = "Traditional authenticator"; | ||||
|                                 break; | ||||
|                             case "webauthn": | ||||
|                                 label = "Security key"; | ||||
|                                 break; | ||||
|                         } | ||||
|                         if (!label) { | ||||
|                             return ""; | ||||
|                         } | ||||
|                         return `<div class="form-label-group my-3 has-validation"> | ||||
|                             <button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button"> | ||||
|                                 ${label} | ||||
|                             </button> | ||||
|                         </div>`; | ||||
|                     }) | ||||
|                     .join("")} | ||||
|             </form>`); | ||||
|         this.challenge.deviceChallenges.forEach((challenge) => { | ||||
|             $(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on( | ||||
|                 "click", | ||||
|                 () => { | ||||
|                     this.deviceChallenge = challenge; | ||||
|                     this.render(); | ||||
|                 }, | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     renderCodeInput() { | ||||
|         this.html(` | ||||
|             <form id="totp-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="text" autofocus class="form-control ${this.error("code").length > 0 ? IS_INVALID : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code"> | ||||
|                     ${this.renderInputError("code")} | ||||
|                 </div> | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||
|             </form>`); | ||||
|         $("#totp-form input").trigger("focus"); | ||||
|         $("#totp-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|             const data = new FormData(ev.target as HTMLFormElement); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     renderWebauthn() { | ||||
|         this.html(` | ||||
|             <form id="totp-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="d-flex justify-content-center"> | ||||
|                     <div class="spinner-border" role="status"> | ||||
|                         <span class="sr-only">Loading...</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </form> | ||||
|             `); | ||||
|         navigator.credentials | ||||
|             .get({ | ||||
|                 publicKey: this.transformCredentialRequestOptions( | ||||
|                     this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions, | ||||
|                 ), | ||||
|             }) | ||||
|             .then((assertion) => { | ||||
|                 if (!assertion) { | ||||
|                     throw new Error("No assertion"); | ||||
|                 } | ||||
|                 try { | ||||
|                     // we now have an authentication assertion! encode the byte arrays contained | ||||
|                     // in the assertion data as strings for posting to the server | ||||
|                     const transformedAssertionForServer = this.transformAssertionForServer( | ||||
|                         assertion as PublicKeyCredential, | ||||
|                     ); | ||||
|  | ||||
|                     // post the assertion to the server for verification. | ||||
|                     this.executor.submit({ | ||||
|                         webauthn: transformedAssertionForServer, | ||||
|                     }); | ||||
|                 } catch (err) { | ||||
|                     throw new Error(`Error when validating assertion on server: ${err}`); | ||||
|                 } | ||||
|             }) | ||||
|             .catch((error) => { | ||||
|                 console.warn(error); | ||||
|                 this.deviceChallenge = undefined; | ||||
|                 this.render(); | ||||
|             }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement); | ||||
| sfe.start(); | ||||
| @ -1,7 +0,0 @@ | ||||
| { | ||||
|     "compilerOptions": { | ||||
|         "types": ["jquery"], | ||||
|         "esModuleInterop": true, | ||||
|         "lib": ["DOM", "ES2015", "ES2017"] | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								web/paths.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/paths.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| /** | ||||
|  * @file Path constants for the web package. | ||||
|  */ | ||||
| import { dirname, resolve } from "node:path"; | ||||
| import { fileURLToPath } from "node:url"; | ||||
|  | ||||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||||
|  | ||||
| /** | ||||
|  * @typedef {'@goauthentik/web'} WebPackageIdentifier | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * The root of the web package. | ||||
|  */ | ||||
| export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(__dirname)); | ||||
|  | ||||
| /** | ||||
|  * Path to the web package's distribution directory. | ||||
|  * | ||||
|  * This is where the built files are located after running the build process. | ||||
|  */ | ||||
| export const DistDirectory = /** @type {`${WebPackageIdentifier}/dist`} */ ( | ||||
|     resolve(__dirname, "dist") | ||||
| ); | ||||
							
								
								
									
										90
									
								
								web/scripts/build-sfe.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								web/scripts/build-sfe.mjs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | ||||
| /** | ||||
|  * @file Build script for the simplified flow executor (SFE). | ||||
|  */ | ||||
| import { DistDirectory, PackageRoot } from "@goauthentik/web/paths"; | ||||
| import esbuild from "esbuild"; | ||||
| import copy from "esbuild-plugin-copy"; | ||||
| import { es5Plugin } from "esbuild-plugin-es5"; | ||||
| import { createRequire } from "node:module"; | ||||
| import * as path from "node:path"; | ||||
|  | ||||
| /** | ||||
|  * Builds the Simplified Flow Executor bundle. | ||||
|  * | ||||
|  * @remarks | ||||
|  * The output directory and file names are referenced by the backend. | ||||
|  * @see {@link ../../authentik/flows/templates/if/flow-sfe.html} | ||||
|  * @returns {Promise<void>} | ||||
|  */ | ||||
| async function buildSFE() { | ||||
|     const require = createRequire(import.meta.url); | ||||
|  | ||||
|     const sourceDirectory = path.join(PackageRoot, "sfe"); | ||||
|  | ||||
|     const entryPoint = path.join(sourceDirectory, "main.js"); | ||||
|     const outDirectory = path.join(DistDirectory, "sfe"); | ||||
|  | ||||
|     const bootstrapCSSPath = require.resolve( | ||||
|         path.join("bootstrap", "dist", "css", "bootstrap.min.css"), | ||||
|     ); | ||||
|  | ||||
|     /** | ||||
|      * @type {esbuild.BuildOptions} | ||||
|      */ | ||||
|     const config = { | ||||
|         tsconfig: path.join(sourceDirectory, "tsconfig.json"), | ||||
|         entryPoints: [entryPoint], | ||||
|         minify: false, | ||||
|         bundle: true, | ||||
|         sourcemap: true, | ||||
|         treeShaking: true, | ||||
|         legalComments: "external", | ||||
|         platform: "browser", | ||||
|         format: "iife", | ||||
|         alias: { | ||||
|             "@swc/helpers": path.dirname(require.resolve("@swc/helpers/package.json")), | ||||
|         }, | ||||
|         banner: { | ||||
|             js: [ | ||||
|                 // --- | ||||
|                 "// Simplified Flow Executor (SFE)", | ||||
|                 `// Bundled on ${new Date().toISOString()}`, | ||||
|                 "// @ts-nocheck", | ||||
|                 "", | ||||
|             ].join("\n"), | ||||
|         }, | ||||
|         plugins: [ | ||||
|             copy({ | ||||
|                 assets: [ | ||||
|                     { | ||||
|                         from: bootstrapCSSPath, | ||||
|                         to: outDirectory, | ||||
|                     }, | ||||
|                 ], | ||||
|             }), | ||||
|             es5Plugin({ | ||||
|                 swc: { | ||||
|                     jsc: { | ||||
|                         loose: false, | ||||
|                         externalHelpers: false, | ||||
|                         keepClassNames: false, | ||||
|                     }, | ||||
|                     minify: false, | ||||
|                 }, | ||||
|             }), | ||||
|         ], | ||||
|         target: ["es5"], | ||||
|         outdir: outDirectory, | ||||
|     }; | ||||
|  | ||||
|     esbuild.build(config); | ||||
| } | ||||
|  | ||||
| buildSFE() | ||||
|     .then(() => { | ||||
|         console.log("Build complete"); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|         console.error("Build failed", error); | ||||
|         process.exit(1); | ||||
|     }); | ||||
| @ -1,3 +1,4 @@ | ||||
| import { DistDirectory, PackageRoot } from "@goauthentik/web/paths"; | ||||
| import { execFileSync } from "child_process"; | ||||
| import { deepmerge } from "deepmerge-ts"; | ||||
| import esbuild from "esbuild"; | ||||
| @ -170,7 +171,7 @@ function composeVersionID() { | ||||
|  * @throws {Error} on build failure | ||||
|  */ | ||||
| function createEntryPointOptions([source, dest], overrides = {}) { | ||||
|     const outdir = path.join(__dirname, "..", "dist", dest); | ||||
|     const outdir = path.join(DistDirectory, dest); | ||||
|  | ||||
|     /** | ||||
|      * @type {esbuild.BuildOptions} | ||||
| @ -233,7 +234,7 @@ async function doWatch() { | ||||
|                         buildObserverPlugin({ | ||||
|                             serverURL, | ||||
|                             logPrefix: entryPoint[1], | ||||
|                             relativeRoot: path.join(__dirname, ".."), | ||||
|                             relativeRoot: PackageRoot, | ||||
|                         }), | ||||
|                     ], | ||||
|                     define: { | ||||
|  | ||||
							
								
								
									
										191
									
								
								web/sfe/lib/AuthenticatorValidateStage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								web/sfe/lib/AuthenticatorValidateStage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,191 @@ | ||||
| /** | ||||
|  * @import { AuthenticatorValidationChallenge, DeviceChallenge } from "@goauthentik/api"; | ||||
|  * @import { FlowExecutor } from './Stage.js'; | ||||
|  */ | ||||
| import { | ||||
|     isWebAuthnSupported, | ||||
|     transformAssertionForServer, | ||||
|     transformCredentialRequestOptions, | ||||
| } from "@goauthentik/web/authentication"; | ||||
| import $ from "jquery"; | ||||
|  | ||||
| import { Stage } from "./Stage.js"; | ||||
| import { ak } from "./utils.js"; | ||||
|  | ||||
| //@ts-check | ||||
|  | ||||
| /** | ||||
|  * @template {AuthenticatorValidationChallenge} T | ||||
|  * @extends {Stage<T>} | ||||
|  */ | ||||
| export class AuthenticatorValidateStage extends Stage { | ||||
|     /** | ||||
|      * @param {FlowExecutor} executor - The executor for this stage | ||||
|      * @param {T} challenge - The challenge for this stage | ||||
|      */ | ||||
|     constructor(executor, challenge) { | ||||
|         super(executor, challenge); | ||||
|  | ||||
|         /** | ||||
|          * @type {DeviceChallenge | null} | ||||
|          */ | ||||
|         this.deviceChallenge = null; | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         if (!this.deviceChallenge) { | ||||
|             this.renderChallengePicker(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         switch (this.deviceChallenge.deviceClass) { | ||||
|             case "static": | ||||
|             case "totp": | ||||
|                 this.renderCodeInput(); | ||||
|                 break; | ||||
|             case "webauthn": | ||||
|                 this.renderWebauthn(); | ||||
|                 break; | ||||
|             default: | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     renderChallengePicker() { | ||||
|         const challenges = this.challenge.deviceChallenges.filter((challenge) => | ||||
|             challenge.deviceClass === "webauthn" && !isWebAuthnSupported() ? undefined : challenge, | ||||
|         ); | ||||
|  | ||||
|         this.html(/* html */ `<form id="picker-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${ | ||||
|                     challenges.length > 0 | ||||
|                         ? /* html */ `<p>Select an authentication method.</p>` | ||||
|                         : /* html */ `<p>No compatible authentication method available</p>` | ||||
|                 } | ||||
|                 ${challenges | ||||
|                     .map((challenge) => { | ||||
|                         let label = undefined; | ||||
|  | ||||
|                         switch (challenge.deviceClass) { | ||||
|                             case "static": | ||||
|                                 label = "Recovery keys"; | ||||
|                                 break; | ||||
|                             case "totp": | ||||
|                                 label = "Traditional authenticator"; | ||||
|                                 break; | ||||
|                             case "webauthn": | ||||
|                                 label = "Security key"; | ||||
|                                 break; | ||||
|                         } | ||||
|  | ||||
|                         if (!label) return ""; | ||||
|  | ||||
|                         return /* html */ `<div class="form-label-group my-3 has-validation"> | ||||
|                             <button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button"> | ||||
|                                 ${label} | ||||
|                             </button> | ||||
|                         </div>`; | ||||
|                     }) | ||||
|                     .join("")} | ||||
|             </form>`); | ||||
|  | ||||
|         this.challenge.deviceChallenges.forEach((challenge) => { | ||||
|             $(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on( | ||||
|                 "click", | ||||
|                 () => { | ||||
|                     this.deviceChallenge = challenge; | ||||
|                     this.render(); | ||||
|                 }, | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     renderCodeInput() { | ||||
|         this.html(/* html */ ` | ||||
|             <form id="totp-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code"> | ||||
|                     ${this.renderInputError("code")} | ||||
|                 </div> | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||
|             </form>`); | ||||
|  | ||||
|         $("#totp-form input").trigger("focus"); | ||||
|  | ||||
|         $("#totp-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|  | ||||
|             const target = /** @type {HTMLFormElement} */ (ev.target); | ||||
|  | ||||
|             const data = new FormData(target); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     renderWebauthn() { | ||||
|         this.html(/* html */ ` | ||||
|             <form id="totp-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="d-flex justify-content-center"> | ||||
|                     <div class="spinner-border" role="status"> | ||||
|                         <span class="sr-only">Loading...</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </form> | ||||
|             `); | ||||
|  | ||||
|         const challenge = /** @type {PublicKeyCredentialRequestOptions} */ ( | ||||
|             this.deviceChallenge?.challenge | ||||
|         ); | ||||
|  | ||||
|         navigator.credentials | ||||
|             .get({ | ||||
|                 publicKey: transformCredentialRequestOptions(challenge), | ||||
|             }) | ||||
|             .then((credential) => { | ||||
|                 if (!credential) { | ||||
|                     throw new Error("No assertion"); | ||||
|                 } | ||||
|  | ||||
|                 if (credential.type !== "public-key") { | ||||
|                     throw new Error("Invalid assertion type"); | ||||
|                 } | ||||
|  | ||||
|                 try { | ||||
|                     // We now have an authentication assertion! | ||||
|                     // Encode the byte arrays contained in the assertion data as strings | ||||
|                     // for posting to the server. | ||||
|                     const transformedAssertionForServer = transformAssertionForServer( | ||||
|                         /** @type {PublicKeyCredential} */ (credential), | ||||
|                     ); | ||||
|  | ||||
|                     // Post the assertion to the server for verification. | ||||
|                     this.executor.submit({ | ||||
|                         webauthn: transformedAssertionForServer, | ||||
|                     }); | ||||
|                 } catch (err) { | ||||
|                     throw new Error(`Error when validating assertion on server: ${err}`); | ||||
|                 } | ||||
|             }) | ||||
|             .catch((error) => { | ||||
|                 console.warn(error); | ||||
|  | ||||
|                 this.deviceChallenge = null; | ||||
|                 this.render(); | ||||
|             }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								web/sfe/lib/AutosubmitStage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/sfe/lib/AutosubmitStage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| /** | ||||
|  * @import { AutosubmitChallenge } from "@goauthentik/api"; | ||||
|  */ | ||||
| import $ from "jquery"; | ||||
|  | ||||
| import { Stage } from "./Stage.js"; | ||||
| import { ak } from "./utils.js"; | ||||
|  | ||||
| /** | ||||
|  * @template {AutosubmitChallenge} T | ||||
|  * @extends {Stage<T>} | ||||
|  */ | ||||
| export class AutosubmitStage extends Stage { | ||||
|     render() { | ||||
|         this.html(/* html */ ` | ||||
|             <form id="autosubmit-form" action="${this.challenge.url}" method="POST"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${Object.entries(this.challenge.attrs).map(([key, value]) => { | ||||
|                     return /* html */ `<input | ||||
|                             type="hidden" | ||||
|                             name="${key}" | ||||
|                             value="${value}" | ||||
|                         />`; | ||||
|                 })} | ||||
|                 <div class="d-flex justify-content-center"> | ||||
|                     <div class="spinner-border" role="status"> | ||||
|                         <span class="sr-only">Loading...</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </form>`); | ||||
|  | ||||
|         $("#autosubmit-form").submit(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										50
									
								
								web/sfe/lib/IdentificationStage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								web/sfe/lib/IdentificationStage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| /** | ||||
|  * @import { IdentificationChallenge } from "@goauthentik/api"; | ||||
|  */ | ||||
| import $ from "jquery"; | ||||
|  | ||||
| import { Stage } from "./Stage.js"; | ||||
| import { ak } from "./utils.js"; | ||||
|  | ||||
| /** | ||||
|  * @template {IdentificationChallenge} T | ||||
|  * @extends {Stage<T>} | ||||
|  */ | ||||
| export class IdentificationStage extends Stage { | ||||
|     render() { | ||||
|         this.html(/* html */ ` | ||||
|             <form id="ident-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 ${ | ||||
|                     this.challenge.applicationPre | ||||
|                         ? /* html */ `<p> | ||||
|                               Log in to continue to ${this.challenge.applicationPre}. | ||||
|                           </p>` | ||||
|                         : "" | ||||
|                 } | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username"> | ||||
|                 </div> | ||||
|                 ${ | ||||
|                     this.challenge.passwordFields | ||||
|                         ? /* html */ `<div class="form-label-group my-3 has-validation"> | ||||
|                                 <input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password"> | ||||
|                                 ${this.renderInputError("password")} | ||||
|                         </div>` | ||||
|                         : "" | ||||
|                 } | ||||
|                 ${this.renderNonFieldErrors()} | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button> | ||||
|             </form>`); | ||||
|  | ||||
|         $("#ident-form input[name=uid_field]").trigger("focus"); | ||||
|         $("#ident-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|             const target = /** @type {HTMLFormElement} */ (ev.target); | ||||
|  | ||||
|             const data = new FormData(target); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										37
									
								
								web/sfe/lib/PasswordStage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								web/sfe/lib/PasswordStage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| /** | ||||
|  * @import { PasswordChallenge } from "@goauthentik/api"; | ||||
|  */ | ||||
| import $ from "jquery"; | ||||
|  | ||||
| import { Stage } from "./Stage.js"; | ||||
| import { ak } from "./utils.js"; | ||||
|  | ||||
| /** | ||||
|  * @template {PasswordChallenge} T | ||||
|  * @extends {Stage<T>} | ||||
|  */ | ||||
| export class PasswordStage extends Stage { | ||||
|     render() { | ||||
|         this.html(/* html */ ` | ||||
|             <form id="password-form"> | ||||
|                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||
|                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||
|                 <div class="form-label-group my-3 has-validation"> | ||||
|                     <input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password"> | ||||
|                     ${this.renderInputError("password")} | ||||
|                 </div> | ||||
|                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||
|             </form>`); | ||||
|  | ||||
|         $("#password-form input").trigger("focus"); | ||||
|  | ||||
|         $("#password-form").on("submit", (ev) => { | ||||
|             ev.preventDefault(); | ||||
|  | ||||
|             const target = /** @type {HTMLFormElement} */ (ev.target); | ||||
|  | ||||
|             const data = new FormData(target); | ||||
|             this.executor.submit(data); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										14
									
								
								web/sfe/lib/RedirectStage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								web/sfe/lib/RedirectStage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| /** | ||||
|  * @import { RedirectChallenge } from "@goauthentik/api"; | ||||
|  */ | ||||
| import { Stage } from "./Stage.js"; | ||||
|  | ||||
| /** | ||||
|  * @template {RedirectChallenge} T | ||||
|  * @extends {Stage<T>} | ||||
|  */ | ||||
| export class RedirectStage extends Stage { | ||||
|     render() { | ||||
|         window.location.assign(this.challenge.to); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										113
									
								
								web/sfe/lib/SimpleFlowExecutor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								web/sfe/lib/SimpleFlowExecutor.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,113 @@ | ||||
| /** | ||||
|  * @import { ChallengeTypes } from "@goauthentik/api"; | ||||
|  * @import { FlowExecutor } from './Stage.js'; | ||||
|  */ | ||||
| import $ from "jquery"; | ||||
|  | ||||
| import { ChallengeTypesFromJSON } from "@goauthentik/api"; | ||||
|  | ||||
| import { AuthenticatorValidateStage } from "./AuthenticatorValidateStage.js"; | ||||
| import { AutosubmitStage } from "./AutosubmitStage.js"; | ||||
| import { IdentificationStage } from "./IdentificationStage.js"; | ||||
| import { PasswordStage } from "./PasswordStage.js"; | ||||
| import { RedirectStage } from "./RedirectStage.js"; | ||||
| import { ak } from "./utils.js"; | ||||
|  | ||||
| /** | ||||
|  * Simple Flow Executor lifecycle. | ||||
|  * | ||||
|  * @implements {FlowExecutor} | ||||
|  */ | ||||
| export class SimpleFlowExecutor { | ||||
|     /** | ||||
|      * | ||||
|      * @param {HTMLDivElement} container | ||||
|      */ | ||||
|     constructor(container) { | ||||
|         /** | ||||
|          * @type {ChallengeTypes | null} The current challenge. | ||||
|          */ | ||||
|         this.challenge = null; | ||||
|         /** | ||||
|          * @type {string} The flow slug. | ||||
|          */ | ||||
|         this.flowSlug = window.location.pathname.split("/")[3] || ""; | ||||
|         /** | ||||
|          * @type {HTMLDivElement} The container element for the flow executor. | ||||
|          */ | ||||
|         this.container = container; | ||||
|     } | ||||
|  | ||||
|     get apiURL() { | ||||
|         return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`; | ||||
|     } | ||||
|  | ||||
|     start() { | ||||
|         $.ajax({ | ||||
|             type: "GET", | ||||
|             url: this.apiURL, | ||||
|             success: (data) => { | ||||
|                 this.challenge = ChallengeTypesFromJSON(data); | ||||
|  | ||||
|                 this.renderChallenge(); | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Submits the form data. | ||||
|      * @param {Record<string, unknown> | FormData} payload | ||||
|      */ | ||||
|     submit(payload) { | ||||
|         $("button[type=submit]").addClass("disabled") | ||||
|             .html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> | ||||
|                 <span role="status">Loading...</span>`); | ||||
|         /** | ||||
|          * @type {Record<string, unknown>} | ||||
|          */ | ||||
|         let finalData; | ||||
|  | ||||
|         if (payload instanceof FormData) { | ||||
|             finalData = {}; | ||||
|  | ||||
|             payload.forEach((value, key) => { | ||||
|                 finalData[key] = value; | ||||
|             }); | ||||
|         } else { | ||||
|             finalData = payload; | ||||
|         } | ||||
|  | ||||
|         $.ajax({ | ||||
|             type: "POST", | ||||
|             url: this.apiURL, | ||||
|             data: JSON.stringify(finalData), | ||||
|             success: (data) => { | ||||
|                 this.challenge = ChallengeTypesFromJSON(data); | ||||
|                 this.renderChallenge(); | ||||
|             }, | ||||
|             contentType: "application/json", | ||||
|             dataType: "json", | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {void} | ||||
|      */ | ||||
|     renderChallenge() { | ||||
|         switch (this.challenge?.component) { | ||||
|             case "ak-stage-identification": | ||||
|                 return new IdentificationStage(this, this.challenge).render(); | ||||
|             case "ak-stage-password": | ||||
|                 return new PasswordStage(this, this.challenge).render(); | ||||
|             case "xak-flow-redirect": | ||||
|                 return new RedirectStage(this, this.challenge).render(); | ||||
|             case "ak-stage-autosubmit": | ||||
|                 return new AutosubmitStage(this, this.challenge).render(); | ||||
|             case "ak-stage-authenticator-validate": | ||||
|                 return new AuthenticatorValidateStage(this, this.challenge).render(); | ||||
|             default: | ||||
|                 this.container.innerText = `Unsupported stage: ${this.challenge?.component}`; | ||||
|                 return; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										116
									
								
								web/sfe/lib/Stage.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								web/sfe/lib/Stage.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,116 @@ | ||||
| /** | ||||
|  * @import { ContextualFlowInfo, ErrorDetail } from "@goauthentik/api"; | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} FlowInfoChallenge | ||||
|  * @property {ContextualFlowInfo} [flowInfo] | ||||
|  * @property {Record<string, Array<ErrorDetail>>} [responseErrors] | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @abstract | ||||
|  */ | ||||
| export class FlowExecutor { | ||||
|     constructor() { | ||||
|         /** | ||||
|          * The DOM container element. | ||||
|          * | ||||
|          * @type {HTMLElement} | ||||
|          * @abstract | ||||
|          * @returns {void} | ||||
|          */ | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-expressions | ||||
|         this.container; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Submits the form data. | ||||
|      * | ||||
|      * @param {Record<string, unknown> | FormData} data The data to submit. | ||||
|      * @abstract | ||||
|      * @returns {void} | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
|     submit(data) { | ||||
|         throw new Error(`Method 'submit' not implemented in ${this.constructor.name}`); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Represents a stage in a flow | ||||
|  * @template {FlowInfoChallenge} T | ||||
|  * @abstract | ||||
|  */ | ||||
| export class Stage { | ||||
|     /** | ||||
|      * @param {FlowExecutor} executor - The executor for this stage | ||||
|      * @param {T} challenge - The challenge for this stage | ||||
|      */ | ||||
|     constructor(executor, challenge) { | ||||
|         /** @type {FlowExecutor} */ | ||||
|         this.executor = executor; | ||||
|  | ||||
|         /** @type {T} */ | ||||
|         this.challenge = challenge; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @protected | ||||
|      * @param {string} fieldName | ||||
|      */ | ||||
|     error(fieldName) { | ||||
|         if (!this.challenge.responseErrors) { | ||||
|             return []; | ||||
|         } | ||||
|         return this.challenge.responseErrors[fieldName] || []; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @protected | ||||
|      * @param {string} fieldName | ||||
|      * @returns {string} | ||||
|      */ | ||||
|     renderInputError(fieldName) { | ||||
|         return `${this.error(fieldName) | ||||
|             .map((error) => { | ||||
|                 return /* html */ `<div class="invalid-feedback"> | ||||
|                     ${error.string} | ||||
|                 </div>`; | ||||
|             }) | ||||
|             .join("")}`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @protected | ||||
|      * @returns {string} | ||||
|      */ | ||||
|     renderNonFieldErrors() { | ||||
|         return `${this.error("non_field_errors") | ||||
|             .map((error) => { | ||||
|                 return /* html */ `<div class="alert alert-danger" role="alert"> | ||||
|                     ${error.string} | ||||
|                 </div>`; | ||||
|             }) | ||||
|             .join("")}`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @protected | ||||
|      * @param {string} innerHTML | ||||
|      * @returns {void} | ||||
|      */ | ||||
|     html(innerHTML) { | ||||
|         this.executor.container.innerHTML = innerHTML; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Renders the stage (must be implemented by subclasses) | ||||
|      * | ||||
|      * @abstract | ||||
|      * @returns {void} | ||||
|      */ | ||||
|     render() { | ||||
|         throw new Error("Abstract method"); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								web/sfe/lib/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/sfe/lib/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| /** | ||||
|  * @file Simplified Flow Executor (SFE) library module. | ||||
|  */ | ||||
|  | ||||
| export * from "./Stage.js"; | ||||
| export * from "./SimpleFlowExecutor.js"; | ||||
| export * from "./AuthenticatorValidateStage.js"; | ||||
| export * from "./AutosubmitStage.js"; | ||||
| export * from "./IdentificationStage.js"; | ||||
| export * from "./PasswordStage.js"; | ||||
| export * from "./RedirectStage.js"; | ||||
| export * from "./utils.js"; | ||||
							
								
								
									
										20
									
								
								web/sfe/lib/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/sfe/lib/utils.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| /** | ||||
|  * @typedef {object} GlobalAuthentik | ||||
|  * @property {object} brand | ||||
|  * @property {string} brand.branding_logo | ||||
|  * @property {object} api | ||||
|  * @property {string} api.base | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Retrieves the global authentik object from the window. | ||||
|  * @throws {Error} If the object not found | ||||
|  * @returns {GlobalAuthentik} | ||||
|  */ | ||||
| export function ak() { | ||||
|     if (!("authentik" in window)) { | ||||
|         throw new Error("No authentik object found in window"); | ||||
|     } | ||||
|  | ||||
|     return /** @type {GlobalAuthentik} */ (window.authentik); | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	