Compare commits
	
		
			117 Commits
		
	
	
		
			server-con
			...
			tests/e2e/
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4b0d641a51 | |||
| 99b559893b | |||
| 8014088c3a | |||
| 3ee353126f | |||
| db76c5d9e2 | |||
| 61bff69b7d | |||
| 69651323e3 | |||
| 75a0ac9588 | |||
| 941a697397 | |||
| 4a74db17a1 | |||
| 0cf6bff93c | |||
| 814e438422 | |||
| 2db77a37dd | |||
| e40c5ac617 | |||
| 7440900dac | |||
| ca96b27825 | |||
| ad4a765a80 | |||
| 4dcd481010 | |||
| d0dc14d84d | |||
| 7bf960352b | |||
| c07d01661b | |||
| 427597ec14 | |||
| 7cc77bd387 | |||
| 381a1a2c49 | |||
| 08f8222224 | |||
| 1211c34a18 | |||
| 22efb57369 | |||
| 3eeda53be6 | |||
| 82ace18703 | |||
| 8589079252 | |||
| ae2af6e58e | |||
| 86a7f98ff6 | |||
| 3af45371d3 | |||
| b01ffd934f | |||
| f11ba94603 | |||
| 7d2aa43364 | |||
| f1351a7577 | |||
| 0611eea0e7 | |||
| d0b46fcf9c | |||
| dcbdc37d31 | |||
| d07f396379 | |||
| 0972103b83 | |||
| b448e76db4 | |||
| f2937bd6dd | |||
| 53c2e3e77c | |||
| 7dd62c1f55 | |||
| 33e3510fba | |||
| 0e5fac2642 | |||
| c53b1fe78a | |||
| 838a7457b2 | |||
| a3c07bc9ff | |||
| 121f2c609d | |||
| 365affc28e | |||
| f367822779 | |||
| 848198125d | |||
| 497ac5e3d0 | |||
| 1773d4d681 | |||
| 4edbb51939 | |||
| c7e97ab48e | |||
| 31f7faae1c | |||
| f5dae2ae92 | |||
| 2c043dba0b | |||
| bda10e5db1 | |||
| be9ae7d4f7 | |||
| b4a6189bfa | |||
| bfdb827ff9 | |||
| 488a58e1c5 | |||
| 3f83e69453 | |||
| e92fa5df0b | |||
| f8c22170df | |||
| e3d08a8434 | |||
| 97d3e9afdc | |||
| 1eb08def73 | |||
| 6e3b379e4a | |||
| 264f59775c | |||
| d048f1ecbd | |||
| eb31f31584 | |||
| fe5c842e92 | |||
| b82d3100c9 | |||
| 49bb668036 | |||
| 52c70c7700 | |||
| b99fd36f86 | |||
| 8a5381eca3 | |||
| 2c77830179 | |||
| ffcd7def60 | |||
| ed121bc2a3 | |||
| d5ab9d9167 | |||
| a983321ad6 | |||
| 9c3420ede4 | |||
| 91b40350aa | |||
| 1912991682 | |||
| 71b9117f53 | |||
| b5f947f460 | |||
| 3a2f7e9549 | |||
| 1582ce0920 | |||
| 6d3eea5266 | |||
| e987208bd1 | |||
| 0efab8eef7 | |||
| 9402dac8ae | |||
| f57a290eee | |||
| 5dab0d2b7a | |||
| 2da6036248 | |||
| cdba94cea4 | |||
| c59eca664a | |||
| d5b205f9c0 | |||
| 8ad9ad833e | |||
| 599ce15f68 | |||
| 91310eff52 | |||
| b522d6732a | |||
| 17d96f204e | |||
| 65e4667bc3 | |||
| f67f9e5ed0 | |||
| 62dd6a4393 | |||
| a46eae8276 | |||
| c4acc9fc24 | |||
| e748a03082 | |||
| e473f28e21 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2025.4.0 | ||||
| current_version = 2025.4.1 | ||||
| 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*))? | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -200,7 +200,7 @@ jobs: | ||||
|         uses: actions/cache@v4 | ||||
|         with: | ||||
|           path: web/dist | ||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} | ||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b | ||||
|       - name: prepare web ui | ||||
|         if: steps.cache-web.outputs.cache-hit != 'true' | ||||
|         working-directory: web | ||||
| @ -208,6 +208,7 @@ jobs: | ||||
|           npm ci | ||||
|           make -C .. gen-client-ts | ||||
|           npm run build | ||||
|           npm run build:sfe | ||||
|       - name: run e2e | ||||
|         run: | | ||||
|           uv run coverage run manage.py test ${{ matrix.job.glob }} | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -29,7 +29,7 @@ jobs: | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|       - name: golangci-lint | ||||
|         uses: golangci/golangci-lint-action@v7 | ||||
|         uses: golangci/golangci-lint-action@v8 | ||||
|         with: | ||||
|           version: latest | ||||
|           args: --timeout 5000s --verbose | ||||
|  | ||||
							
								
								
									
										10
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -40,7 +40,8 @@ COPY ./web /work/web/ | ||||
| COPY ./website /work/website/ | ||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||
|  | ||||
| RUN npm run build | ||||
| RUN npm run build && \ | ||||
|     npm run build:sfe | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder | ||||
| @ -85,18 +86,17 @@ FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip | ||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ||||
| ENV GEOIPUPDATE_VERBOSE="1" | ||||
| ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" | ||||
| ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" | ||||
|  | ||||
| USER root | ||||
| RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||
|     --mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \ | ||||
|     mkdir -p /usr/share/GeoIP && \ | ||||
|     /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||
|     /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||
|  | ||||
| # Stage 5: Download uv | ||||
| FROM ghcr.io/astral-sh/uv:0.7.2 AS uv | ||||
| FROM ghcr.io/astral-sh/uv:0.7.4 AS uv | ||||
| # Stage 6: Base python image | ||||
| FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base | ||||
| FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base | ||||
|  | ||||
| ENV VENV_PATH="/ak-root/.venv" \ | ||||
|     PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ | ||||
|  | ||||
| @ -42,4 +42,4 @@ See [SECURITY.md](SECURITY.md) | ||||
|  | ||||
| ## Adoption and Contributions | ||||
|  | ||||
| Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md). | ||||
| Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [contribution guide](https://docs.goauthentik.io/docs/developer-docs?utm_source=github). | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2025.4.0" | ||||
| __version__ = "2025.4.1" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -54,7 +54,7 @@ def create_component(generator: SchemaGenerator, name, schema, type_=ResolvedCom | ||||
|     return component | ||||
|  | ||||
|  | ||||
| def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):  # noqa: W0613 | ||||
| def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs): | ||||
|     """Workaround to set a default response for endpoints. | ||||
|     Workaround suggested at | ||||
|     <https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357> | ||||
|  | ||||
| @ -164,9 +164,7 @@ class BlueprintEntry: | ||||
|         """Get the blueprint model, with yaml tags resolved if present""" | ||||
|         return str(self.tag_resolver(self.model, blueprint)) | ||||
|  | ||||
|     def get_permissions( | ||||
|         self, blueprint: "Blueprint" | ||||
|     ) -> Generator[BlueprintEntryPermission, None, None]: | ||||
|     def get_permissions(self, blueprint: "Blueprint") -> Generator[BlueprintEntryPermission]: | ||||
|         """Get permissions of this entry, with all yaml tags resolved""" | ||||
|         for perm in self.permissions: | ||||
|             yield BlueprintEntryPermission( | ||||
|  | ||||
| @ -5,10 +5,10 @@ from typing import Any | ||||
| from django.db.models import F, Q | ||||
| from django.db.models import Value as V | ||||
| from django.http.request import HttpRequest | ||||
| from sentry_sdk import get_current_span | ||||
|  | ||||
| from authentik import get_full_version | ||||
| from authentik.brands.models import Brand | ||||
| from authentik.lib.sentry import get_http_meta | ||||
| from authentik.tenants.models import Tenant | ||||
|  | ||||
| _q_default = Q(default=True) | ||||
| @ -32,13 +32,9 @@ def context_processor(request: HttpRequest) -> dict[str, Any]: | ||||
|     """Context Processor that injects brand object into every template""" | ||||
|     brand = getattr(request, "brand", DEFAULT_BRAND) | ||||
|     tenant = getattr(request, "tenant", Tenant()) | ||||
|     trace = "" | ||||
|     span = get_current_span() | ||||
|     if span: | ||||
|         trace = span.to_traceparent() | ||||
|     return { | ||||
|         "brand": brand, | ||||
|         "footer_links": tenant.footer_links, | ||||
|         "sentry_trace": trace, | ||||
|         "html_meta": {**get_http_meta()}, | ||||
|         "version": get_full_version(), | ||||
|     } | ||||
|  | ||||
| @ -99,18 +99,17 @@ class GroupSerializer(ModelSerializer): | ||||
|             if superuser | ||||
|             else "authentik_core.disable_group_superuser" | ||||
|         ) | ||||
|         has_perm = user.has_perm(perm) | ||||
|         if self.instance and not has_perm: | ||||
|             has_perm = user.has_perm(perm, self.instance) | ||||
|         if not has_perm: | ||||
|             raise ValidationError( | ||||
|                 _( | ||||
|                     ( | ||||
|                         "User does not have permission to set " | ||||
|                         "superuser status to {superuser_status}." | ||||
|                     ).format_map({"superuser_status": superuser}) | ||||
|         if self.instance or superuser: | ||||
|             has_perm = user.has_perm(perm) or user.has_perm(perm, self.instance) | ||||
|             if not has_perm: | ||||
|                 raise ValidationError( | ||||
|                     _( | ||||
|                         ( | ||||
|                             "User does not have permission to set " | ||||
|                             "superuser status to {superuser_status}." | ||||
|                         ).format_map({"superuser_status": superuser}) | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         return superuser | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.contrib.auth.management import create_permissions | ||||
| from django.core.management import call_command | ||||
| from django.core.management.base import BaseCommand, no_translations | ||||
| from guardian.management import create_anonymous_user | ||||
|  | ||||
| @ -16,6 +17,10 @@ class Command(BaseCommand): | ||||
|         """Check permissions for all apps""" | ||||
|         for tenant in Tenant.objects.filter(ready=True): | ||||
|             with tenant: | ||||
|                 # See https://code.djangoproject.com/ticket/28417 | ||||
|                 # Remove potential lingering old permissions | ||||
|                 call_command("remove_stale_contenttypes", "--no-input") | ||||
|  | ||||
|                 for app in apps.get_app_configs(): | ||||
|                     self.stdout.write(f"Checking app {app.name} ({app.label})\n") | ||||
|                     create_permissions(app, verbosity=0) | ||||
|  | ||||
| @ -31,7 +31,10 @@ class PickleSerializer: | ||||
|  | ||||
|     def loads(self, data): | ||||
|         """Unpickle data to be loaded from redis""" | ||||
|         return pickle.loads(data)  # nosec | ||||
|         try: | ||||
|             return pickle.loads(data)  # nosec | ||||
|         except Exception: | ||||
|             return {} | ||||
|  | ||||
|  | ||||
| def _migrate_session( | ||||
|  | ||||
| @ -0,0 +1,27 @@ | ||||
| # Generated by Django 5.1.9 on 2025-05-14 11:15 | ||||
|  | ||||
| from django.apps.registry import Apps | ||||
| from django.db import migrations | ||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
|  | ||||
| def remove_old_authenticated_session_content_type( | ||||
|     apps: Apps, schema_editor: BaseDatabaseSchemaEditor | ||||
| ): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     ContentType = apps.get_model("contenttypes", "ContentType") | ||||
|  | ||||
|     ContentType.objects.using(db_alias).filter(model="oldauthenticatedsession").delete() | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0047_delete_oldauthenticatedsession"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             code=remove_old_authenticated_session_content_type, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,14 +1,5 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {{ config_json|json_script:":ak-config:" }} | ||||
|  | ||||
| {{ brand_json|json_script:":ak-brand:" }} | ||||
|  | ||||
| <meta name="ak-version-family" content="{{ version_family }}"> | ||||
| <meta name="ak-version-subdomain" content="{{ version_subdomain }}"> | ||||
| <meta name="ak-build" content="{{ build }}"> | ||||
| <meta name="ak-base-url" content="{{ base_url }}"> | ||||
| <meta name="ak-base-url-rel" content="{{ base_url_rel }}"> | ||||
| {% get_current_language as LANGUAGE_CODE %} | ||||
|  | ||||
| <script> | ||||
|     window.authentik = { | ||||
|  | ||||
| @ -21,7 +21,9 @@ | ||||
|         <script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script> | ||||
|         {% block head %} | ||||
|         {% endblock %} | ||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||
|         {% for key, value in html_meta.items %} | ||||
|         <meta name="{{key}}" content="{{ value }}" /> | ||||
|         {% endfor %} | ||||
|     </head> | ||||
|     <body> | ||||
|         {% block body %} | ||||
|  | ||||
| @ -124,6 +124,16 @@ class TestGroupsAPI(APITestCase): | ||||
|             {"is_superuser": ["User does not have permission to set superuser status to True."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_superuser_no_perm_no_superuser(self): | ||||
|         """Test creating a group without permission and without superuser flag""" | ||||
|         assign_perm("authentik_core.add_group", self.login_user) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:group-list"), | ||||
|             data={"name": generate_id(), "is_superuser": False}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 201) | ||||
|  | ||||
|     def test_superuser_update_no_perm(self): | ||||
|         """Test updating a superuser group without permission""" | ||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| """Interface views""" | ||||
|  | ||||
| from json import dumps | ||||
| from typing import Any | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| @ -45,19 +46,14 @@ class InterfaceView(TemplateView): | ||||
|     """Base interface view""" | ||||
|  | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         """Add common context data to all interface views""" | ||||
|  | ||||
|         kwargs["config_json"] = ConfigView(request=Request(self.request)).get_config().data | ||||
|         kwargs["brand_json"] = CurrentBrandSerializer(self.request.brand).data | ||||
|  | ||||
|         kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data) | ||||
|         kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data) | ||||
|         kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}" | ||||
|         kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" | ||||
|  | ||||
|         kwargs["build"] = get_build_hash() | ||||
|         kwargs["url_kwargs"] = self.kwargs | ||||
|         kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) | ||||
|         kwargs["base_url_rel"] = CONFIG.get("web.path", "/") | ||||
|  | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -132,13 +132,14 @@ class LicenseKey: | ||||
|         """Get a summarized version of all (not expired) licenses""" | ||||
|         total = LicenseKey(get_license_aud(), 0, "Summarized license", 0, 0) | ||||
|         for lic in License.objects.all(): | ||||
|             total.internal_users += lic.internal_users | ||||
|             total.external_users += lic.external_users | ||||
|             if lic.is_valid: | ||||
|                 total.internal_users += lic.internal_users | ||||
|                 total.external_users += lic.external_users | ||||
|                 total.license_flags.extend(lic.status.license_flags) | ||||
|             exp_ts = int(mktime(lic.expiry.timetuple())) | ||||
|             if total.exp == 0: | ||||
|                 total.exp = exp_ts | ||||
|             total.exp = max(total.exp, exp_ts) | ||||
|             total.license_flags.extend(lic.status.license_flags) | ||||
|         return total | ||||
|  | ||||
|     @staticmethod | ||||
|  | ||||
| @ -39,6 +39,10 @@ class License(SerializerModel): | ||||
|     internal_users = models.BigIntegerField() | ||||
|     external_users = models.BigIntegerField() | ||||
|  | ||||
|     @property | ||||
|     def is_valid(self) -> bool: | ||||
|         return self.expiry >= now() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.enterprise.api import LicenseSerializer | ||||
|  | ||||
| @ -8,6 +8,7 @@ from django.test import TestCase | ||||
| from django.utils.timezone import now | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.models import ( | ||||
|     THRESHOLD_READ_ONLY_WEEKS, | ||||
| @ -71,9 +72,9 @@ class TestEnterpriseLicense(TestCase): | ||||
|     ) | ||||
|     def test_valid_multiple(self): | ||||
|         """Check license verification""" | ||||
|         lic = License.objects.create(key=generate_id()) | ||||
|         lic = License.objects.create(key=generate_id(), expiry=expiry_valid) | ||||
|         self.assertTrue(lic.status.status().is_valid) | ||||
|         lic2 = License.objects.create(key=generate_id()) | ||||
|         lic2 = License.objects.create(key=generate_id(), expiry=expiry_valid) | ||||
|         self.assertTrue(lic2.status.status().is_valid) | ||||
|         total = LicenseKey.get_total() | ||||
|         self.assertEqual(total.internal_users, 200) | ||||
| @ -232,7 +233,9 @@ class TestEnterpriseLicense(TestCase): | ||||
|     ) | ||||
|     def test_expiry_expired(self): | ||||
|         """Check license verification""" | ||||
|         License.objects.create(key=generate_id()) | ||||
|         User.objects.all().delete() | ||||
|         License.objects.all().delete() | ||||
|         License.objects.create(key=generate_id(), expiry=expiry_expired) | ||||
|         self.assertEqual(LicenseKey.get_total().summary().status, LicenseUsageStatus.EXPIRED) | ||||
|  | ||||
|     @patch( | ||||
|  | ||||
| @ -57,7 +57,7 @@ class LogEventSerializer(PassiveSerializer): | ||||
|  | ||||
|  | ||||
| @contextmanager | ||||
| def capture_logs(log_default_output=True) -> Generator[list[LogEvent], None, None]: | ||||
| def capture_logs(log_default_output=True) -> Generator[list[LogEvent]]: | ||||
|     """Capture log entries created""" | ||||
|     logs = [] | ||||
|     cap = LogCapture() | ||||
|  | ||||
| @ -15,6 +15,7 @@ | ||||
|         {% endblock %} | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> | ||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||
|         <link rel="prefetch" href="{{ flow_background_url }}" /> | ||||
|         {% include "base/header_js.html" %} | ||||
|         <style> | ||||
|           html, | ||||
| @ -22,7 +23,7 @@ | ||||
|             height: 100%; | ||||
|           } | ||||
|           body { | ||||
|             background-image: url("{{ flow.background_url }}"); | ||||
|             background-image: url("{{ flow_background_url }}"); | ||||
|             background-repeat: no-repeat; | ||||
|             background-size: cover; | ||||
|           } | ||||
|  | ||||
| @ -5,9 +5,9 @@ | ||||
|  | ||||
| {% block head_before %} | ||||
| {{ block.super }} | ||||
| <link rel="prefetch" href="{{ flow.background_url }}" /> | ||||
| <link rel="prefetch" href="{{ flow_background_url }}" /> | ||||
| {% if flow.compatibility_mode and not inspector %} | ||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||
| <script>ShadyDOM = { force: true };</script> | ||||
| {% endif %} | ||||
| {% include "base/header_js.html" %} | ||||
| <script> | ||||
| @ -21,7 +21,7 @@ window.authentik.flow = { | ||||
| <script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script> | ||||
| <style> | ||||
| :root { | ||||
|     --ak-flow-background: url("{{ flow.background_url }}"); | ||||
|     --ak-flow-background: url("{{ flow_background_url }}"); | ||||
| } | ||||
| </style> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -13,7 +13,9 @@ 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 | ||||
|         kwargs["flow_background_url"] = flow.background_url(self.request) | ||||
|         kwargs["inspector"] = "inspector" in self.request.GET | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
| @ -363,6 +363,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict: | ||||
|         pool_options = config.get_dict_from_b64_json("postgresql.pool_options", True) | ||||
|         if not pool_options: | ||||
|             pool_options = True | ||||
|     # FIXME: Temporarily force pool to be deactivated. | ||||
|     # See https://github.com/goauthentik/authentik/issues/14320 | ||||
|     pool_options = False | ||||
|  | ||||
|     db = { | ||||
|         "default": { | ||||
|  | ||||
| @ -17,7 +17,7 @@ from ldap3.core.exceptions import LDAPException | ||||
| from redis.exceptions import ConnectionError as RedisConnectionError | ||||
| from redis.exceptions import RedisError, ResponseError | ||||
| from rest_framework.exceptions import APIException | ||||
| from sentry_sdk import HttpTransport | ||||
| from sentry_sdk import HttpTransport, get_current_scope | ||||
| from sentry_sdk import init as sentry_sdk_init | ||||
| from sentry_sdk.api import set_tag | ||||
| from sentry_sdk.integrations.argv import ArgvIntegration | ||||
| @ -27,6 +27,7 @@ from sentry_sdk.integrations.redis import RedisIntegration | ||||
| from sentry_sdk.integrations.socket import SocketIntegration | ||||
| from sentry_sdk.integrations.stdlib import StdlibIntegration | ||||
| from sentry_sdk.integrations.threading import ThreadingIntegration | ||||
| from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME | ||||
| from structlog.stdlib import get_logger | ||||
| from websockets.exceptions import WebSocketException | ||||
|  | ||||
| @ -95,6 +96,8 @@ def traces_sampler(sampling_context: dict) -> float: | ||||
|         return 0 | ||||
|     if _type == "websocket": | ||||
|         return 0 | ||||
|     if CONFIG.get_bool("debug"): | ||||
|         return 1 | ||||
|     return float(CONFIG.get("error_reporting.sample_rate", 0.1)) | ||||
|  | ||||
|  | ||||
| @ -167,3 +170,14 @@ def before_send(event: dict, hint: dict) -> dict | None: | ||||
|     if settings.DEBUG: | ||||
|         return None | ||||
|     return event | ||||
|  | ||||
|  | ||||
| def get_http_meta(): | ||||
|     """Get sentry-related meta key-values""" | ||||
|     scope = get_current_scope() | ||||
|     meta = { | ||||
|         SENTRY_TRACE_HEADER_NAME: scope.get_traceparent() or "", | ||||
|     } | ||||
|     if bag := scope.get_baggage(): | ||||
|         meta[BAGGAGE_HEADER_NAME] = bag.serialize() | ||||
|     return meta | ||||
|  | ||||
| @ -59,7 +59,7 @@ class PropertyMappingManager: | ||||
|         request: HttpRequest | None, | ||||
|         return_mapping: bool = False, | ||||
|         **kwargs, | ||||
|     ) -> Generator[tuple[dict, PropertyMapping], None]: | ||||
|     ) -> Generator[tuple[dict, PropertyMapping]]: | ||||
|         """Iterate over all mappings that were pre-compiled and | ||||
|         execute all of them with the given context""" | ||||
|         if not self.__has_compiled: | ||||
|  | ||||
| @ -494,86 +494,88 @@ class TestConfig(TestCase): | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_pool(self): | ||||
|         """Test DB Config with pool""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pool", True) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "pool": True, | ||||
|                         "sslcert": None, | ||||
|                         "sslkey": None, | ||||
|                         "sslmode": None, | ||||
|                         "sslrootcert": None, | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     # FIXME: Temporarily force pool to be deactivated. | ||||
|     # See https://github.com/goauthentik/authentik/issues/14320 | ||||
|     # def test_db_pool(self): | ||||
|     #     """Test DB Config with pool""" | ||||
|     #     config = ConfigLoader() | ||||
|     #     config.set("postgresql.host", "foo") | ||||
|     #     config.set("postgresql.name", "foo") | ||||
|     #     config.set("postgresql.user", "foo") | ||||
|     #     config.set("postgresql.password", "foo") | ||||
|     #     config.set("postgresql.port", "foo") | ||||
|     #     config.set("postgresql.test.name", "foo") | ||||
|     #     config.set("postgresql.use_pool", True) | ||||
|     #     conf = django_db_config(config) | ||||
|     #     self.assertEqual( | ||||
|     #         conf, | ||||
|     #         { | ||||
|     #             "default": { | ||||
|     #                 "ENGINE": "authentik.root.db", | ||||
|     #                 "HOST": "foo", | ||||
|     #                 "NAME": "foo", | ||||
|     #                 "OPTIONS": { | ||||
|     #                     "pool": True, | ||||
|     #                     "sslcert": None, | ||||
|     #                     "sslkey": None, | ||||
|     #                     "sslmode": None, | ||||
|     #                     "sslrootcert": None, | ||||
|     #                 }, | ||||
|     #                 "PASSWORD": "foo", | ||||
|     #                 "PORT": "foo", | ||||
|     #                 "TEST": {"NAME": "foo"}, | ||||
|     #                 "USER": "foo", | ||||
|     #                 "CONN_MAX_AGE": 0, | ||||
|     #                 "CONN_HEALTH_CHECKS": False, | ||||
|     #                 "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|     #             } | ||||
|     #         }, | ||||
|     #     ) | ||||
|  | ||||
|     def test_db_pool_options(self): | ||||
|         """Test DB Config with pool""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pool", True) | ||||
|         config.set( | ||||
|             "postgresql.pool_options", | ||||
|             base64.b64encode( | ||||
|                 dumps( | ||||
|                     { | ||||
|                         "max_size": 15, | ||||
|                     } | ||||
|                 ).encode() | ||||
|             ).decode(), | ||||
|         ) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "pool": { | ||||
|                             "max_size": 15, | ||||
|                         }, | ||||
|                         "sslcert": None, | ||||
|                         "sslkey": None, | ||||
|                         "sslmode": None, | ||||
|                         "sslrootcert": None, | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     # def test_db_pool_options(self): | ||||
|     #     """Test DB Config with pool""" | ||||
|     #     config = ConfigLoader() | ||||
|     #     config.set("postgresql.host", "foo") | ||||
|     #     config.set("postgresql.name", "foo") | ||||
|     #     config.set("postgresql.user", "foo") | ||||
|     #     config.set("postgresql.password", "foo") | ||||
|     #     config.set("postgresql.port", "foo") | ||||
|     #     config.set("postgresql.test.name", "foo") | ||||
|     #     config.set("postgresql.use_pool", True) | ||||
|     #     config.set( | ||||
|     #         "postgresql.pool_options", | ||||
|     #         base64.b64encode( | ||||
|     #             dumps( | ||||
|     #                 { | ||||
|     #                     "max_size": 15, | ||||
|     #                 } | ||||
|     #             ).encode() | ||||
|     #         ).decode(), | ||||
|     #     ) | ||||
|     #     conf = django_db_config(config) | ||||
|     #     self.assertEqual( | ||||
|     #         conf, | ||||
|     #         { | ||||
|     #             "default": { | ||||
|     #                 "ENGINE": "authentik.root.db", | ||||
|     #                 "HOST": "foo", | ||||
|     #                 "NAME": "foo", | ||||
|     #                 "OPTIONS": { | ||||
|     #                     "pool": { | ||||
|     #                         "max_size": 15, | ||||
|     #                     }, | ||||
|     #                     "sslcert": None, | ||||
|     #                     "sslkey": None, | ||||
|     #                     "sslmode": None, | ||||
|     #                     "sslrootcert": None, | ||||
|     #                 }, | ||||
|     #                 "PASSWORD": "foo", | ||||
|     #                 "PORT": "foo", | ||||
|     #                 "TEST": {"NAME": "foo"}, | ||||
|     #                 "USER": "foo", | ||||
|     #                 "CONN_MAX_AGE": 0, | ||||
|     #                 "CONN_HEALTH_CHECKS": False, | ||||
|     #                 "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|     #             } | ||||
|     #         }, | ||||
|     #     ) | ||||
|  | ||||
| @ -199,7 +199,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): | ||||
|             chunk_size = len(ops) | ||||
|         if len(ops) < 1: | ||||
|             return | ||||
|         for chunk in batched(ops, chunk_size): | ||||
|         for chunk in batched(ops, chunk_size, strict=False): | ||||
|             req = PatchRequest(Operations=list(chunk)) | ||||
|             self._request( | ||||
|                 "PATCH", | ||||
|  | ||||
| @ -11,7 +11,7 @@ from django.test.runner import DiscoverRunner | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.sentry import sentry_init | ||||
| from authentik.root.signals import post_startup, pre_startup, startup | ||||
| from tests.e2e.utils import get_docker_tag | ||||
| from tests.docker import get_docker_tag | ||||
|  | ||||
| # globally set maxDiff to none to show full assert error | ||||
| TestCase.maxDiff = None | ||||
|  | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -2,7 +2,7 @@ | ||||
|     "$schema": "http://json-schema.org/draft-07/schema", | ||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||
|     "type": "object", | ||||
|     "title": "authentik 2025.4.0 Blueprint schema", | ||||
|     "title": "authentik 2025.4.1 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
|  | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.1} | ||||
|     restart: unless-stopped | ||||
|     command: server | ||||
|     environment: | ||||
| @ -55,7 +55,7 @@ services: | ||||
|       redis: | ||||
|         condition: service_healthy | ||||
|   worker: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.1} | ||||
|     restart: unless-stopped | ||||
|     command: worker | ||||
|     environment: | ||||
|  | ||||
							
								
								
									
										12
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
									
									
									
									
								
							| @ -5,7 +5,7 @@ go 1.24.0 | ||||
| require ( | ||||
| 	beryju.io/ldap v0.1.0 | ||||
| 	github.com/coreos/go-oidc/v3 v3.14.1 | ||||
| 	github.com/getsentry/sentry-go v0.32.0 | ||||
| 	github.com/getsentry/sentry-go v0.33.0 | ||||
| 	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 | ||||
| 	github.com/go-ldap/ldap/v3 v3.4.11 | ||||
| 	github.com/go-openapi/runtime v0.28.0 | ||||
| @ -19,7 +19,7 @@ require ( | ||||
| 	github.com/jellydator/ttlcache/v3 v3.3.0 | ||||
| 	github.com/mitchellh/mapstructure v1.5.0 | ||||
| 	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 | ||||
| 	github.com/pires/go-proxyproto v0.8.0 | ||||
| 	github.com/pires/go-proxyproto v0.8.1 | ||||
| 	github.com/prometheus/client_golang v1.22.0 | ||||
| 	github.com/redis/go-redis/v9 v9.8.0 | ||||
| 	github.com/sethvargo/go-envconfig v1.3.0 | ||||
| @ -27,10 +27,10 @@ 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.2025040.1 | ||||
| 	goauthentik.io/api/v3 v3.2025041.1 | ||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||
| 	golang.org/x/oauth2 v0.29.0 | ||||
| 	golang.org/x/sync v0.13.0 | ||||
| 	golang.org/x/oauth2 v0.30.0 | ||||
| 	golang.org/x/sync v0.14.0 | ||||
| 	gopkg.in/yaml.v2 v2.4.0 | ||||
| 	layeh.com/radius v0.0.0-20210819152912-ad72663a72ab | ||||
| ) | ||||
| @ -75,7 +75,7 @@ require ( | ||||
| 	go.opentelemetry.io/otel/trace v1.24.0 // indirect | ||||
| 	golang.org/x/crypto v0.36.0 // indirect | ||||
| 	golang.org/x/sys v0.31.0 // indirect | ||||
| 	golang.org/x/text v0.23.0 // indirect | ||||
| 	golang.org/x/text v0.24.0 // indirect | ||||
| 	google.golang.org/protobuf v1.36.5 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|  | ||||
							
								
								
									
										28
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								go.sum
									
									
									
									
									
								
							| @ -69,8 +69,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m | ||||
| github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||
| github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= | ||||
| github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||
| github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY= | ||||
| github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= | ||||
| github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= | ||||
| github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||
| github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | ||||
| @ -230,8 +230,8 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+ | ||||
| github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= | ||||
| github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= | ||||
| github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= | ||||
| github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0= | ||||
| github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY= | ||||
| github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= | ||||
| github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= | ||||
| github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| @ -290,8 +290,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.2025040.1 h1:rQEcMNpz84/LPX8LVFteOJuserrd4PnU4k1Iu/wWqhs= | ||||
| goauthentik.io/api/v3 v3.2025040.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8= | ||||
| goauthentik.io/api/v3 v3.2025041.1/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= | ||||
| @ -358,16 +358,16 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ | ||||
| golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= | ||||
| golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= | ||||
| golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= | ||||
| golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= | ||||
| golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= | ||||
| golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= | ||||
| golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= | ||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= | ||||
| golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= | ||||
| golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= | ||||
| golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= | ||||
| golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| @ -376,8 +376,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ | ||||
| golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= | ||||
| golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | ||||
| golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= | ||||
| golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | ||||
| golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| @ -412,8 +412,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= | ||||
| golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= | ||||
| golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= | ||||
| golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= | ||||
| golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
|  | ||||
| @ -29,4 +29,4 @@ func UserAgent() string { | ||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||
| } | ||||
|  | ||||
| const VERSION = "2025.4.0" | ||||
| const VERSION = "2025.4.1" | ||||
|  | ||||
| @ -56,6 +56,7 @@ EXPOSE 3389 6636 9300 | ||||
|  | ||||
| USER 1000 | ||||
|  | ||||
| ENV GOFIPS=1 | ||||
| ENV TMPDIR=/dev/shm/ \ | ||||
|     GOFIPS=1 | ||||
|  | ||||
| ENTRYPOINT ["/ldap"] | ||||
|  | ||||
| @ -97,6 +97,7 @@ elif [[ "$1" == "test-all" ]]; then | ||||
| elif [[ "$1" == "healthcheck" ]]; then | ||||
|     run_authentik healthcheck $(cat $MODE_FILE) | ||||
| elif [[ "$1" == "dump_config" ]]; then | ||||
|     shift | ||||
|     exec python -m authentik.lib.config $@ | ||||
| elif [[ "$1" == "debug" ]]; then | ||||
|     exec sleep infinity | ||||
|  | ||||
							
								
								
									
										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.1013.0", | ||||
|                 "aws-cdk": "^2.1015.0", | ||||
|                 "cross-env": "^7.0.3" | ||||
|             }, | ||||
|             "engines": { | ||||
| @ -17,9 +17,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/aws-cdk": { | ||||
|             "version": "2.1013.0", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1013.0.tgz", | ||||
|             "integrity": "sha512-cbq4cOoEIZueMWenGgfI4RujS+AQ9GaMCTlW/3CnvEIhMD8j/tgZx7PTtgMuvwYrRoEeb/wTxgLPgUd5FhsoHA==", | ||||
|             "version": "2.1015.0", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1015.0.tgz", | ||||
|             "integrity": "sha512-txd+yMVVybtLfiwT409+fahbP0SkiwhmQvQf6PVVYnWzDPSknxYlUNJHisHV4tJEcbHWn1QPsLmqqMT0bw8hBg==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "bin": { | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         "node": ">=20" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "aws-cdk": "^2.1013.0", | ||||
|         "aws-cdk": "^2.1015.0", | ||||
|         "cross-env": "^7.0.3" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -26,7 +26,7 @@ Parameters: | ||||
|     Description: authentik Docker image | ||||
|   AuthentikVersion: | ||||
|     Type: String | ||||
|     Default: 2025.4.0 | ||||
|     Default: 2025.4.1 | ||||
|     Description: authentik Docker image tag | ||||
|   AuthentikServerCPU: | ||||
|     Type: Number | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								locale/pt/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								locale/pt/LC_MESSAGES/django.mo
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										3924
									
								
								locale/pt/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3924
									
								
								locale/pt/LC_MESSAGES/django.po
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,6 +1,6 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2025.4.0", | ||||
|     "version": "2025.4.1", | ||||
|     "private": true, | ||||
|     "type": "module", | ||||
|     "devDependencies": { | ||||
|  | ||||
							
								
								
									
										4
									
								
								packages/docusaurus-config/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								packages/docusaurus-config/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|     "name": "@goauthentik/docusaurus-config", | ||||
|     "version": "1.0.5", | ||||
|     "version": "1.0.6", | ||||
|     "lockfileVersion": 3, | ||||
|     "requires": true, | ||||
|     "packages": { | ||||
|         "": { | ||||
|             "name": "@goauthentik/docusaurus-config", | ||||
|             "version": "1.0.5", | ||||
|             "version": "1.0.6", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "deepmerge-ts": "^7.1.5", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|     "name": "@goauthentik/docusaurus-config", | ||||
|     "version": "1.0.5", | ||||
|     "version": "1.0.6", | ||||
|     "description": "authentik's Docusaurus config", | ||||
|     "license": "MIT", | ||||
|     "scripts": { | ||||
|  | ||||
| @ -76,6 +76,7 @@ EXPOSE 9000 9300 9443 | ||||
|  | ||||
| USER 1000 | ||||
|  | ||||
| ENV GOFIPS=1 | ||||
| ENV TMPDIR=/dev/shm/ \ | ||||
|     GOFIPS=1 | ||||
|  | ||||
| ENTRYPOINT ["/proxy"] | ||||
|  | ||||
							
								
								
									
										200
									
								
								pyproject.toml
									
									
									
									
									
								
							
							
						
						
									
										200
									
								
								pyproject.toml
									
									
									
									
									
								
							| @ -1,104 +1,116 @@ | ||||
| [project] | ||||
| name = "authentik" | ||||
| version = "2025.4.0" | ||||
| version = "2025.4.1" | ||||
| description = "" | ||||
| authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }] | ||||
| requires-python = "==3.12.*" | ||||
| requires-python = "==3.13.*" | ||||
| dependencies = [ | ||||
|     "argon2-cffi", | ||||
|     "celery", | ||||
|     "channels", | ||||
|     "channels-redis", | ||||
|     "cryptography", | ||||
|     "dacite", | ||||
|     "deepmerge", | ||||
|     "defusedxml", | ||||
|     "django", | ||||
|     "django-countries", | ||||
|     "django-cte", | ||||
|     "django-filter", | ||||
|     "django-guardian", | ||||
|     "django-model-utils", | ||||
|     "django-pglock", | ||||
|     "django-prometheus", | ||||
|     "django-redis", | ||||
|     "django-storages[s3]", | ||||
|     "django-tenants", | ||||
|     "djangorestframework", | ||||
|     "djangorestframework-guardian", | ||||
|     "docker", | ||||
|     "drf-orjson-renderer", | ||||
|     "drf-spectacular", | ||||
|     "dumb-init", | ||||
|     "duo-client", | ||||
|     "fido2", | ||||
|     "flower", | ||||
|     "geoip2", | ||||
|     "geopy", | ||||
|     "google-api-python-client", | ||||
|     "gssapi", | ||||
|     "gunicorn", | ||||
|     "jsonpatch", | ||||
|     "jwcrypto", | ||||
|     "kubernetes", | ||||
|     "ldap3", | ||||
|     "lxml", | ||||
|     "msgraph-sdk", | ||||
|     "opencontainers", | ||||
|     "packaging", | ||||
|     "paramiko", | ||||
|     "psycopg[c, pool]", | ||||
|     "pydantic", | ||||
|     "pydantic-scim", | ||||
|     "pyjwt", | ||||
|     "pyrad", | ||||
|     "python-kadmin-rs ==0.6.0", | ||||
|     "pyyaml", | ||||
|     "requests-oauthlib", | ||||
|     "scim2-filter-parser", | ||||
|     "sentry-sdk", | ||||
|     "service_identity", | ||||
|     "setproctitle", | ||||
|     "structlog", | ||||
|     "swagger-spec-validator", | ||||
|     "tenant-schemas-celery", | ||||
|     "twilio", | ||||
|     "ua-parser", | ||||
|     "unidecode", | ||||
|     "urllib3 <3", | ||||
|     "uvicorn[standard]", | ||||
|     "watchdog", | ||||
|     "webauthn", | ||||
|     "wsproto", | ||||
|     "xmlsec <= 1.3.14", | ||||
|     "zxcvbn", | ||||
|     "argon2-cffi==23.1.0", | ||||
|     "celery==5.5.2", | ||||
|     "channels==4.2.2", | ||||
|     "channels-redis==4.2.1", | ||||
|     "cryptography==44.0.3", | ||||
|     "dacite==1.9.2", | ||||
|     "deepmerge==2.0", | ||||
|     "defusedxml==0.7.1", | ||||
|     "django==5.1.9", | ||||
|     "django-countries==7.6.1", | ||||
|     "django-cte==1.3.3", | ||||
|     "django-filter==25.1", | ||||
|     "django-guardian<3.0.0", | ||||
|     "django-model-utils==5.0.0", | ||||
|     "django-pglock==1.7.2", | ||||
|     "django-prometheus==2.3.1", | ||||
|     "django-redis==5.4.0", | ||||
|     "django-storages[s3]==1.14.6", | ||||
|     "django-tenants==3.7.0", | ||||
|     "djangorestframework==3.16.0", | ||||
|     "djangorestframework-guardian==0.3.0", | ||||
|     "docker==7.1.0", | ||||
|     "drf-orjson-renderer==1.7.3", | ||||
|     "drf-spectacular==0.28.0", | ||||
|     "dumb-init==1.2.5.post1", | ||||
|     "duo-client==5.5.0", | ||||
|     "fido2==1.2.0", | ||||
|     "flower==2.0.1", | ||||
|     "geoip2==5.1.0", | ||||
|     "geopy==2.4.1", | ||||
|     "google-api-python-client==2.169.0", | ||||
|     "gssapi==1.9.0", | ||||
|     "gunicorn==23.0.0", | ||||
|     "jsonpatch==1.33", | ||||
|     "jwcrypto==1.5.6", | ||||
|     "kubernetes==32.0.1", | ||||
|     "ldap3==2.9.1", | ||||
|     "lxml==5.4.0", | ||||
|     "msgraph-sdk==1.30.0", | ||||
|     "opencontainers==0.0.14", | ||||
|     "packaging==25.0", | ||||
|     "paramiko==3.5.1", | ||||
|     "psycopg[c,pool]==3.2.9", | ||||
|     "pydantic==2.11.4", | ||||
|     "pydantic-scim==0.0.8", | ||||
|     "pyjwt==2.10.1", | ||||
|     "pyrad==2.4", | ||||
|     "python-kadmin-rs==0.6.0", | ||||
|     "pyyaml==6.0.2", | ||||
|     "requests-oauthlib==2.0.0", | ||||
|     "scim2-filter-parser==0.7.0", | ||||
|     "sentry-sdk==2.28.0", | ||||
|     "service-identity==24.2.0", | ||||
|     "setproctitle==1.3.6", | ||||
|     "structlog==25.3.0", | ||||
|     "swagger-spec-validator==3.0.4", | ||||
|     "tenant-schemas-celery==4.0.1", | ||||
|     "twilio==9.6.1", | ||||
|     "ua-parser==1.0.1", | ||||
|     "unidecode==1.4.0", | ||||
|     "urllib3<3", | ||||
|     "uvicorn[standard]==0.34.2", | ||||
|     "watchdog==6.0.0", | ||||
|     "webauthn==2.5.2", | ||||
|     "wsproto==1.2.0", | ||||
|     "xmlsec==1.3.15", | ||||
|     "zxcvbn==4.5.0", | ||||
| ] | ||||
|  | ||||
| [dependency-groups] | ||||
| dev = [ | ||||
|     "aws-cdk-lib", | ||||
|     "bandit", | ||||
|     "black", | ||||
|     "bump2version", | ||||
|     "channels[daphne]", | ||||
|     "codespell", | ||||
|     "colorama", | ||||
|     "constructs", | ||||
|     "coverage[toml]", | ||||
|     "debugpy", | ||||
|     "drf-jsonschema-serializer", | ||||
|     "freezegun", | ||||
|     "importlib-metadata", | ||||
|     "k5test", | ||||
|     "pdoc", | ||||
|     "pytest", | ||||
|     "pytest-django", | ||||
|     "pytest-github-actions-annotate-failures", | ||||
|     "pytest-randomly", | ||||
|     "pytest-timeout", | ||||
|     "requests-mock", | ||||
|     "ruff", | ||||
|     "selenium", | ||||
|     "aws-cdk-lib==2.188.0", | ||||
|     "bandit==1.8.3", | ||||
|     "black==25.1.0", | ||||
|     "bump2version==1.0.1", | ||||
|     "channels[daphne]==4.2.2", | ||||
|     "codespell==2.4.1", | ||||
|     "colorama==0.4.6", | ||||
|     "constructs==10.4.2", | ||||
|     "coverage[toml]==7.8.0", | ||||
|     "debugpy==1.8.14", | ||||
|     "drf-jsonschema-serializer==3.0.0", | ||||
|     "freezegun==1.5.1", | ||||
|     "importlib-metadata==8.6.1", | ||||
|     "k5test==0.10.4", | ||||
|     "pdoc==15.0.3", | ||||
|     "pytest==8.3.5", | ||||
|     "pytest-django==4.11.1", | ||||
|     "pytest-github-actions-annotate-failures==0.3.0", | ||||
|     "pytest-randomly==3.16.0", | ||||
|     "pytest-timeout==2.4.0", | ||||
|     "requests-mock==1.12.1", | ||||
|     "ruff==0.11.9", | ||||
|     "selenium==4.32.0", | ||||
| ] | ||||
|  | ||||
| [tool.uv] | ||||
| no-binary-package = [ | ||||
|     # This differs from the no-binary packages in the Dockerfile. This is due to the fact | ||||
|     # that these packages are built from source for different reasons than cryptography and kadmin. | ||||
|     # These packages are built from source to link against the libxml2 on the system which is | ||||
|     # required for functionality and to stay up-to-date on both libraries. | ||||
|     # The other packages specified in the dockerfile are compiled from source to link against the | ||||
|     # correct FIPS OpenSSL libraries | ||||
|     "lxml", | ||||
|     "xmlsec", | ||||
| ] | ||||
|  | ||||
| [tool.uv.sources] | ||||
| @ -143,12 +155,12 @@ ignore-words = ".github/codespell-words.txt" | ||||
|  | ||||
| [tool.black] | ||||
| line-length = 100 | ||||
| target-version = ['py312'] | ||||
| target-version = ['py313'] | ||||
| exclude = 'node_modules' | ||||
|  | ||||
| [tool.ruff] | ||||
| line-length = 100 | ||||
| target-version = "py312" | ||||
| target-version = "py313" | ||||
| exclude = ["**/migrations/**", "**/node_modules/**"] | ||||
|  | ||||
| [tool.ruff.lint] | ||||
|  | ||||
| @ -56,6 +56,7 @@ HEALTHCHECK --interval=5s --retries=20 --start-period=3s CMD [ "/rac", "healthch | ||||
|  | ||||
| USER 1000 | ||||
|  | ||||
| ENV GOFIPS=1 | ||||
| ENV TMPDIR=/dev/shm/ \ | ||||
|     GOFIPS=1 | ||||
|  | ||||
| ENTRYPOINT ["/rac"] | ||||
|  | ||||
| @ -56,6 +56,7 @@ EXPOSE 1812/udp 9300 | ||||
|  | ||||
| USER 1000 | ||||
|  | ||||
| ENV GOFIPS=1 | ||||
| ENV TMPDIR=/dev/shm/ \ | ||||
|     GOFIPS=1 | ||||
|  | ||||
| ENTRYPOINT ["/radius"] | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| openapi: 3.0.3 | ||||
| info: | ||||
|   title: authentik | ||||
|   version: 2025.4.0 | ||||
|   version: 2025.4.1 | ||||
|   description: Making authentication simple. | ||||
|   contact: | ||||
|     email: hello@goauthentik.io | ||||
|  | ||||
| @ -0,0 +1,12 @@ | ||||
| import socket | ||||
| from os import environ | ||||
|  | ||||
| IS_CI = "CI" in environ | ||||
| RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1 | ||||
|  | ||||
|  | ||||
| def get_local_ip() -> str: | ||||
|     """Get the local machine's IP""" | ||||
|     hostname = socket.gethostname() | ||||
|     ip_addr = socket.gethostbyname(hostname) | ||||
|     return ip_addr | ||||
|  | ||||
							
								
								
									
										190
									
								
								tests/browser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								tests/browser.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,190 @@ | ||||
| """authentik e2e testing utilities""" | ||||
|  | ||||
| # This file cannot import anything django or anything that will load django | ||||
|  | ||||
| import json | ||||
| from sys import stderr | ||||
| from time import sleep | ||||
| from typing import TYPE_CHECKING | ||||
| from unittest.case import TestCase | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.contrib.staticfiles.testing import StaticLiveServerTestCase | ||||
| from django.urls import reverse | ||||
| from selenium import webdriver | ||||
| from selenium.common.exceptions import WebDriverException | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
| from selenium.webdriver.remote.command import Command | ||||
| from selenium.webdriver.remote.webdriver import WebDriver | ||||
| from selenium.webdriver.remote.webelement import WebElement | ||||
| from selenium.webdriver.support import expected_conditions as ec | ||||
| from selenium.webdriver.support.wait import WebDriverWait | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from tests import IS_CI, RETRIES, get_local_ip | ||||
| from tests.websocket import BaseWebsocketTestCase | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.core.models import User | ||||
|  | ||||
|  | ||||
| class BaseSeleniumTestCase(TestCase): | ||||
|     """Mixin which adds helpers for spinning up Selenium""" | ||||
|  | ||||
|     host = get_local_ip() | ||||
|     wait_timeout: int | ||||
|     user: "User" | ||||
|  | ||||
|     def setUp(self): | ||||
|         if IS_CI: | ||||
|             print("::group::authentik Logs", file=stderr) | ||||
|         from django.apps import apps | ||||
|  | ||||
|         from authentik.core.tests.utils import create_test_admin_user | ||||
|  | ||||
|         apps.get_app_config("authentik_tenants").ready() | ||||
|         self.wait_timeout = 60 | ||||
|         self.driver = self._get_driver() | ||||
|         self.driver.implicitly_wait(30) | ||||
|         self.wait = WebDriverWait(self.driver, self.wait_timeout) | ||||
|         self.logger = get_logger() | ||||
|         self.user = create_test_admin_user() | ||||
|         super().setUp() | ||||
|  | ||||
|     def _get_driver(self) -> WebDriver: | ||||
|         count = 0 | ||||
|         try: | ||||
|             opts = webdriver.ChromeOptions() | ||||
|             opts.add_argument("--disable-search-engine-choice-screen") | ||||
|             return webdriver.Chrome(options=opts) | ||||
|         except WebDriverException: | ||||
|             pass | ||||
|         while count < RETRIES: | ||||
|             try: | ||||
|                 driver = webdriver.Remote( | ||||
|                     command_executor="http://localhost:4444/wd/hub", | ||||
|                     options=webdriver.ChromeOptions(), | ||||
|                 ) | ||||
|                 driver.maximize_window() | ||||
|                 return driver | ||||
|             except WebDriverException: | ||||
|                 count += 1 | ||||
|         raise ValueError(f"Webdriver failed after {RETRIES}.") | ||||
|  | ||||
|     def tearDown(self): | ||||
|         if IS_CI: | ||||
|             print("::endgroup::", file=stderr) | ||||
|         super().tearDown() | ||||
|         if IS_CI: | ||||
|             print("::group::Browser logs") | ||||
|         # Very verbose way to get browser logs | ||||
|         # https://github.com/SeleniumHQ/selenium/pull/15641 | ||||
|         # for some reason this removes the `get_log` API from Remote Webdriver | ||||
|         # and only keeps it on the local Chrome web driver, even when using | ||||
|         # a remote chrome driver...? (nvm the fact this was released as a minor version) | ||||
|         for line in self.driver.execute(Command.GET_LOG, {"type": "browser"})["value"]: | ||||
|             print(line["message"]) | ||||
|         if IS_CI: | ||||
|             print("::endgroup::") | ||||
|         self.driver.quit() | ||||
|  | ||||
|     def wait_for_url(self, desired_url): | ||||
|         """Wait until URL is `desired_url`.""" | ||||
|         self.wait.until( | ||||
|             lambda driver: driver.current_url == desired_url, | ||||
|             f"URL {self.driver.current_url} doesn't match expected URL {desired_url}", | ||||
|         ) | ||||
|  | ||||
|     def url(self, view, query: dict | None = None, **kwargs) -> str: | ||||
|         """reverse `view` with `**kwargs` into full URL using live_server_url""" | ||||
|         url = self.live_server_url + reverse(view, kwargs=kwargs) | ||||
|         if query: | ||||
|             return url + "?" + urlencode(query) | ||||
|         return url | ||||
|  | ||||
|     def if_user_url(self, path: str | None = None) -> str: | ||||
|         """same as self.url() but show URL in shell""" | ||||
|         url = self.url("authentik_core:if-user") | ||||
|         if path: | ||||
|             return f"{url}#{path}" | ||||
|         return url | ||||
|  | ||||
|     def get_shadow_root( | ||||
|         self, selector: str, container: WebElement | WebDriver | None = None | ||||
|     ) -> WebElement: | ||||
|         """Get shadow root element's inner shadowRoot""" | ||||
|         if not container: | ||||
|             container = self.driver | ||||
|         shadow_root = container.find_element(By.CSS_SELECTOR, selector) | ||||
|         element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root) | ||||
|         return element | ||||
|  | ||||
|     def shady_dom(self) -> WebElement: | ||||
|         class wrapper: | ||||
|             def __init__(self, container: WebDriver): | ||||
|                 self.container = container | ||||
|  | ||||
|             def find_element(self, by: str, selector: str) -> WebElement: | ||||
|                 return self.container.execute_script( | ||||
|                     "return document.__shady_native_querySelector(arguments[0])", selector | ||||
|                 ) | ||||
|  | ||||
|         return wrapper(self.driver) | ||||
|  | ||||
|     def login(self, shadow_dom=True): | ||||
|         """Do entire login flow""" | ||||
|  | ||||
|         if shadow_dom: | ||||
|             flow_executor = self.get_shadow_root("ak-flow-executor") | ||||
|             identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor) | ||||
|         else: | ||||
|             flow_executor = self.shady_dom() | ||||
|             identification_stage = self.shady_dom() | ||||
|  | ||||
|         wait = WebDriverWait(identification_stage, self.wait_timeout) | ||||
|         wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=uidField]"))) | ||||
|  | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click() | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys( | ||||
|             Keys.ENTER | ||||
|         ) | ||||
|  | ||||
|         if shadow_dom: | ||||
|             flow_executor = self.get_shadow_root("ak-flow-executor") | ||||
|             password_stage = self.get_shadow_root("ak-stage-password", flow_executor) | ||||
|         else: | ||||
|             flow_executor = self.shady_dom() | ||||
|             password_stage = self.shady_dom() | ||||
|  | ||||
|         wait = WebDriverWait(password_stage, self.wait_timeout) | ||||
|         wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=password]"))) | ||||
|  | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER) | ||||
|         sleep(1) | ||||
|  | ||||
|     def assert_user(self, expected_user: "User"): | ||||
|         """Check users/me API and assert it matches expected_user""" | ||||
|         from authentik.core.api.users import UserSerializer | ||||
|  | ||||
|         self.driver.get(self.url("authentik_api:user-me") + "?format=json") | ||||
|         user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text | ||||
|         user = UserSerializer(data=json.loads(user_json)["user"]) | ||||
|         user.is_valid() | ||||
|         self.assertEqual(user["username"].value, expected_user.username) | ||||
|         self.assertEqual(user["name"].value, expected_user.name) | ||||
|         self.assertEqual(user["email"].value, expected_user.email) | ||||
|  | ||||
|  | ||||
| class SeleniumTestCase(BaseSeleniumTestCase, StaticLiveServerTestCase): | ||||
|     """Test case which spins up a selenium instance and a HTTP-only test server""" | ||||
|  | ||||
|  | ||||
| class WebsocketSeleniumTestCase(BaseSeleniumTestCase, BaseWebsocketTestCase): | ||||
|     """Test case which spins up a selenium instance and a Websocket/HTTP test server""" | ||||
							
								
								
									
										48
									
								
								tests/decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								tests/decorators.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| """authentik e2e testing utilities""" | ||||
|  | ||||
| from collections.abc import Callable | ||||
| from functools import wraps | ||||
|  | ||||
| from django.test.testcases import TransactionTestCase | ||||
| from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from tests import RETRIES | ||||
|  | ||||
|  | ||||
| def retry(max_retires=RETRIES, exceptions=None): | ||||
|     """Retry test multiple times. Default to catching Selenium Timeout Exception""" | ||||
|  | ||||
|     if not exceptions: | ||||
|         exceptions = [WebDriverException, TimeoutException, NoSuchElementException] | ||||
|  | ||||
|     logger = get_logger() | ||||
|  | ||||
|     def retry_actual(func: Callable): | ||||
|         """Retry test multiple times""" | ||||
|         count = 1 | ||||
|  | ||||
|         @wraps(func) | ||||
|         def wrapper(self: TransactionTestCase, *args, **kwargs): | ||||
|             """Run test again if we're below max_retries, including tearDown and | ||||
|             setUp. Otherwise raise the error""" | ||||
|             nonlocal count | ||||
|             try: | ||||
|                 return func(self, *args, **kwargs) | ||||
|  | ||||
|             except tuple(exceptions) as exc: | ||||
|                 count += 1 | ||||
|                 if count > max_retires: | ||||
|                     logger.debug("Exceeded retry count", exc=exc, test=self) | ||||
|  | ||||
|                     raise exc | ||||
|                 logger.debug("Retrying on error", exc=exc, test=self) | ||||
|                 self.tearDown() | ||||
|                 self._post_teardown() | ||||
|                 self._pre_setup() | ||||
|                 self.setUp() | ||||
|                 return wrapper(self, *args, **kwargs) | ||||
|  | ||||
|         return wrapper | ||||
|  | ||||
|     return retry_actual | ||||
							
								
								
									
										139
									
								
								tests/docker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								tests/docker.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | ||||
| """Docker testing helpers""" | ||||
|  | ||||
| import os | ||||
| from time import sleep | ||||
| from typing import TYPE_CHECKING, Any | ||||
| from unittest.case import TestCase | ||||
|  | ||||
| from docker import DockerClient, from_env | ||||
| from docker.errors import DockerException | ||||
| from docker.models.containers import Container | ||||
| from docker.models.networks import Network | ||||
|  | ||||
| from authentik.lib.generators import generate_id | ||||
| from tests import IS_CI | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.outposts.models import Outpost | ||||
|  | ||||
|  | ||||
| def get_docker_tag() -> str: | ||||
|     """Get docker-tag based off of CI variables""" | ||||
|     env_pr_branch = "GITHUB_HEAD_REF" | ||||
|     default_branch = "GITHUB_REF" | ||||
|     branch_name = os.environ.get(default_branch, "main") | ||||
|     if os.environ.get(env_pr_branch, "") != "": | ||||
|         branch_name = os.environ[env_pr_branch] | ||||
|     branch_name = branch_name.replace("refs/heads/", "").replace("/", "-") | ||||
|     return f"gh-{branch_name}" | ||||
|  | ||||
|  | ||||
| class DockerTestCase(TestCase): | ||||
|     """Mixin for dealing with containers""" | ||||
|  | ||||
|     max_healthcheck_attempts = 30 | ||||
|  | ||||
|     __client: DockerClient | ||||
|     __network: Network | ||||
|  | ||||
|     __label_id = generate_id() | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.__client = from_env() | ||||
|         self.__network = self.docker_client.networks.create( | ||||
|             name=f"authentik-test-{self.__label_id}" | ||||
|         ) | ||||
|         super().setUp() | ||||
|  | ||||
|     @property | ||||
|     def docker_client(self) -> DockerClient: | ||||
|         return self.__client | ||||
|  | ||||
|     @property | ||||
|     def docker_network(self) -> Network: | ||||
|         return self.__network | ||||
|  | ||||
|     @property | ||||
|     def docker_labels(self) -> dict: | ||||
|         return {"io.goauthentik.test": self.__label_id} | ||||
|  | ||||
|     def get_container_image(self, base: str) -> str: | ||||
|         """Try to pull docker image based on git branch, fallback to main if not found.""" | ||||
|         image = f"{base}:gh-main" | ||||
|         if not IS_CI: | ||||
|             return image | ||||
|         try: | ||||
|             branch_image = f"{base}:{get_docker_tag()}" | ||||
|             self.docker_client.images.pull(branch_image) | ||||
|             return branch_image | ||||
|         except DockerException: | ||||
|             self.docker_client.images.pull(image) | ||||
|         return image | ||||
|  | ||||
|     def run_container(self, **specs: dict[str, Any]) -> Container: | ||||
|         if "network_mode" not in specs: | ||||
|             specs["network"] = self.__network.name | ||||
|         specs["labels"] = self.docker_labels | ||||
|         specs["detach"] = True | ||||
|         if hasattr(self, "live_server_url"): | ||||
|             specs.setdefault("environment", {}) | ||||
|             specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url | ||||
|         container = self.docker_client.containers.run(**specs) | ||||
|         container.reload() | ||||
|         state = container.attrs.get("State", {}) | ||||
|         if "Health" not in state: | ||||
|             return container | ||||
|         self.wait_for_container(container) | ||||
|         return container | ||||
|  | ||||
|     def output_container_logs(self, container: Container | None = None): | ||||
|         """Output the container logs to our STDOUT""" | ||||
|         if IS_CI: | ||||
|             image = container.image | ||||
|             tags = image.tags[0] if len(image.tags) > 0 else str(image) | ||||
|             print(f"::group::Container logs - {tags}") | ||||
|         for log in container.logs().decode().split("\n"): | ||||
|             print(log) | ||||
|         if IS_CI: | ||||
|             print("::endgroup::") | ||||
|  | ||||
|     def tearDown(self): | ||||
|         containers: list[Container] = self.docker_client.containers.list( | ||||
|             filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())} | ||||
|         ) | ||||
|         for container in containers: | ||||
|             self.output_container_logs(container) | ||||
|             try: | ||||
|                 container.stop() | ||||
|             except DockerException: | ||||
|                 pass | ||||
|             try: | ||||
|                 container.remove(force=True) | ||||
|             except DockerException: | ||||
|                 pass | ||||
|         self.__network.remove() | ||||
|         super().tearDown() | ||||
|  | ||||
|     def wait_for_container(self, container: Container): | ||||
|         """Check that container is health""" | ||||
|         attempt = 0 | ||||
|         while attempt < self.max_healthcheck_attempts: | ||||
|             container.reload() | ||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") | ||||
|             if status == "healthy": | ||||
|                 return container | ||||
|             attempt += 1 | ||||
|             sleep(0.5) | ||||
|         self.failureException("Container failed to start") | ||||
|  | ||||
|     def wait_for_outpost(self, outpost: "Outpost"): | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         attempt = 0 | ||||
|         while attempt < self.max_healthcheck_attempts: | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen: | ||||
|                     return | ||||
|             attempt += 1 | ||||
|             sleep(0.5) | ||||
|         self.failureException("Outpost failed to become healthy") | ||||
| @ -1,12 +1,12 @@ | ||||
| services: | ||||
|   chrome: | ||||
|     image: docker.io/selenium/standalone-chrome:122.0 | ||||
|     image: docker.io/selenium/standalone-chrome:136.0 | ||||
|     volumes: | ||||
|       - /dev/shm:/dev/shm | ||||
|     network_mode: host | ||||
|     restart: always | ||||
|   mailpit: | ||||
|     image: docker.io/axllent/mailpit:v1.6.5 | ||||
|     image: docker.io/axllent/mailpit:v1.24.2 | ||||
|     ports: | ||||
|       - 1025:1025 | ||||
|       - 8025:8025 | ||||
|  | ||||
| @ -18,10 +18,12 @@ from authentik.stages.authenticator_static.models import ( | ||||
|     StaticToken, | ||||
| ) | ||||
| from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsAuthenticator(SeleniumTestCase): | ||||
| class TestFlowsAuthenticator(DockerTestCase, SeleniumTestCase): | ||||
|     """test flow with otp stages""" | ||||
|  | ||||
|     @retry() | ||||
|  | ||||
| @ -11,10 +11,12 @@ from authentik.core.models import User | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.stages.identification.models import IdentificationStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsEnroll(SeleniumTestCase): | ||||
| class TestFlowsEnroll(DockerTestCase, SeleniumTestCase): | ||||
|     """Test Enroll flow""" | ||||
|  | ||||
|     @retry() | ||||
|  | ||||
| @ -1,12 +1,21 @@ | ||||
| """test default login flow""" | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from authentik.flows.models import Flow | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsLogin(SeleniumTestCase): | ||||
| class TestFlowsLogin(DockerTestCase, SeleniumTestCase): | ||||
|     """test default login flow""" | ||||
|  | ||||
|     def tearDown(self): | ||||
|         # Reset authentication flow's compatibility mode; we need to do this as its | ||||
|         # not specified in the blueprint | ||||
|         Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=False) | ||||
|         return super().tearDown() | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
| @ -23,3 +32,21 @@ class TestFlowsLogin(SeleniumTestCase): | ||||
|         self.login() | ||||
|         self.wait_for_url(self.if_user_url("/library")) | ||||
|         self.assert_user(self.user) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
|         "default/flow-default-invalidation-flow.yaml", | ||||
|     ) | ||||
|     def test_login_compatibility_mode(self): | ||||
|         """test default login flow with compatibility mode enabled""" | ||||
|         Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=True) | ||||
|         self.driver.get( | ||||
|             self.url( | ||||
|                 "authentik_core:if-flow", | ||||
|                 flow_slug="default-authentication-flow", | ||||
|             ) | ||||
|         ) | ||||
|         self.login(shadow_dom=False) | ||||
|         self.wait_for_url(self.if_user_url("/library")) | ||||
|         self.assert_user(self.user) | ||||
|  | ||||
							
								
								
									
										53
									
								
								tests/e2e/test_flows_login_sfe.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								tests/e2e/test_flows_login_sfe.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| """test default login (using SFE interface) flow""" | ||||
|  | ||||
| from time import sleep | ||||
|  | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsLoginSFE(DockerTestCase, SeleniumTestCase): | ||||
|     """test default login flow""" | ||||
|  | ||||
|     def login(self): | ||||
|         """Do entire login flow adjusted for SFE""" | ||||
|         flow_executor = self.driver.find_element(By.ID, "flow-sfe-container") | ||||
|         identification_stage = flow_executor.find_element(By.ID, "ident-form") | ||||
|  | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").click() | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( | ||||
|             Keys.ENTER | ||||
|         ) | ||||
|  | ||||
|         password_stage = flow_executor.find_element(By.ID, "password-form") | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER) | ||||
|         sleep(1) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
|         "default/flow-default-invalidation-flow.yaml", | ||||
|     ) | ||||
|     def test_login(self): | ||||
|         """test default login flow""" | ||||
|         self.driver.get( | ||||
|             self.url( | ||||
|                 "authentik_core:if-flow", | ||||
|                 flow_slug="default-authentication-flow", | ||||
|                 query={"sfe": True}, | ||||
|             ) | ||||
|         ) | ||||
|         self.login() | ||||
|         self.wait_for_url(self.if_user_url("/library")) | ||||
|         self.assert_user(self.user) | ||||
| @ -13,10 +13,12 @@ from authentik.flows.models import Flow | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.stages.identification.models import IdentificationStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsRecovery(SeleniumTestCase): | ||||
| class TestFlowsRecovery(DockerTestCase, SeleniumTestCase): | ||||
|     """Test Recovery flow""" | ||||
|  | ||||
|     def initial_stages(self, user: User): | ||||
|  | ||||
| @ -8,10 +8,12 @@ from authentik.core.models import User | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.lib.generators import generate_key | ||||
| from authentik.stages.password.models import PasswordStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestFlowsStageSetup(SeleniumTestCase): | ||||
| class TestFlowsStageSetup(DockerTestCase, SeleniumTestCase): | ||||
|     """test stage setup flows""" | ||||
|  | ||||
|     @retry() | ||||
|  | ||||
| @ -16,10 +16,12 @@ from authentik.lib.generators import generate_id | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST | ||||
| from authentik.outposts.models import Outpost, OutpostConfig, OutpostType | ||||
| from authentik.providers.ldap.models import APIAccessMode, LDAPProvider | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
| from tests.websocket import WebsocketTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderLDAP(SeleniumTestCase): | ||||
| class TestProviderLDAP(DockerTestCase, WebsocketTestCase): | ||||
|     """LDAP and Outpost e2e tests""" | ||||
|  | ||||
|     def start_ldap(self, outpost: Outpost): | ||||
|  | ||||
| @ -18,10 +18,12 @@ from authentik.providers.oauth2.models import ( | ||||
|     RedirectURI, | ||||
|     RedirectURIMatchingMode, | ||||
| ) | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderOAuth2Github(SeleniumTestCase): | ||||
| class TestProviderOAuth2Github(DockerTestCase, SeleniumTestCase): | ||||
|     """test OAuth Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -26,10 +26,12 @@ from authentik.providers.oauth2.models import ( | ||||
|     RedirectURIMatchingMode, | ||||
|     ScopeMapping, | ||||
| ) | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderOAuth2OAuth(SeleniumTestCase): | ||||
| class TestProviderOAuth2OAuth(DockerTestCase, SeleniumTestCase): | ||||
|     """test OAuth with OAuth Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -26,10 +26,12 @@ from authentik.providers.oauth2.models import ( | ||||
|     RedirectURIMatchingMode, | ||||
|     ScopeMapping, | ||||
| ) | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||
| class TestProviderOAuth2OIDC(DockerTestCase, SeleniumTestCase): | ||||
|     """test OAuth with OpenID Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -26,10 +26,12 @@ from authentik.providers.oauth2.models import ( | ||||
|     RedirectURIMatchingMode, | ||||
|     ScopeMapping, | ||||
| ) | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): | ||||
| class TestProviderOAuth2OIDCImplicit(DockerTestCase, SeleniumTestCase): | ||||
|     """test OAuth with OpenID Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -3,11 +3,8 @@ | ||||
| from base64 import b64encode | ||||
| from dataclasses import asdict | ||||
| from json import loads | ||||
| from sys import platform | ||||
| from time import sleep | ||||
| from unittest.case import skip, skipUnless | ||||
|  | ||||
| from channels.testing import ChannelsLiveServerTestCase | ||||
| from jwt import decode | ||||
| from selenium.webdriver.common.by import By | ||||
|  | ||||
| @ -18,10 +15,13 @@ from authentik.lib.generators import generate_id | ||||
| from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType | ||||
| from authentik.outposts.tasks import outpost_connection_discovery | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
| from tests.websocket import WebsocketTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderProxy(SeleniumTestCase): | ||||
| class TestProviderProxy(DockerTestCase, SeleniumTestCase): | ||||
|     """Proxy and Outpost e2e tests""" | ||||
|  | ||||
|     def setUp(self): | ||||
| @ -37,13 +37,41 @@ class TestProviderProxy(SeleniumTestCase): | ||||
|         """Start proxy container based on outpost created""" | ||||
|         self.run_container( | ||||
|             image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"), | ||||
|             ports={ | ||||
|                 "9000": "9000", | ||||
|             }, | ||||
|             environment={ | ||||
|                 "AUTHENTIK_TOKEN": outpost.token.key, | ||||
|             }, | ||||
|             ports={"9000": "9000"}, | ||||
|             environment={"AUTHENTIK_TOKEN": outpost.token.key}, | ||||
|         ) | ||||
|         self.wait_for_outpost(outpost) | ||||
|  | ||||
|     def _prepare(self): | ||||
|         # set additionalHeaders to test later | ||||
|         self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"} | ||||
|         self.user.save() | ||||
|  | ||||
|         proxy: ProxyProvider = ProxyProvider.objects.create( | ||||
|             name=generate_id(), | ||||
|             authorization_flow=Flow.objects.get( | ||||
|                 slug="default-provider-authorization-implicit-consent" | ||||
|             ), | ||||
|             invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"), | ||||
|             internal_host=f"http://{self.host}", | ||||
|             external_host="http://localhost:9000", | ||||
|             basic_auth_enabled=True, | ||||
|             basic_auth_user_attribute="basic-username", | ||||
|             basic_auth_password_attribute="basic-password",  # nosec | ||||
|         ) | ||||
|         # Ensure OAuth2 Params are set | ||||
|         proxy.set_oauth_defaults() | ||||
|         proxy.save() | ||||
|         # we need to create an application to actually access the proxy | ||||
|         Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy) | ||||
|         outpost: Outpost = Outpost.objects.create( | ||||
|             name=generate_id(), | ||||
|             type=OutpostType.PROXY, | ||||
|         ) | ||||
|         outpost.providers.add(proxy) | ||||
|         outpost.build_user_permissions(outpost.user) | ||||
|  | ||||
|         self.start_proxy(outpost) | ||||
|  | ||||
|     @retry() | ||||
|     @apply_blueprint( | ||||
| @ -61,44 +89,7 @@ class TestProviderProxy(SeleniumTestCase): | ||||
|     @reconcile_app("authentik_crypto") | ||||
|     def test_proxy_simple(self): | ||||
|         """Test simple outpost setup with single provider""" | ||||
|         # set additionalHeaders to test later | ||||
|         self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"} | ||||
|         self.user.save() | ||||
|  | ||||
|         proxy: ProxyProvider = ProxyProvider.objects.create( | ||||
|             name=generate_id(), | ||||
|             authorization_flow=Flow.objects.get( | ||||
|                 slug="default-provider-authorization-implicit-consent" | ||||
|             ), | ||||
|             invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"), | ||||
|             internal_host=f"http://{self.host}", | ||||
|             external_host="http://localhost:9000", | ||||
|         ) | ||||
|         # Ensure OAuth2 Params are set | ||||
|         proxy.set_oauth_defaults() | ||||
|         proxy.save() | ||||
|         # we need to create an application to actually access the proxy | ||||
|         Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy) | ||||
|         outpost: Outpost = Outpost.objects.create( | ||||
|             name=generate_id(), | ||||
|             type=OutpostType.PROXY, | ||||
|         ) | ||||
|         outpost.providers.add(proxy) | ||||
|         outpost.build_user_permissions(outpost.user) | ||||
|  | ||||
|         self.start_proxy(outpost) | ||||
|  | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         healthcheck_retries = 0 | ||||
|         while healthcheck_retries < 50:  # noqa: PLR2004 | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen: | ||||
|                     break | ||||
|             healthcheck_retries += 1 | ||||
|             sleep(0.5) | ||||
|         sleep(5) | ||||
|  | ||||
|         self._prepare() | ||||
|         self.driver.get("http://localhost:9000/api") | ||||
|         self.login() | ||||
|         sleep(1) | ||||
| @ -137,49 +128,13 @@ class TestProviderProxy(SeleniumTestCase): | ||||
|     @reconcile_app("authentik_crypto") | ||||
|     def test_proxy_basic_auth(self): | ||||
|         """Test simple outpost setup with single provider""" | ||||
|         self._prepare() | ||||
|         # Setup basic auth | ||||
|         cred = generate_id() | ||||
|         attr = "basic-password"  # nosec | ||||
|         self.user.attributes["basic-username"] = cred | ||||
|         self.user.attributes[attr] = cred | ||||
|         self.user.attributes["basic-password"] = cred | ||||
|         self.user.save() | ||||
|  | ||||
|         proxy: ProxyProvider = ProxyProvider.objects.create( | ||||
|             name=generate_id(), | ||||
|             authorization_flow=Flow.objects.get( | ||||
|                 slug="default-provider-authorization-implicit-consent" | ||||
|             ), | ||||
|             invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"), | ||||
|             internal_host=f"http://{self.host}", | ||||
|             external_host="http://localhost:9000", | ||||
|             basic_auth_enabled=True, | ||||
|             basic_auth_user_attribute="basic-username", | ||||
|             basic_auth_password_attribute=attr, | ||||
|         ) | ||||
|         # Ensure OAuth2 Params are set | ||||
|         proxy.set_oauth_defaults() | ||||
|         proxy.save() | ||||
|         # we need to create an application to actually access the proxy | ||||
|         Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy) | ||||
|         outpost: Outpost = Outpost.objects.create( | ||||
|             name=generate_id(), | ||||
|             type=OutpostType.PROXY, | ||||
|         ) | ||||
|         outpost.providers.add(proxy) | ||||
|         outpost.build_user_permissions(outpost.user) | ||||
|  | ||||
|         self.start_proxy(outpost) | ||||
|  | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         healthcheck_retries = 0 | ||||
|         while healthcheck_retries < 50:  # noqa: PLR2004 | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen: | ||||
|                     break | ||||
|             healthcheck_retries += 1 | ||||
|             sleep(0.5) | ||||
|         sleep(5) | ||||
|  | ||||
|         self.driver.get("http://localhost:9000/api") | ||||
|         self.login() | ||||
|         sleep(1) | ||||
| @ -187,9 +142,9 @@ class TestProviderProxy(SeleniumTestCase): | ||||
|         full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text | ||||
|         body = loads(full_body_text) | ||||
|  | ||||
|         self.assertEqual(body["headers"]["X-Authentik-Username"], [self.user.username]) | ||||
|         self.assertEqual(body.get("headers").get("X-Authentik-Username"), [self.user.username]) | ||||
|         auth_header = b64encode(f"{cred}:{cred}".encode()).decode() | ||||
|         self.assertEqual(body["headers"]["Authorization"], [f"Basic {auth_header}"]) | ||||
|         self.assertEqual(body.get("headers").get("Authorization"), [f"Basic {auth_header}"]) | ||||
|  | ||||
|         self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out") | ||||
|         sleep(2) | ||||
| @ -199,10 +154,7 @@ class TestProviderProxy(SeleniumTestCase): | ||||
|         self.assertIn("You've logged out of", title) | ||||
|  | ||||
|  | ||||
| # TODO: Fix flaky test | ||||
| @skip("Flaky test") | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestProviderProxyConnect(ChannelsLiveServerTestCase): | ||||
| class TestProviderProxyConnect(DockerTestCase, WebsocketTestCase): | ||||
|     """Test Proxy connectivity over websockets""" | ||||
|  | ||||
|     @retry(exceptions=[AssertionError]) | ||||
| @ -241,14 +193,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase): | ||||
|         outpost.build_user_permissions(outpost.user) | ||||
|  | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         healthcheck_retries = 0 | ||||
|         while healthcheck_retries < 50:  # noqa: PLR2004 | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen and state.version: | ||||
|                     break | ||||
|             healthcheck_retries += 1 | ||||
|             sleep(0.5) | ||||
|         self.wait_for_outpost(outpost) | ||||
|  | ||||
|         state = outpost.state | ||||
|         self.assertGreaterEqual(len(state), 1) | ||||
|  | ||||
| @ -13,10 +13,12 @@ from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.outposts.models import Outpost, OutpostType | ||||
| from authentik.providers.proxy.models import ProxyMode, ProxyProvider | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderProxyForward(SeleniumTestCase): | ||||
| class TestProviderProxyForward(DockerTestCase, SeleniumTestCase): | ||||
|     """Proxy and Outpost e2e tests""" | ||||
|  | ||||
|     def setUp(self): | ||||
| @ -30,14 +32,11 @@ class TestProviderProxyForward(SeleniumTestCase): | ||||
|         """Start proxy container based on outpost created""" | ||||
|         self.run_container( | ||||
|             image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"), | ||||
|             ports={ | ||||
|                 "9000": "9000", | ||||
|             }, | ||||
|             environment={ | ||||
|                 "AUTHENTIK_TOKEN": outpost.token.key, | ||||
|             }, | ||||
|             ports={"9000": "9000"}, | ||||
|             environment={"AUTHENTIK_TOKEN": outpost.token.key}, | ||||
|             name="ak-test-outpost", | ||||
|         ) | ||||
|         self.wait_for_outpost(outpost) | ||||
|  | ||||
|     @apply_blueprint( | ||||
|         "default/flow-default-authentication-flow.yaml", | ||||
| @ -77,17 +76,6 @@ class TestProviderProxyForward(SeleniumTestCase): | ||||
|  | ||||
|         self.start_outpost(outpost) | ||||
|  | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         healthcheck_retries = 0 | ||||
|         while healthcheck_retries < 50:  # noqa: PLR2004 | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen: | ||||
|                     break | ||||
|             healthcheck_retries += 1 | ||||
|             sleep(0.5) | ||||
|         sleep(5) | ||||
|  | ||||
|     @retry() | ||||
|     def test_traefik(self): | ||||
|         """Test traefik""" | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Radius e2e tests""" | ||||
|  | ||||
| from dataclasses import asdict | ||||
| from time import sleep | ||||
|  | ||||
| from pyrad.client import Client | ||||
| from pyrad.dictionary import Dictionary | ||||
| @ -9,14 +8,17 @@ from pyrad.packet import AccessAccept, AccessReject, AccessRequest | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.outposts.models import Outpost, OutpostConfig, OutpostType | ||||
| from authentik.providers.radius.models import RadiusProvider | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
| from tests.websocket import WebsocketTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderRadius(SeleniumTestCase): | ||||
| class TestProviderRadius(DockerTestCase, WebsocketTestCase): | ||||
|     """Radius Outpost e2e tests""" | ||||
|  | ||||
|     def setUp(self): | ||||
| @ -28,13 +30,13 @@ class TestProviderRadius(SeleniumTestCase): | ||||
|         self.run_container( | ||||
|             image=self.get_container_image("ghcr.io/goauthentik/dev-radius"), | ||||
|             ports={"1812/udp": "1812/udp"}, | ||||
|             environment={ | ||||
|                 "AUTHENTIK_TOKEN": outpost.token.key, | ||||
|             }, | ||||
|             environment={"AUTHENTIK_TOKEN": outpost.token.key}, | ||||
|         ) | ||||
|         self.wait_for_outpost(outpost) | ||||
|  | ||||
|     def _prepare(self) -> User: | ||||
|         """prepare user, provider, app and container""" | ||||
|         self.user = create_test_user() | ||||
|         radius: RadiusProvider = RadiusProvider.objects.create( | ||||
|             name=generate_id(), | ||||
|             authorization_flow=Flow.objects.get(slug="default-authentication-flow"), | ||||
| @ -50,17 +52,6 @@ class TestProviderRadius(SeleniumTestCase): | ||||
|         outpost.providers.add(radius) | ||||
|  | ||||
|         self.start_radius(outpost) | ||||
|  | ||||
|         # Wait until outpost healthcheck succeeds | ||||
|         healthcheck_retries = 0 | ||||
|         while healthcheck_retries < 50:  # noqa: PLR2004 | ||||
|             if len(outpost.state) > 0: | ||||
|                 state = outpost.state[0] | ||||
|                 if state.last_seen: | ||||
|                     break | ||||
|             healthcheck_retries += 1 | ||||
|             sleep(0.5) | ||||
|         sleep(5) | ||||
|         return outpost | ||||
|  | ||||
|     @retry() | ||||
|  | ||||
| @ -14,10 +14,12 @@ from authentik.policies.expression.models import ExpressionPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider | ||||
| from authentik.sources.saml.processors.constants import SAML_BINDING_POST | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestProviderSAML(SeleniumTestCase): | ||||
| class TestProviderSAML(DockerTestCase, SeleniumTestCase): | ||||
|     """test SAML Provider flow""" | ||||
|  | ||||
|     def setup_client(self, provider: SAMLProvider, force_post: bool = False): | ||||
|  | ||||
| @ -11,10 +11,12 @@ from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping | ||||
| from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer | ||||
| from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer | ||||
| from authentik.sources.ldap.sync.users import UserLDAPSynchronizer | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestSourceLDAPSamba(SeleniumTestCase): | ||||
| class TestSourceLDAPSamba(DockerTestCase, SeleniumTestCase): | ||||
|     """test LDAP Source""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -16,7 +16,9 @@ from authentik.sources.oauth.models import OAuthSource | ||||
| from authentik.sources.oauth.types.registry import SourceType, registry | ||||
| from authentik.sources.oauth.views.callback import OAuthCallback | ||||
| from authentik.stages.identification.models import IdentificationStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class OAuth1Callback(OAuthCallback): | ||||
| @ -48,7 +50,7 @@ class OAUth1Type(SourceType): | ||||
|         } | ||||
|  | ||||
|  | ||||
| class TestSourceOAuth1(SeleniumTestCase): | ||||
| class TestSourceOAuth1(DockerTestCase, SeleniumTestCase): | ||||
|     """Test OAuth1 Source""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|  | ||||
| @ -16,10 +16,12 @@ from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.sources.oauth.models import OAuthSource | ||||
| from authentik.stages.identification.models import IdentificationStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
|  | ||||
| class TestSourceOAuth2(SeleniumTestCase): | ||||
| class TestSourceOAuth2(DockerTestCase, SeleniumTestCase): | ||||
|     """test OAuth Source flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -16,7 +16,9 @@ from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource | ||||
| from authentik.stages.identification.models import IdentificationStage | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
| IDP_CERT = """-----BEGIN CERTIFICATE----- | ||||
| MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV | ||||
| @ -70,7 +72,7 @@ Sm75WXsflOxuTn08LbgGc4s= | ||||
| -----END PRIVATE KEY-----""" | ||||
|  | ||||
|  | ||||
| class TestSourceSAML(SeleniumTestCase): | ||||
| class TestSourceSAML(DockerTestCase, SeleniumTestCase): | ||||
|     """test SAML Source flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -8,12 +8,14 @@ from docker.types import Healthcheck | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.utils.http import get_http_session | ||||
| from authentik.sources.scim.models import SCIMSource | ||||
| from tests.e2e.utils import SeleniumTestCase, retry | ||||
| from tests.browser import SeleniumTestCase | ||||
| from tests.decorators import retry | ||||
| from tests.docker import DockerTestCase | ||||
|  | ||||
| TEST_POLL_MAX = 25 | ||||
|  | ||||
|  | ||||
| class TestSourceSCIM(SeleniumTestCase): | ||||
| class TestSourceSCIM(DockerTestCase, SeleniumTestCase): | ||||
|     """test SCIM Source flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|  | ||||
| @ -1,311 +0,0 @@ | ||||
| """authentik e2e testing utilities""" | ||||
|  | ||||
| import json | ||||
| import os | ||||
| import socket | ||||
| from collections.abc import Callable | ||||
| from functools import lru_cache, wraps | ||||
| from os import environ | ||||
| from sys import stderr | ||||
| from time import sleep | ||||
| from typing import Any | ||||
| from unittest.case import TestCase | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.contrib.staticfiles.testing import StaticLiveServerTestCase | ||||
| from django.db import connection | ||||
| from django.db.migrations.loader import MigrationLoader | ||||
| from django.test.testcases import TransactionTestCase | ||||
| from django.urls import reverse | ||||
| from docker import DockerClient, from_env | ||||
| from docker.errors import DockerException | ||||
| from docker.models.containers import Container | ||||
| from docker.models.networks import Network | ||||
| from selenium import webdriver | ||||
| from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
| from selenium.webdriver.remote.webdriver import WebDriver | ||||
| from selenium.webdriver.remote.webelement import WebElement | ||||
| from selenium.webdriver.support.wait import WebDriverWait | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.api.users import UserSerializer | ||||
| from authentik.core.models import User | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
| RETRIES = int(environ.get("RETRIES", "3")) | ||||
| IS_CI = "CI" in environ | ||||
|  | ||||
|  | ||||
| def get_docker_tag() -> str: | ||||
|     """Get docker-tag based off of CI variables""" | ||||
|     env_pr_branch = "GITHUB_HEAD_REF" | ||||
|     default_branch = "GITHUB_REF" | ||||
|     branch_name = os.environ.get(default_branch, "main") | ||||
|     if os.environ.get(env_pr_branch, "") != "": | ||||
|         branch_name = os.environ[env_pr_branch] | ||||
|     branch_name = branch_name.replace("refs/heads/", "").replace("/", "-") | ||||
|     return f"gh-{branch_name}" | ||||
|  | ||||
|  | ||||
| def get_local_ip() -> str: | ||||
|     """Get the local machine's IP""" | ||||
|     hostname = socket.gethostname() | ||||
|     ip_addr = socket.gethostbyname(hostname) | ||||
|     return ip_addr | ||||
|  | ||||
|  | ||||
| class DockerTestCase(TestCase): | ||||
|     """Mixin for dealing with containers""" | ||||
|  | ||||
|     max_healthcheck_attempts = 30 | ||||
|  | ||||
|     __client: DockerClient | ||||
|     __network: Network | ||||
|  | ||||
|     __label_id = generate_id() | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.__client = from_env() | ||||
|         self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}") | ||||
|  | ||||
|     @property | ||||
|     def docker_client(self) -> DockerClient: | ||||
|         return self.__client | ||||
|  | ||||
|     @property | ||||
|     def docker_network(self) -> Network: | ||||
|         return self.__network | ||||
|  | ||||
|     @property | ||||
|     def docker_labels(self) -> dict: | ||||
|         return {"io.goauthentik.test": self.__label_id} | ||||
|  | ||||
|     def wait_for_container(self, container: Container): | ||||
|         """Check that container is health""" | ||||
|         attempt = 0 | ||||
|         while True: | ||||
|             container.reload() | ||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") | ||||
|             if status == "healthy": | ||||
|                 return container | ||||
|             sleep(1) | ||||
|             attempt += 1 | ||||
|             if attempt >= self.max_healthcheck_attempts: | ||||
|                 self.failureException("Container failed to start") | ||||
|  | ||||
|     def get_container_image(self, base: str) -> str: | ||||
|         """Try to pull docker image based on git branch, fallback to main if not found.""" | ||||
|         image = f"{base}:gh-main" | ||||
|         try: | ||||
|             branch_image = f"{base}:{get_docker_tag()}" | ||||
|             self.docker_client.images.pull(branch_image) | ||||
|             return branch_image | ||||
|         except DockerException: | ||||
|             self.docker_client.images.pull(image) | ||||
|         return image | ||||
|  | ||||
|     def run_container(self, **specs: dict[str, Any]) -> Container: | ||||
|         if "network_mode" not in specs: | ||||
|             specs["network"] = self.__network.name | ||||
|         specs["labels"] = self.docker_labels | ||||
|         specs["detach"] = True | ||||
|         if hasattr(self, "live_server_url"): | ||||
|             specs.setdefault("environment", {}) | ||||
|             specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url | ||||
|         container = self.docker_client.containers.run(**specs) | ||||
|         container.reload() | ||||
|         state = container.attrs.get("State", {}) | ||||
|         if "Health" not in state: | ||||
|             return container | ||||
|         self.wait_for_container(container) | ||||
|         return container | ||||
|  | ||||
|     def output_container_logs(self, container: Container | None = None): | ||||
|         """Output the container logs to our STDOUT""" | ||||
|         if IS_CI: | ||||
|             image = container.image | ||||
|             tags = image.tags[0] if len(image.tags) > 0 else str(image) | ||||
|             print(f"::group::Container logs - {tags}") | ||||
|         for log in container.logs().decode().split("\n"): | ||||
|             print(log) | ||||
|         if IS_CI: | ||||
|             print("::endgroup::") | ||||
|  | ||||
|     def tearDown(self): | ||||
|         containers: list[Container] = self.docker_client.containers.list( | ||||
|             filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())} | ||||
|         ) | ||||
|         for container in containers: | ||||
|             self.output_container_logs(container) | ||||
|             try: | ||||
|                 container.kill() | ||||
|             except DockerException: | ||||
|                 pass | ||||
|             try: | ||||
|                 container.remove(force=True) | ||||
|             except DockerException: | ||||
|                 pass | ||||
|         self.__network.remove() | ||||
|  | ||||
|  | ||||
| class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase): | ||||
|     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" | ||||
|  | ||||
|     host = get_local_ip() | ||||
|     wait_timeout: int | ||||
|     user: User | ||||
|  | ||||
|     def setUp(self): | ||||
|         if IS_CI: | ||||
|             print("::group::authentik Logs", file=stderr) | ||||
|         apps.get_app_config("authentik_tenants").ready() | ||||
|         self.wait_timeout = 60 | ||||
|         self.driver = self._get_driver() | ||||
|         self.driver.implicitly_wait(30) | ||||
|         self.wait = WebDriverWait(self.driver, self.wait_timeout) | ||||
|         self.logger = get_logger() | ||||
|         self.user = create_test_admin_user() | ||||
|         super().setUp() | ||||
|  | ||||
|     def _get_driver(self) -> WebDriver: | ||||
|         count = 0 | ||||
|         try: | ||||
|             opts = webdriver.ChromeOptions() | ||||
|             opts.add_argument("--disable-search-engine-choice-screen") | ||||
|             return webdriver.Chrome(options=opts) | ||||
|         except WebDriverException: | ||||
|             pass | ||||
|         while count < RETRIES: | ||||
|             try: | ||||
|                 driver = webdriver.Remote( | ||||
|                     command_executor="http://localhost:4444/wd/hub", | ||||
|                     options=webdriver.ChromeOptions(), | ||||
|                 ) | ||||
|                 driver.maximize_window() | ||||
|                 return driver | ||||
|             except WebDriverException: | ||||
|                 count += 1 | ||||
|         raise ValueError(f"Webdriver failed after {RETRIES}.") | ||||
|  | ||||
|     def tearDown(self): | ||||
|         if IS_CI: | ||||
|             print("::endgroup::", file=stderr) | ||||
|         super().tearDown() | ||||
|         if IS_CI: | ||||
|             print("::group::Browser logs") | ||||
|         for line in self.driver.get_log("browser"): | ||||
|             print(line["message"]) | ||||
|         if IS_CI: | ||||
|             print("::endgroup::") | ||||
|         self.driver.quit() | ||||
|  | ||||
|     def wait_for_url(self, desired_url): | ||||
|         """Wait until URL is `desired_url`.""" | ||||
|         self.wait.until( | ||||
|             lambda driver: driver.current_url == desired_url, | ||||
|             f"URL {self.driver.current_url} doesn't match expected URL {desired_url}", | ||||
|         ) | ||||
|  | ||||
|     def url(self, view, query: dict | None = None, **kwargs) -> str: | ||||
|         """reverse `view` with `**kwargs` into full URL using live_server_url""" | ||||
|         url = self.live_server_url + reverse(view, kwargs=kwargs) | ||||
|         if query: | ||||
|             return url + "?" + urlencode(query) | ||||
|         return url | ||||
|  | ||||
|     def if_user_url(self, path: str | None = None) -> str: | ||||
|         """same as self.url() but show URL in shell""" | ||||
|         url = self.url("authentik_core:if-user") | ||||
|         if path: | ||||
|             return f"{url}#{path}" | ||||
|         return url | ||||
|  | ||||
|     def get_shadow_root( | ||||
|         self, selector: str, container: WebElement | WebDriver | None = None | ||||
|     ) -> WebElement: | ||||
|         """Get shadow root element's inner shadowRoot""" | ||||
|         if not container: | ||||
|             container = self.driver | ||||
|         shadow_root = container.find_element(By.CSS_SELECTOR, selector) | ||||
|         element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root) | ||||
|         return element | ||||
|  | ||||
|     def login(self): | ||||
|         """Do entire login flow and check user afterwards""" | ||||
|         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||
|         identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor) | ||||
|  | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click() | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys( | ||||
|             Keys.ENTER | ||||
|         ) | ||||
|  | ||||
|         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||
|         password_stage = self.get_shadow_root("ak-stage-password", flow_executor) | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( | ||||
|             self.user.username | ||||
|         ) | ||||
|         password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER) | ||||
|         sleep(1) | ||||
|  | ||||
|     def assert_user(self, expected_user: User): | ||||
|         """Check users/me API and assert it matches expected_user""" | ||||
|         self.driver.get(self.url("authentik_api:user-me") + "?format=json") | ||||
|         user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text | ||||
|         user = UserSerializer(data=json.loads(user_json)["user"]) | ||||
|         user.is_valid() | ||||
|         self.assertEqual(user["username"].value, expected_user.username) | ||||
|         self.assertEqual(user["name"].value, expected_user.name) | ||||
|         self.assertEqual(user["email"].value, expected_user.email) | ||||
|  | ||||
|  | ||||
| @lru_cache | ||||
| def get_loader(): | ||||
|     """Thin wrapper to lazily get a Migration Loader, only when it's needed | ||||
|     and only once""" | ||||
|     return MigrationLoader(connection) | ||||
|  | ||||
|  | ||||
| def retry(max_retires=RETRIES, exceptions=None): | ||||
|     """Retry test multiple times. Default to catching Selenium Timeout Exception""" | ||||
|  | ||||
|     if not exceptions: | ||||
|         exceptions = [WebDriverException, TimeoutException, NoSuchElementException] | ||||
|  | ||||
|     logger = get_logger() | ||||
|  | ||||
|     def retry_actual(func: Callable): | ||||
|         """Retry test multiple times""" | ||||
|         count = 1 | ||||
|  | ||||
|         @wraps(func) | ||||
|         def wrapper(self: TransactionTestCase, *args, **kwargs): | ||||
|             """Run test again if we're below max_retries, including tearDown and | ||||
|             setUp. Otherwise raise the error""" | ||||
|             nonlocal count | ||||
|             try: | ||||
|                 return func(self, *args, **kwargs) | ||||
|  | ||||
|             except tuple(exceptions) as exc: | ||||
|                 count += 1 | ||||
|                 if count > max_retires: | ||||
|                     logger.debug("Exceeded retry count", exc=exc, test=self) | ||||
|  | ||||
|                     raise exc | ||||
|                 logger.debug("Retrying on error", exc=exc, test=self) | ||||
|                 self.tearDown() | ||||
|                 self._post_teardown() | ||||
|                 self._pre_setup() | ||||
|                 self.setUp() | ||||
|                 return wrapper(self, *args, **kwargs) | ||||
|  | ||||
|         return wrapper | ||||
|  | ||||
|     return retry_actual | ||||
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
| Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB | 
| @ -19,7 +19,7 @@ from authentik.outposts.models import ( | ||||
| ) | ||||
| from authentik.outposts.tasks import outpost_connection_discovery | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from tests.e2e.utils import DockerTestCase, get_docker_tag | ||||
| from tests.docker import DockerTestCase, get_docker_tag | ||||
|  | ||||
|  | ||||
| class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase): | ||||
|  | ||||
| @ -19,7 +19,7 @@ from authentik.outposts.models import ( | ||||
| from authentik.outposts.tasks import outpost_connection_discovery | ||||
| from authentik.providers.proxy.controllers.docker import DockerController | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from tests.e2e.utils import DockerTestCase, get_docker_tag | ||||
| from tests.docker import DockerTestCase, get_docker_tag | ||||
|  | ||||
|  | ||||
| class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase): | ||||
|  | ||||
							
								
								
									
										52
									
								
								tests/websocket.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								tests/websocket.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | ||||
| # This file cannot import anything django or anything that will load django | ||||
| from sys import stderr | ||||
|  | ||||
| from channels.testing import ChannelsLiveServerTestCase | ||||
| from daphne.testing import DaphneProcess | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from tests import IS_CI, get_local_ip | ||||
|  | ||||
|  | ||||
| def set_database_connection(): | ||||
|     from django.conf import settings | ||||
|  | ||||
|     settings.DATABASES["default"]["NAME"] = settings.DATABASES["default"]["TEST"]["NAME"] | ||||
|     settings.TEST = True | ||||
|  | ||||
|  | ||||
| class DatabasePatchDaphneProcess(DaphneProcess): | ||||
|     # See https://github.com/django/channels/issues/2048 | ||||
|     # See https://github.com/django/channels/pull/2033 | ||||
|  | ||||
|     def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None): | ||||
|         super().__init__(host, get_application, kwargs, setup, teardown) | ||||
|         self.setup = set_database_connection | ||||
|  | ||||
|  | ||||
| class BaseWebsocketTestCase(ChannelsLiveServerTestCase): | ||||
|     """Base channels test case""" | ||||
|  | ||||
|     host = get_local_ip() | ||||
|     ProtocolServerProcess = DatabasePatchDaphneProcess | ||||
|  | ||||
|  | ||||
| class WebsocketTestCase(BaseWebsocketTestCase): | ||||
|     """Test case to allow testing against a running Websocket/HTTP server""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         if IS_CI: | ||||
|             print("::group::authentik Logs", file=stderr) | ||||
|         from django.apps import apps | ||||
|  | ||||
|         from authentik.core.tests.utils import create_test_admin_user | ||||
|  | ||||
|         apps.get_app_config("authentik_tenants").ready() | ||||
|         self.logger = get_logger() | ||||
|         self.user = create_test_admin_user() | ||||
|         super().setUp() | ||||
|  | ||||
|     def tearDown(self): | ||||
|         if IS_CI: | ||||
|             print("::endgroup::", file=stderr) | ||||
|         super().tearDown() | ||||
| @ -1,11 +0,0 @@ | ||||
| import { create } from "@storybook/theming/create"; | ||||
|  | ||||
| const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; | ||||
|  | ||||
| export default create({ | ||||
|     base: isDarkMode ? "dark" : "light", | ||||
|     brandTitle: "authentik Storybook", | ||||
|     brandUrl: "https://goauthentik.io", | ||||
|     brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg", | ||||
|     brandTarget: "_self", | ||||
| }); | ||||
							
								
								
									
										69
									
								
								web/.storybook/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								web/.storybook/main.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | ||||
| /** | ||||
|  * @file Storybook configuration. | ||||
|  * @import { StorybookConfig } from "@storybook/web-components-vite"; | ||||
|  * @import { InlineConfig, Plugin } from "vite"; | ||||
|  */ | ||||
| import { cwd } from "process"; | ||||
| import postcssLit from "rollup-plugin-postcss-lit"; | ||||
| import tsconfigPaths from "vite-tsconfig-paths"; | ||||
|  | ||||
| const NODE_ENV = process.env.NODE_ENV || "development"; | ||||
|  | ||||
| const CSSImportPattern = /import [\w\$]+ from .+\.(css)/g; | ||||
| const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/; | ||||
|  | ||||
| /** | ||||
|  * @satisfies {Plugin<never>} | ||||
|  */ | ||||
| const inlineCSSPlugin = { | ||||
|     name: "inline-css-plugin", | ||||
|     transform: (source, id) => { | ||||
|         if (!JavaScriptFilePattern.test(id)) return; | ||||
|  | ||||
|         const code = source.replace(CSSImportPattern, (match) => { | ||||
|             return `${match}?inline`; | ||||
|         }); | ||||
|  | ||||
|         return { | ||||
|             code, | ||||
|         }; | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * @satisfies {StorybookConfig} | ||||
|  */ | ||||
| const config = { | ||||
|     stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], | ||||
|     addons: [ | ||||
|         "@storybook/addon-controls", | ||||
|         "@storybook/addon-links", | ||||
|         "@storybook/addon-essentials", | ||||
|         "storybook-addon-mock", | ||||
|     ], | ||||
|     framework: { | ||||
|         name: "@storybook/web-components-vite", | ||||
|         options: {}, | ||||
|     }, | ||||
|     docs: { | ||||
|         autodocs: "tag", | ||||
|     }, | ||||
|     viteFinal({ plugins = [], ...config }) { | ||||
|         /** | ||||
|          * @satisfies {InlineConfig} | ||||
|          */ | ||||
|         const mergedConfig = { | ||||
|             ...config, | ||||
|             define: { | ||||
|                 "process.env.NODE_ENV": JSON.stringify(NODE_ENV), | ||||
|                 "process.env.CWD": JSON.stringify(cwd()), | ||||
|                 "process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""), | ||||
|             }, | ||||
|             plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()], | ||||
|         }; | ||||
|  | ||||
|         return mergedConfig; | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| export default config; | ||||
| @ -1,81 +0,0 @@ | ||||
| import replace from "@rollup/plugin-replace"; | ||||
| import type { StorybookConfig } from "@storybook/web-components-vite"; | ||||
| import { cwd } from "process"; | ||||
| import modify from "rollup-plugin-modify"; | ||||
| import postcssLit from "rollup-plugin-postcss-lit"; | ||||
| import tsconfigPaths from "vite-tsconfig-paths"; | ||||
|  | ||||
| export const isProdBuild = process.env.NODE_ENV === "production"; | ||||
| export const apiBasePath = process.env.AK_API_BASE_PATH || ""; | ||||
|  | ||||
| const importInlinePatterns = [ | ||||
|     'import AKGlobal from "(\\.\\./)*common/styles/authentik\\.css', | ||||
|     'import AKGlobal from "@goauthentik/common/styles/authentik\\.css', | ||||
|     'import PF.+ from "@patternfly/patternfly/\\S+\\.css', | ||||
|     'import ThemeDark from "@goauthentik/common/styles/theme-dark\\.css', | ||||
|     'import OneDark from "@goauthentik/common/styles/one-dark\\.css', | ||||
|     'import styles from "\\./LibraryPageImpl\\.css', | ||||
| ]; | ||||
|  | ||||
| const importInlineRegexp = new RegExp(importInlinePatterns.map((a) => `(${a})`).join("|")); | ||||
|  | ||||
| const config: StorybookConfig = { | ||||
|     stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], | ||||
|     addons: [ | ||||
|         "@storybook/addon-controls", | ||||
|         "@storybook/addon-links", | ||||
|         "@storybook/addon-essentials", | ||||
|         "storybook-addon-mock", | ||||
|     ], | ||||
|     staticDirs: [ | ||||
|         { | ||||
|             from: "../node_modules/@patternfly/patternfly/patternfly-base.css", | ||||
|             to: "@patternfly/patternfly/patternfly-base.css", | ||||
|         }, | ||||
|         { | ||||
|             from: "../src/common/styles/authentik.css", | ||||
|             to: "@goauthentik/common/styles/authentik.css", | ||||
|         }, | ||||
|         { | ||||
|             from: "../src/common/styles/theme-dark.css", | ||||
|             to: "@goauthentik/common/styles/theme-dark.css", | ||||
|         }, | ||||
|         { | ||||
|             from: "../src/common/styles/one-dark.css", | ||||
|             to: "@goauthentik/common/styles/one-dark.css", | ||||
|         }, | ||||
|     ], | ||||
|     framework: { | ||||
|         name: "@storybook/web-components-vite", | ||||
|         options: {}, | ||||
|     }, | ||||
|     docs: { | ||||
|         autodocs: "tag", | ||||
|     }, | ||||
|     async viteFinal(config) { | ||||
|         return { | ||||
|             ...config, | ||||
|             plugins: [ | ||||
|                 modify({ | ||||
|                     find: importInlineRegexp, | ||||
|                     replace: (match: RegExpMatchArray) => { | ||||
|                         return `${match}?inline`; | ||||
|                     }, | ||||
|                 }), | ||||
|                 replace({ | ||||
|                     "process.env.NODE_ENV": JSON.stringify( | ||||
|                         isProdBuild ? "production" : "development", | ||||
|                     ), | ||||
|                     "process.env.CWD": JSON.stringify(cwd()), | ||||
|                     "process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath), | ||||
|                     "preventAssignment": true, | ||||
|                 }), | ||||
|                 ...config.plugins, | ||||
|                 postcssLit(), | ||||
|                 tsconfigPaths(), | ||||
|             ], | ||||
|         }; | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| export default config; | ||||
							
								
								
									
										38
									
								
								web/.storybook/manager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								web/.storybook/manager.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| /** | ||||
|  * @file Storybook manager configuration. | ||||
|  * | ||||
|  * @import { ThemeVarsPartial } from "storybook/internal/theming"; | ||||
|  */ | ||||
| import { createUIThemeEffect, resolveUITheme } from "@goauthentik/web/common/theme.ts"; | ||||
| import { addons } from "@storybook/manager-api"; | ||||
| import { create } from "@storybook/theming/create"; | ||||
|  | ||||
| /** | ||||
|  * @satisfies {Partial<ThemeVarsPartial>} | ||||
|  */ | ||||
| const baseTheme = { | ||||
|     brandTitle: "authentik Storybook", | ||||
|     brandUrl: "https://goauthentik.io", | ||||
|     brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg", | ||||
|     brandTarget: "_self", | ||||
| }; | ||||
|  | ||||
| const uiTheme = resolveUITheme(); | ||||
|  | ||||
| addons.setConfig({ | ||||
|     theme: create({ | ||||
|         ...baseTheme, | ||||
|         base: uiTheme, | ||||
|     }), | ||||
|     enableShortcuts: false, | ||||
| }); | ||||
|  | ||||
| createUIThemeEffect((nextUITheme) => { | ||||
|     addons.setConfig({ | ||||
|         theme: create({ | ||||
|             ...baseTheme, | ||||
|             base: nextUITheme, | ||||
|         }), | ||||
|         enableShortcuts: false, | ||||
|     }); | ||||
| }); | ||||
| @ -1,9 +0,0 @@ | ||||
| // .storybook/manager.js | ||||
| import { addons } from "@storybook/manager-api"; | ||||
|  | ||||
| import authentikTheme from "./authentikTheme"; | ||||
|  | ||||
| addons.setConfig({ | ||||
|     theme: authentikTheme, | ||||
|     enableShortcuts: false, | ||||
| }); | ||||
| @ -1,5 +1,3 @@ | ||||
| <link rel="stylesheet" href="@patternfly/patternfly/patternfly-base.css" /> | ||||
| <link rel="stylesheet" href="@goauthentik/common/styles/authentik.css" /> | ||||
| <style> | ||||
|     body { | ||||
|         overflow-y: scroll; | ||||
|  | ||||
							
								
								
									
										32
									
								
								web/.storybook/preview.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/.storybook/preview.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | ||||
| /// <reference types="../types/css.js" /> | ||||
| /** | ||||
|  * @file Storybook manager configuration. | ||||
|  * | ||||
|  * @import { Preview } from "@storybook/web-components"; | ||||
|  */ | ||||
| import { applyDocumentTheme } from "@goauthentik/web/common/theme.ts"; | ||||
|  | ||||
| applyDocumentTheme(); | ||||
|  | ||||
| /** | ||||
|  * @satisfies {Preview} | ||||
|  */ | ||||
| const preview = { | ||||
|     parameters: { | ||||
|         options: { | ||||
|             storySort: { | ||||
|                 method: "alphabetical", | ||||
|             }, | ||||
|         }, | ||||
|         actions: { argTypesRegex: "^on[A-Z].*" }, | ||||
|  | ||||
|         controls: { | ||||
|             matchers: { | ||||
|                 color: /(background|color)$/i, | ||||
|                 date: /Date$/, | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| export default preview; | ||||
| @ -1,30 +0,0 @@ | ||||
| import type { Preview } from "@storybook/web-components"; | ||||
|  | ||||
| import "@goauthentik/common/styles/authentik.css"; | ||||
| // import "@goauthentik/common/styles/theme-dark.css"; | ||||
| import "@patternfly/patternfly/components/Brand/brand.css"; | ||||
| import "@patternfly/patternfly/components/Page/page.css"; | ||||
| // .storybook/preview.js | ||||
| import "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| const preview: Preview = { | ||||
|     parameters: { | ||||
|         options: { | ||||
|             storySort: { | ||||
|                 method: "alphabetical", | ||||
|             }, | ||||
|         }, | ||||
|         actions: { argTypesRegex: "^on[A-Z].*" }, | ||||
|         cssUserPrefs: { | ||||
|             "prefers-color-scheme": "light", | ||||
|         }, | ||||
|         controls: { | ||||
|             matchers: { | ||||
|                 color: /(background|color)$/i, | ||||
|                 date: /Date$/, | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| export default preview; | ||||
							
								
								
									
										2781
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2781
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -19,7 +19,6 @@ | ||||
|         "lint:precommit": "wireit", | ||||
|         "lint:types": "wireit", | ||||
|         "lit-analyse": "wireit", | ||||
|         "postinstall": "bash scripts/patch-spotlight.sh", | ||||
|         "precommit": "wireit", | ||||
|         "prettier": "wireit", | ||||
|         "prettier-check": "wireit", | ||||
| @ -37,7 +36,14 @@ | ||||
|     "exports": { | ||||
|         "./package.json": "./package.json", | ||||
|         "./paths": "./paths.js", | ||||
|         "./scripts/*": "./scripts/*.mjs" | ||||
|         "./scripts/*": "./scripts/*.mjs", | ||||
|         "./elements/*": "./src/elements/*", | ||||
|         "./common/*": "./src/common/*", | ||||
|         "./components/*": "./src/components/*", | ||||
|         "./flow/*": "./src/flow/*", | ||||
|         "./locales/*": "./src/locales/*", | ||||
|         "./user/*": "./src/user/*", | ||||
|         "./admin/*": "./src/admin/*" | ||||
|     }, | ||||
|     "dependencies": { | ||||
|         "@codemirror/lang-css": "^6.3.1", | ||||
| @ -50,7 +56,7 @@ | ||||
|         "@floating-ui/dom": "^1.6.11", | ||||
|         "@formatjs/intl-listformat": "^7.5.7", | ||||
|         "@fortawesome/fontawesome-free": "^6.6.0", | ||||
|         "@goauthentik/api": "^2025.4.0-1746018955", | ||||
|         "@goauthentik/api": "^2025.4.1-1747332783", | ||||
|         "@lit/context": "^1.1.2", | ||||
|         "@lit/localize": "^0.12.2", | ||||
|         "@lit/reactive-element": "^2.0.4", | ||||
| @ -106,14 +112,14 @@ | ||||
|         "@hcaptcha/types": "^1.0.4", | ||||
|         "@lit/localize-tools": "^0.8.0", | ||||
|         "@rollup/plugin-replace": "^6.0.1", | ||||
|         "@storybook/addon-essentials": "^8.3.4", | ||||
|         "@storybook/addon-links": "^8.3.4", | ||||
|         "@storybook/api": "^7.6.17", | ||||
|         "@storybook/blocks": "^8.3.4", | ||||
|         "@storybook/builder-vite": "^8.3.4", | ||||
|         "@storybook/manager-api": "^8.3.4", | ||||
|         "@storybook/web-components": "^8.3.4", | ||||
|         "@storybook/web-components-vite": "^8.3.4", | ||||
|         "@storybook/addon-essentials": "^8.6.12", | ||||
|         "@storybook/addon-links": "^8.6.12", | ||||
|         "@storybook/blocks": "^8.6.12", | ||||
|         "@storybook/experimental-addon-test": "^8.6.12", | ||||
|         "@storybook/manager-api": "^8.6.12", | ||||
|         "@storybook/test": "^8.6.12", | ||||
|         "@storybook/web-components": "^8.6.12", | ||||
|         "@storybook/web-components-vite": "^8.6.12", | ||||
|         "@trivago/prettier-plugin-sort-imports": "^5.2.2", | ||||
|         "@types/chart.js": "^2.9.41", | ||||
|         "@types/codemirror": "^5.60.15", | ||||
| @ -145,9 +151,8 @@ | ||||
|         "npm-run-all": "^4.1.5", | ||||
|         "prettier": "^3.3.3", | ||||
|         "pseudolocale": "^2.1.0", | ||||
|         "rollup-plugin-modify": "^3.0.0", | ||||
|         "rollup-plugin-postcss-lit": "^2.1.0", | ||||
|         "storybook": "^8.3.4", | ||||
|         "storybook": "^8.6.12", | ||||
|         "storybook-addon-mock": "^5.0.0", | ||||
|         "turnstile-types": "^1.2.3", | ||||
|         "typescript": "^5.6.2", | ||||
|  | ||||
| @ -6,7 +6,7 @@ | ||||
|  * @import { Message as ESBuildMessage } from "esbuild"; | ||||
|  */ | ||||
|  | ||||
| const logPrefix = "👷 [ESBuild]"; | ||||
| const logPrefix = "authentik/dev/web: "; | ||||
| const log = console.debug.bind(console, logPrefix); | ||||
|  | ||||
| /** | ||||
| @ -76,7 +76,7 @@ export class ESBuildObserver extends EventSource { | ||||
|      */ | ||||
|     #startListener = () => { | ||||
|         this.#trackActivity(); | ||||
|         log("⏰  Build started..."); | ||||
|         log("⏰ Build started..."); | ||||
|     }; | ||||
|  | ||||
|     #internalErrorListener = () => { | ||||
| @ -86,7 +86,7 @@ export class ESBuildObserver extends EventSource { | ||||
|             clearTimeout(this.#keepAliveInterval); | ||||
|  | ||||
|             this.close(); | ||||
|             log("⛔️  Closing connection"); | ||||
|             log("⛔️ Closing connection"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
| @ -126,13 +126,13 @@ export class ESBuildObserver extends EventSource { | ||||
|         this.#trackActivity(); | ||||
|  | ||||
|         if (!this.online) { | ||||
|             log("🚫  Build finished while offline."); | ||||
|             log("🚫 Build finished while offline."); | ||||
|             this.deferredReload = true; | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         log("🛎️  Build completed! Reloading..."); | ||||
|         log("🛎️ Build completed! Reloading..."); | ||||
|  | ||||
|         // We use an animation frame to keep the reload from happening before the | ||||
|         // event loop has a chance to process the message. | ||||
| @ -189,13 +189,13 @@ export class ESBuildObserver extends EventSource { | ||||
|  | ||||
|             if (!this.deferredReload) return; | ||||
|  | ||||
|             log("🛎️  Reloading after offline build..."); | ||||
|             log("🛎️ Reloading after offline build..."); | ||||
|             this.deferredReload = false; | ||||
|  | ||||
|             window.location.reload(); | ||||
|         }); | ||||
|  | ||||
|         log("🛎️  Listening for build changes..."); | ||||
|         log("🛎️ Listening for build changes..."); | ||||
|  | ||||
|         this.#keepAliveInterval = setInterval(() => { | ||||
|             const now = Date.now(); | ||||
| @ -203,7 +203,7 @@ export class ESBuildObserver extends EventSource { | ||||
|             if (now - this.lastUpdatedAt < 10_000) return; | ||||
|  | ||||
|             this.alive = false; | ||||
|             log("👋  Waiting for build to start..."); | ||||
|             log("👋 Waiting for build to start..."); | ||||
|         }, 15_000); | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -47,7 +47,16 @@ class SimpleFlowExecutor { | ||||
|         return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`; | ||||
|     } | ||||
|  | ||||
|     loading() { | ||||
|         this.container.innerHTML = `<div class="d-flex justify-content-center"> | ||||
|             <div class="spinner-border spinner-border-md" role="status"> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </div>`; | ||||
|     } | ||||
|  | ||||
|     start() { | ||||
|         this.loading(); | ||||
|         $.ajax({ | ||||
|             type: "GET", | ||||
|             url: this.apiURL, | ||||
| @ -201,6 +210,9 @@ class PasswordStage extends Stage<PasswordChallenge> { | ||||
|             <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"> | ||||
|                     <input type="text" readonly class="form-control-plaintext" value="Welcome, ${this.challenge?.pendingUser}."> | ||||
|                 </div> | ||||
|                 <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")} | ||||
|  | ||||
| @ -1,33 +0,0 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| TARGET="./node_modules/@spotlightjs/overlay/dist/index-"[0-9a-f]*.js | ||||
|  | ||||
| if [[ $(grep -L "QX2" "$TARGET" > /dev/null 2> /dev/null) ]]; then | ||||
|     patch --forward -V none --no-backup-if-mismatch -p0 $TARGET <<EOF | ||||
|  | ||||
| TARGET=$(find "./node_modules/@spotlightjs/overlay/dist/" -name "index-[0-9a-f]*.js"); | ||||
|  | ||||
| if ! grep -GL 'QX2 = ' "$TARGET" > /dev/null ; then | ||||
| patch --forward --no-backup-if-mismatch -p0 "$TARGET" <<EOF | ||||
| >>>>>>> main | ||||
| --- a/index-5682ce90.js	2024-06-13 16:19:28 | ||||
| +++ b/index-5682ce90.js	2024-06-13 16:20:23 | ||||
| @@ -4958,11 +4958,10 @@ | ||||
|      } | ||||
|    ); | ||||
|  } | ||||
| -const q2 = w.lazy(() => import("./main-3257b7fc.js").then((n) => n.m)); | ||||
| +const q2 = w.lazy(() => import("./main-3257b7fc.js").then((n) => n.m)), QX2 = () => {}; | ||||
|  function Gp({ | ||||
|    data: n, | ||||
| -  onUpdateData: a = () => { | ||||
| -  }, | ||||
| +  onUpdateData: a = QX2, | ||||
|    editingEnabled: s = !1, | ||||
|    clipboardEnabled: o = !1, | ||||
|    displayDataTypes: c = !1, | ||||
| EOF | ||||
|  | ||||
| else | ||||
|     echo "spotlight overlay.js patch already applied" | ||||
| fi | ||||
| @ -1,6 +1,6 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { VERSION } from "@goauthentik/common/constants"; | ||||
| import { ServerContext } from "@goauthentik/common/server-context"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||
| import "@goauthentik/elements/EmptyState"; | ||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||
| @ -33,7 +33,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) | ||||
|         const status = await new AdminApi(DEFAULT_CONFIG).adminSystemRetrieve(); | ||||
|         const version = await new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve(); | ||||
|         let build: string | TemplateResult = msg("Release"); | ||||
|         if (ServerContext.config.capabilities.includes(CapabilitiesEnum.CanDebug)) { | ||||
|         if (globalAK().config.capabilities.includes(CapabilitiesEnum.CanDebug)) { | ||||
|             build = msg("Development"); | ||||
|         } else if (version.buildHash !== "") { | ||||
|             build = html`<a | ||||
| @ -58,12 +58,10 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) | ||||
|     } | ||||
|  | ||||
|     renderModal() { | ||||
|         let product = ServerContext.brand.brandingTitle || DefaultBrand.brandingTitle; | ||||
|  | ||||
|         let product = globalAK().brand.brandingTitle || DefaultBrand.brandingTitle; | ||||
|         if (this.licenseSummary.status != LicenseSummaryStatusEnum.Unlicensed) { | ||||
|             product += ` ${msg("Enterprise")}`; | ||||
|         } | ||||
|  | ||||
|         return html`<div | ||||
|             class="pf-c-backdrop" | ||||
|             @click=${(e: PointerEvent) => { | ||||
|  | ||||
| @ -4,14 +4,13 @@ import { ROUTES } from "@goauthentik/admin/Routes"; | ||||
| import { | ||||
|     EVENT_API_DRAWER_TOGGLE, | ||||
|     EVENT_NOTIFICATION_DRAWER_TOGGLE, | ||||
|     EVENT_SIDEBAR_TOGGLE, | ||||
| } from "@goauthentik/common/constants"; | ||||
| import { setSentryPII, tryInitializeSentry } from "@goauthentik/common/sentry"; | ||||
| import { ServerContext } from "@goauthentik/common/server-context"; | ||||
| import { configureSentry } from "@goauthentik/common/sentry"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | ||||
| import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; | ||||
| import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; | ||||
| import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader"; | ||||
| import "@goauthentik/elements/ak-locale-context"; | ||||
| import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||
| import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||
| @ -27,7 +26,7 @@ import "@goauthentik/elements/sidebar/Sidebar"; | ||||
| import "@goauthentik/elements/sidebar/SidebarItem"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators.js"; | ||||
| import { customElement, eventOptions, property, query } from "lit/decorators.js"; | ||||
| import { classMap } from "lit/directives/class-map.js"; | ||||
|  | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| @ -53,28 +52,33 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|     //#region Properties | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); | ||||
|     public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     apiDrawerOpen = getURLParam("apiDrawerOpen", false); | ||||
|     public apiDrawerOpen = getURLParam("apiDrawerOpen", false); | ||||
|  | ||||
|     ws: WebsocketClient; | ||||
|     protected readonly ws: WebsocketClient; | ||||
|  | ||||
|     @state() | ||||
|     user?: SessionUser; | ||||
|     @property({ | ||||
|         type: Object, | ||||
|         attribute: false, | ||||
|         reflect: false, | ||||
|     }) | ||||
|     public user?: SessionUser; | ||||
|  | ||||
|     @query("ak-about-modal") | ||||
|     aboutModal?: AboutModal; | ||||
|     public aboutModal?: AboutModal; | ||||
|  | ||||
|     @property({ type: Boolean, reflect: true }) | ||||
|     public sidebarOpen: boolean; | ||||
|  | ||||
|     #toggleSidebar = () => { | ||||
|         this.sidebarOpen = !this.sidebarOpen; | ||||
|     }; | ||||
|     @eventOptions({ passive: true }) | ||||
|     protected sidebarListener(event: CustomEvent<SidebarToggleEventDetail>) { | ||||
|         this.sidebarOpen = !!event.detail.open; | ||||
|     } | ||||
|  | ||||
|     #sidebarMatcher: MediaQueryList; | ||||
|     #sidebarListener = (event: MediaQueryListEvent) => { | ||||
|     #sidebarMediaQueryListener = (event: MediaQueryListEvent) => { | ||||
|         this.sidebarOpen = event.matches; | ||||
|     }; | ||||
|  | ||||
| @ -82,59 +86,57 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|  | ||||
|     //#region Styles | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             PFBase, | ||||
|             PFPage, | ||||
|             PFButton, | ||||
|             PFDrawer, | ||||
|             PFNav, | ||||
|             css` | ||||
|                 .pf-c-page__main, | ||||
|                 .pf-c-drawer__content, | ||||
|                 .pf-c-page__drawer { | ||||
|                     z-index: auto !important; | ||||
|                     background-color: transparent; | ||||
|                 } | ||||
|     static styles: CSSResult[] = [ | ||||
|         PFBase, | ||||
|         PFPage, | ||||
|         PFButton, | ||||
|         PFDrawer, | ||||
|         PFNav, | ||||
|         css` | ||||
|             .pf-c-page__main, | ||||
|             .pf-c-drawer__content, | ||||
|             .pf-c-page__drawer { | ||||
|                 z-index: auto !important; | ||||
|                 background-color: transparent; | ||||
|             } | ||||
|  | ||||
|                 .display-none { | ||||
|                     display: none; | ||||
|                 } | ||||
|             .display-none { | ||||
|                 display: none; | ||||
|             } | ||||
|  | ||||
|             .pf-c-page { | ||||
|                 background-color: var(--pf-c-page--BackgroundColor) !important; | ||||
|             } | ||||
|  | ||||
|             :host([theme="dark"]) { | ||||
|                 /* Global page background colour */ | ||||
|                 .pf-c-page { | ||||
|                     background-color: var(--pf-c-page--BackgroundColor) !important; | ||||
|                     --pf-c-page--BackgroundColor: var(--ak-dark-background); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|                 :host([theme="dark"]) { | ||||
|                     /* Global page background colour */ | ||||
|                     .pf-c-page { | ||||
|                         --pf-c-page--BackgroundColor: var(--ak-dark-background); | ||||
|                     } | ||||
|                 } | ||||
|             ak-page-navbar { | ||||
|                 grid-area: header; | ||||
|             } | ||||
|  | ||||
|                 ak-page-navbar { | ||||
|                     grid-area: header; | ||||
|                 } | ||||
|             .ak-sidebar { | ||||
|                 grid-area: nav; | ||||
|             } | ||||
|  | ||||
|                 .ak-sidebar { | ||||
|                     grid-area: nav; | ||||
|                 } | ||||
|  | ||||
|                 .pf-c-drawer__panel { | ||||
|                     z-index: var(--pf-global--ZIndex--xl); | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
|             .pf-c-drawer__panel { | ||||
|                 z-index: var(--pf-global--ZIndex--xl); | ||||
|             } | ||||
|         `, | ||||
|     ]; | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Lifecycle | ||||
|  | ||||
|     constructor() { | ||||
|         configureSentry(true); | ||||
|         super(); | ||||
|         this.ws = new WebsocketClient(); | ||||
|  | ||||
|         this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)"); | ||||
|         this.sidebarOpen = this.#sidebarMatcher.matches; | ||||
|     } | ||||
| @ -142,8 +144,6 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|     public connectedCallback() { | ||||
|         super.connectedCallback(); | ||||
|  | ||||
|         window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); | ||||
|  | ||||
|         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { | ||||
|             this.notificationDrawerOpen = !this.notificationDrawerOpen; | ||||
|             updateURLParams({ | ||||
| @ -158,31 +158,25 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         this.#sidebarMatcher.addEventListener("change", this.#sidebarListener); | ||||
|         this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, { | ||||
|             passive: true, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public disconnectedCallback(): void { | ||||
|         super.disconnectedCallback(); | ||||
|         window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); | ||||
|         this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener); | ||||
|         this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener); | ||||
|     } | ||||
|  | ||||
|     async firstUpdated(): Promise<void> { | ||||
|         tryInitializeSentry(ServerContext.config); | ||||
|         this.user = await me(); | ||||
|  | ||||
|         setSentryPII(this.user.user); | ||||
|  | ||||
|         const canAccessAdmin = | ||||
|             this.user.user.isSuperuser || | ||||
|             // TODO: somehow add `access_admin_interface` to the API schema | ||||
|             this.user.user.systemPermissions.includes("access_admin_interface"); | ||||
|  | ||||
|         if (!canAccessAdmin && this.user.user.pk > 0) { | ||||
|             console.debug( | ||||
|                 "authentik/admin: User does not have access to admin interface. Redirecting...", | ||||
|             ); | ||||
|  | ||||
|             window.location.assign("/if/user/"); | ||||
|         } | ||||
|     } | ||||
| @ -204,7 +198,7 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|  | ||||
|         return html` <ak-locale-context> | ||||
|             <div class="pf-c-page"> | ||||
|                 <ak-page-navbar> | ||||
|                 <ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}> | ||||
|                     <ak-version-banner></ak-version-banner> | ||||
|                     <ak-enterprise-status interface="admin"></ak-enterprise-status> | ||||
|                 </ak-page-navbar> | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	