From b361dd3b59be672abe77e2b55407a652b8d34b1c Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Mon, 19 May 2025 22:41:09 +0200 Subject: [PATCH 1/4] lib/sync/outgoing: reduce number of db queries made (#14177) --- .../providers/google_workspace/clients/groups.py | 2 +- .../providers/google_workspace/clients/users.py | 2 +- .../enterprise/providers/google_workspace/models.py | 12 ++++++++++-- .../providers/microsoft_entra/clients/groups.py | 2 +- .../providers/microsoft_entra/clients/users.py | 2 +- .../enterprise/providers/microsoft_entra/models.py | 12 ++++++++++-- authentik/lib/sync/outgoing/base.py | 12 ++++++------ authentik/providers/scim/clients/groups.py | 2 +- authentik/providers/scim/clients/users.py | 2 +- authentik/providers/scim/models.py | 4 ++-- 10 files changed, 34 insertions(+), 18 deletions(-) diff --git a/authentik/enterprise/providers/google_workspace/clients/groups.py b/authentik/enterprise/providers/google_workspace/clients/groups.py index de85d96c59..46bd480801 100644 --- a/authentik/enterprise/providers/google_workspace/clients/groups.py +++ b/authentik/enterprise/providers/google_workspace/clients/groups.py @@ -25,7 +25,7 @@ class GoogleWorkspaceGroupClient( """Google client for groups""" connection_type = GoogleWorkspaceProviderGroup - connection_type_query = "group" + connection_attr = "googleworkspaceprovidergroup_set" can_discover = True def __init__(self, provider: GoogleWorkspaceProvider) -> None: diff --git a/authentik/enterprise/providers/google_workspace/clients/users.py b/authentik/enterprise/providers/google_workspace/clients/users.py index 3c6206a4ec..2a425e76bc 100644 --- a/authentik/enterprise/providers/google_workspace/clients/users.py +++ b/authentik/enterprise/providers/google_workspace/clients/users.py @@ -20,7 +20,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP """Sync authentik users into google workspace""" connection_type = GoogleWorkspaceProviderUser - connection_type_query = "user" + connection_attr = "googleworkspaceprovideruser_set" can_discover = True def __init__(self, provider: GoogleWorkspaceProvider) -> None: diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index fdade8ea6f..188f061fc6 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -132,7 +132,11 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): if type == User: # Get queryset of all users with consistent ordering # according to the provider's settings - base = User.objects.all().exclude_anonymous() + base = ( + User.objects.prefetch_related("googleworkspaceprovideruser_set") + .all() + .exclude_anonymous() + ) if self.exclude_users_service_account: base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( type=UserTypes.INTERNAL_SERVICE_ACCOUNT @@ -142,7 +146,11 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + return ( + Group.objects.prefetch_related("googleworkspaceprovidergroup_set") + .all() + .order_by("pk") + ) raise ValueError(f"Invalid type {type}") def google_credentials(self): diff --git a/authentik/enterprise/providers/microsoft_entra/clients/groups.py b/authentik/enterprise/providers/microsoft_entra/clients/groups.py index 2cb135822f..3c7f8502c1 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/groups.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/groups.py @@ -29,7 +29,7 @@ class MicrosoftEntraGroupClient( """Microsoft client for groups""" connection_type = MicrosoftEntraProviderGroup - connection_type_query = "group" + connection_attr = "microsoftentraprovidergroup_set" can_discover = True def __init__(self, provider: MicrosoftEntraProvider) -> None: diff --git a/authentik/enterprise/providers/microsoft_entra/clients/users.py b/authentik/enterprise/providers/microsoft_entra/clients/users.py index a650b42a52..a95fc388ce 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/users.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/users.py @@ -24,7 +24,7 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv """Sync authentik users into microsoft entra""" connection_type = MicrosoftEntraProviderUser - connection_type_query = "user" + connection_attr = "microsoftentraprovideruser_set" can_discover = True def __init__(self, provider: MicrosoftEntraProvider) -> None: diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 50630c0976..56bb346bcc 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -121,7 +121,11 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): if type == User: # Get queryset of all users with consistent ordering # according to the provider's settings - base = User.objects.all().exclude_anonymous() + base = ( + User.objects.prefetch_related("microsoftentraprovideruser_set") + .all() + .exclude_anonymous() + ) if self.exclude_users_service_account: base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( type=UserTypes.INTERNAL_SERVICE_ACCOUNT @@ -131,7 +135,11 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + return ( + Group.objects.prefetch_related("microsoftentraprovidergroup_set") + .all() + .order_by("pk") + ) raise ValueError(f"Invalid type {type}") def microsoft_credentials(self): diff --git a/authentik/lib/sync/outgoing/base.py b/authentik/lib/sync/outgoing/base.py index 77a70c643c..16eb1eb92b 100644 --- a/authentik/lib/sync/outgoing/base.py +++ b/authentik/lib/sync/outgoing/base.py @@ -23,7 +23,6 @@ if TYPE_CHECKING: class Direction(StrEnum): - add = "add" remove = "remove" @@ -37,13 +36,16 @@ SAFE_METHODS = [ class BaseOutgoingSyncClient[ - TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" + TModel: "Model", + TConnection: "Model", + TSchema: dict, + TProvider: "OutgoingSyncProvider", ]: """Basic Outgoing sync client Client""" provider: TProvider connection_type: type[TConnection] - connection_type_query: str + connection_attr: str mapper: PropertyMappingManager can_discover = False @@ -63,9 +65,7 @@ class BaseOutgoingSyncClient[ def write(self, obj: TModel) -> tuple[TConnection, bool]: """Write object to destination. Uses self.create and self.update, but can be overwritten for further logic""" - connection = self.connection_type.objects.filter( - provider=self.provider, **{self.connection_type_query: obj} - ).first() + connection = getattr(obj, self.connection_attr).filter(provider=self.provider).first() try: if not connection: connection = self.create(obj) diff --git a/authentik/providers/scim/clients/groups.py b/authentik/providers/scim/clients/groups.py index e9e6d25e55..cf403d1f59 100644 --- a/authentik/providers/scim/clients/groups.py +++ b/authentik/providers/scim/clients/groups.py @@ -34,7 +34,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): """SCIM client for groups""" connection_type = SCIMProviderGroup - connection_type_query = "group" + connection_attr = "scimprovidergroup_set" mapper: PropertyMappingManager def __init__(self, provider: SCIMProvider): diff --git a/authentik/providers/scim/clients/users.py b/authentik/providers/scim/clients/users.py index ff11608ebe..579d4c8381 100644 --- a/authentik/providers/scim/clients/users.py +++ b/authentik/providers/scim/clients/users.py @@ -18,7 +18,7 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]): """SCIM client for users""" connection_type = SCIMProviderUser - connection_type_query = "user" + connection_attr = "scimprovideruser_set" mapper: PropertyMappingManager def __init__(self, provider: SCIMProvider): diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 09fbf16cb6..606cec31cd 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -116,7 +116,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): if type == User: # Get queryset of all users with consistent ordering # according to the provider's settings - base = User.objects.all().exclude_anonymous() + base = User.objects.prefetch_related("scimprovideruser_set").all().exclude_anonymous() if self.exclude_users_service_account: base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( type=UserTypes.INTERNAL_SERVICE_ACCOUNT @@ -126,7 +126,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + return Group.objects.prefetch_related("scimprovidergroup_set").all().order_by("pk") raise ValueError(f"Invalid type {type}") @property From 65517f3b7fdc27773acd30829c9dfc4cdae4927d Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Mon, 19 May 2025 22:48:17 +0200 Subject: [PATCH 2/4] enterprise/stages: Add MTLS stage (#14296) * prepare client auth with inbuilt server Signed-off-by: Jens Langhammer * introduce better IPC auth Signed-off-by: Jens Langhammer * init Signed-off-by: Jens Langhammer * start stage Signed-off-by: Jens Langhammer * only allow trusted proxies to set MTLS headers Signed-off-by: Jens Langhammer * more stage progress Signed-off-by: Jens Langhammer * dont fail if ipc_key doesn't exist Signed-off-by: Jens Langhammer * actually install app Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * add some tests Signed-off-by: Jens Langhammer * update API Signed-off-by: Jens Langhammer * fix unquote Signed-off-by: Jens Langhammer * fix int serial number not jsonable Signed-off-by: Jens Langhammer * init ui Signed-off-by: Jens Langhammer * add UI Signed-off-by: Jens Langhammer * unrelated: fix git pull in makefile Signed-off-by: Jens Langhammer * fix parse helper Signed-off-by: Jens Langhammer * add test for outpost Signed-off-by: Jens Langhammer * more tests and improvements Signed-off-by: Jens Langhammer * improve labels Signed-off-by: Jens Langhammer * add support for multiple CAs on brand Signed-off-by: Jens Langhammer * add support for multiple CAs to MTLS stage Signed-off-by: Jens Langhammer * dont log ipcuser secret views Signed-off-by: Jens Langhammer * fix go mod Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- Makefile | 3 +- authentik/api/authentication.py | 53 +- authentik/brands/api.py | 3 + ...0010_brand_client_certificates_and_more.py | 37 ++ authentik/brands/models.py | 7 + authentik/crypto/api.py | 23 +- authentik/enterprise/settings.py | 1 + authentik/enterprise/stages/mtls/__init__.py | 0 authentik/enterprise/stages/mtls/api.py | 31 ++ authentik/enterprise/stages/mtls/apps.py | 12 + .../stages/mtls/migrations/0001_initial.py | 68 +++ .../stages/mtls/migrations/__init__.py | 0 authentik/enterprise/stages/mtls/models.py | 71 +++ authentik/enterprise/stages/mtls/stage.py | 215 ++++++++ .../enterprise/stages/mtls/tests/__init__.py | 0 .../stages/mtls/tests/fixtures/ca.pem | 31 ++ .../mtls/tests/fixtures/cert_client.pem | 30 ++ .../stages/mtls/tests/test_stage.py | 213 ++++++++ authentik/enterprise/stages/mtls/urls.py | 5 + authentik/flows/tests/__init__.py | 14 + blueprints/schema.json | 217 ++++++++ cmd/server/server.go | 12 +- internal/constants/constants.go | 6 +- internal/gounicorn/gounicorn.go | 24 +- internal/outpost/ak/api.go | 2 +- internal/outpost/ak/api_ws.go | 2 +- internal/outpost/ak/crypto.go | 28 +- internal/outpost/ak/global.go | 2 +- internal/outpost/flow/executor.go | 2 +- .../proxyv2/application/mode_common.go | 2 +- internal/outpost/proxyv2/refresh.go | 2 +- internal/outpost/rac/connection/connection.go | 2 +- internal/utils/web/http_forwarded.go | 13 +- internal/web/brand_tls/brand_tls.go | 51 +- internal/web/metrics.go | 22 +- internal/web/proxy.go | 27 +- internal/web/web.go | 89 +++- internal/web/web_tls.go | 35 +- schema.yml | 474 ++++++++++++++++++ web/src/admin/brands/BrandForm.ts | 14 + web/src/admin/brands/Certificates.ts | 39 ++ web/src/admin/stages/StageListPage.ts | 1 + web/src/admin/stages/StageWizard.ts | 1 + web/src/admin/stages/mtls/MTLSStageForm.ts | 162 ++++++ 44 files changed, 1950 insertions(+), 96 deletions(-) create mode 100644 authentik/brands/migrations/0010_brand_client_certificates_and_more.py create mode 100644 authentik/enterprise/stages/mtls/__init__.py create mode 100644 authentik/enterprise/stages/mtls/api.py create mode 100644 authentik/enterprise/stages/mtls/apps.py create mode 100644 authentik/enterprise/stages/mtls/migrations/0001_initial.py create mode 100644 authentik/enterprise/stages/mtls/migrations/__init__.py create mode 100644 authentik/enterprise/stages/mtls/models.py create mode 100644 authentik/enterprise/stages/mtls/stage.py create mode 100644 authentik/enterprise/stages/mtls/tests/__init__.py create mode 100644 authentik/enterprise/stages/mtls/tests/fixtures/ca.pem create mode 100644 authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem create mode 100644 authentik/enterprise/stages/mtls/tests/test_stage.py create mode 100644 authentik/enterprise/stages/mtls/urls.py create mode 100644 web/src/admin/brands/Certificates.ts create mode 100644 web/src/admin/stages/mtls/MTLSStageForm.ts diff --git a/Makefile b/Makefile index dca1c20ad0..85be43dd05 100644 --- a/Makefile +++ b/Makefile @@ -169,8 +169,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),) git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO} else - cd ${PWD}/${GEN_API_GO} - git pull + cd ${PWD}/${GEN_API_GO} && git pull endif cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO} make -C ${PWD}/${GEN_API_GO} build diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index ab4b67d731..c9dfeac885 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -1,9 +1,12 @@ """API Authentication""" from hmac import compare_digest +from pathlib import Path +from tempfile import gettempdir from typing import Any from django.conf import settings +from django.contrib.auth.models import AnonymousUser from drf_spectacular.extensions import OpenApiAuthenticationExtension from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.exceptions import AuthenticationFailed @@ -11,11 +14,17 @@ from rest_framework.request import Request from structlog.stdlib import get_logger from authentik.core.middleware import CTX_AUTH_VIA -from authentik.core.models import Token, TokenIntents, User +from authentik.core.models import Token, TokenIntents, User, UserTypes from authentik.outposts.models import Outpost from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API LOGGER = get_logger() +_tmp = Path(gettempdir()) +try: + with open(_tmp / "authentik-core-ipc.key") as _f: + ipc_key = _f.read() +except OSError: + ipc_key = None def validate_auth(header: bytes) -> str | None: @@ -73,6 +82,11 @@ def auth_user_lookup(raw_header: bytes) -> User | None: if user: CTX_AUTH_VIA.set("secret_key") return user + # then try to auth via secret key (for embedded outpost/etc) + user = token_ipc(auth_credentials) + if user: + CTX_AUTH_VIA.set("ipc") + return user raise AuthenticationFailed("Token invalid/expired") @@ -90,6 +104,43 @@ def token_secret_key(value: str) -> User | None: return outpost.user +class IPCUser(AnonymousUser): + """'Virtual' user for IPC communication between authentik core and the authentik router""" + + username = "authentik:system" + is_active = True + is_superuser = True + + @property + def type(self): + return UserTypes.INTERNAL_SERVICE_ACCOUNT + + def has_perm(self, perm, obj=None): + return True + + def has_perms(self, perm_list, obj=None): + return True + + def has_module_perms(self, module): + return True + + @property + def is_anonymous(self): + return False + + @property + def is_authenticated(self): + return True + + +def token_ipc(value: str) -> User | None: + """Check if the token is the secret key + and return the service account for the managed outpost""" + if not ipc_key or not compare_digest(value, ipc_key): + return None + return IPCUser() + + class TokenAuthentication(BaseAuthentication): """Token-based authentication using HTTP Bearer authentication""" diff --git a/authentik/brands/api.py b/authentik/brands/api.py index bcfd0fc6db..b634e8c11e 100644 --- a/authentik/brands/api.py +++ b/authentik/brands/api.py @@ -59,6 +59,7 @@ class BrandSerializer(ModelSerializer): "flow_device_code", "default_application", "web_certificate", + "client_certificates", "attributes", ] extra_kwargs = { @@ -120,6 +121,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet): "domain", "branding_title", "web_certificate__name", + "client_certificates__name", ] filterset_fields = [ "brand_uuid", @@ -136,6 +138,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet): "flow_user_settings", "flow_device_code", "web_certificate", + "client_certificates", ] ordering = ["domain"] diff --git a/authentik/brands/migrations/0010_brand_client_certificates_and_more.py b/authentik/brands/migrations/0010_brand_client_certificates_and_more.py new file mode 100644 index 0000000000..b7c48ac020 --- /dev/null +++ b/authentik/brands/migrations/0010_brand_client_certificates_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.9 on 2025-05-19 15:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_brands", "0009_brand_branding_default_flow_background"), + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ] + + operations = [ + migrations.AddField( + model_name="brand", + name="client_certificates", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Certificates used for client authentication.", + to="authentik_crypto.certificatekeypair", + ), + ), + migrations.AlterField( + model_name="brand", + name="web_certificate", + field=models.ForeignKey( + default=None, + help_text="Web Certificate used by the authentik Core webserver.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/brands/models.py b/authentik/brands/models.py index cc8cc43787..afc56d489a 100644 --- a/authentik/brands/models.py +++ b/authentik/brands/models.py @@ -73,6 +73,13 @@ class Brand(SerializerModel): default=None, on_delete=models.SET_DEFAULT, help_text=_("Web Certificate used by the authentik Core webserver."), + related_name="+", + ) + client_certificates = models.ManyToManyField( + CertificateKeyPair, + default=None, + blank=True, + help_text=_("Certificates used for client authentication."), ) attributes = models.JSONField(default=dict, blank=True) diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index c06fa0cb00..70dd92e519 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -30,6 +30,7 @@ from structlog.stdlib import get_logger from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import ModelSerializer, PassiveSerializer +from authentik.core.models import UserTypes from authentik.crypto.apps import MANAGED_KEY from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg from authentik.crypto.models import CertificateKeyPair @@ -272,11 +273,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): def view_certificate(self, request: Request, pk: str) -> Response: """Return certificate-key pairs certificate and log access""" certificate: CertificateKeyPair = self.get_object() - Event.new( # noqa # nosec - EventAction.SECRET_VIEW, - secret=certificate, - type="certificate", - ).from_http(request) + if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: + Event.new( # noqa # nosec + EventAction.SECRET_VIEW, + secret=certificate, + type="certificate", + ).from_http(request) if "download" in request.query_params: # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html response = HttpResponse( @@ -302,11 +304,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): def view_private_key(self, request: Request, pk: str) -> Response: """Return certificate-key pairs private key and log access""" certificate: CertificateKeyPair = self.get_object() - Event.new( # noqa # nosec - EventAction.SECRET_VIEW, - secret=certificate, - type="private_key", - ).from_http(request) + if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: + Event.new( # noqa # nosec + EventAction.SECRET_VIEW, + secret=certificate, + type="private_key", + ).from_http(request) if "download" in request.query_params: # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html response = HttpResponse(certificate.key_data, content_type="application/x-pem-file") diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index edc607d35d..676b6dc7c4 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -19,6 +19,7 @@ TENANT_APPS = [ "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", + "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", ] diff --git a/authentik/enterprise/stages/mtls/__init__.py b/authentik/enterprise/stages/mtls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/api.py b/authentik/enterprise/stages/mtls/api.py new file mode 100644 index 0000000000..1b7a471deb --- /dev/null +++ b/authentik/enterprise/stages/mtls/api.py @@ -0,0 +1,31 @@ +"""Mutual TLS Stage API Views""" + +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.enterprise.api import EnterpriseRequiredMixin +from authentik.enterprise.stages.mtls.models import MutualTLSStage +from authentik.flows.api.stages import StageSerializer + + +class MutualTLSStageSerializer(EnterpriseRequiredMixin, StageSerializer): + """MutualTLSStage Serializer""" + + class Meta: + model = MutualTLSStage + fields = StageSerializer.Meta.fields + [ + "mode", + "certificate_authorities", + "cert_attribute", + "user_attribute", + ] + + +class MutualTLSStageViewSet(UsedByMixin, ModelViewSet): + """MutualTLSStage Viewset""" + + queryset = MutualTLSStage.objects.all() + serializer_class = MutualTLSStageSerializer + filterset_fields = "__all__" + ordering = ["name"] + search_fields = ["name"] diff --git a/authentik/enterprise/stages/mtls/apps.py b/authentik/enterprise/stages/mtls/apps.py new file mode 100644 index 0000000000..0d75e6e593 --- /dev/null +++ b/authentik/enterprise/stages/mtls/apps.py @@ -0,0 +1,12 @@ +"""authentik stage app config""" + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseStageMTLSConfig(EnterpriseConfig): + """authentik MTLS stage config""" + + name = "authentik.enterprise.stages.mtls" + label = "authentik_stages_mtls" + verbose_name = "authentik Enterprise.Stages.MTLS" + default = True diff --git a/authentik/enterprise/stages/mtls/migrations/0001_initial.py b/authentik/enterprise/stages/mtls/migrations/0001_initial.py new file mode 100644 index 0000000000..09d56deb4d --- /dev/null +++ b/authentik/enterprise/stages/mtls/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.9 on 2025-05-19 18:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ("authentik_flows", "0027_auto_20231028_1424"), + ] + + operations = [ + migrations.CreateModel( + name="MutualTLSStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ( + "mode", + models.TextField(choices=[("optional", "Optional"), ("required", "Required")]), + ), + ( + "cert_attribute", + models.TextField( + choices=[ + ("subject", "Subject"), + ("common_name", "Common Name"), + ("email", "Email"), + ] + ), + ), + ( + "user_attribute", + models.TextField(choices=[("username", "Username"), ("email", "Email")]), + ), + ( + "certificate_authorities", + models.ManyToManyField( + blank=True, + default=None, + help_text="Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`.", + to="authentik_crypto.certificatekeypair", + ), + ), + ], + options={ + "verbose_name": "Mutual TLS Stage", + "verbose_name_plural": "Mutual TLS Stages", + "permissions": [ + ("pass_outpost_certificate", "Permissions to pass Certificates for outposts.") + ], + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/enterprise/stages/mtls/migrations/__init__.py b/authentik/enterprise/stages/mtls/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/models.py b/authentik/enterprise/stages/mtls/models.py new file mode 100644 index 0000000000..e37f50224d --- /dev/null +++ b/authentik/enterprise/stages/mtls/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import Serializer + +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Stage +from authentik.flows.stage import StageView + + +class TLSMode(models.TextChoices): + """Modes the TLS Stage can operate in""" + + OPTIONAL = "optional" + REQUIRED = "required" + + +class CertAttributes(models.TextChoices): + """Certificate attribute used for user matching""" + + SUBJECT = "subject" + COMMON_NAME = "common_name" + EMAIL = "email" + + +class UserAttributes(models.TextChoices): + """User attribute for user matching""" + + USERNAME = "username" + EMAIL = "email" + + +class MutualTLSStage(Stage): + """Authenticate/enroll users using a client-certificate.""" + + mode = models.TextField(choices=TLSMode.choices) + + certificate_authorities = models.ManyToManyField( + CertificateKeyPair, + default=None, + blank=True, + help_text=_( + "Configure certificate authorities to validate the certificate against. " + "This option has a higher priority than the `client_certificate` option on `Brand`." + ), + ) + + cert_attribute = models.TextField(choices=CertAttributes.choices) + user_attribute = models.TextField(choices=UserAttributes.choices) + + @property + def view(self) -> type[StageView]: + from authentik.enterprise.stages.mtls.stage import MTLSStageView + + return MTLSStageView + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.stages.mtls.api import MutualTLSStageSerializer + + return MutualTLSStageSerializer + + @property + def component(self) -> str: + return "ak-stage-mtls-form" + + class Meta: + verbose_name = _("Mutual TLS Stage") + verbose_name_plural = _("Mutual TLS Stages") + permissions = [ + ("pass_outpost_certificate", _("Permissions to pass Certificates for outposts.")), + ] diff --git a/authentik/enterprise/stages/mtls/stage.py b/authentik/enterprise/stages/mtls/stage.py new file mode 100644 index 0000000000..650b4ea3ca --- /dev/null +++ b/authentik/enterprise/stages/mtls/stage.py @@ -0,0 +1,215 @@ +from binascii import hexlify +from urllib.parse import unquote_plus + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.x509 import Certificate, NameOID, ObjectIdentifier, load_pem_x509_certificate +from django.utils.translation import gettext_lazy as _ + +from authentik.brands.models import Brand +from authentik.core.models import User +from authentik.crypto.models import CertificateKeyPair +from authentik.enterprise.stages.mtls.models import ( + CertAttributes, + MutualTLSStage, + TLSMode, + UserAttributes, +) +from authentik.flows.challenge import AccessDeniedChallenge +from authentik.flows.models import FlowDesignation +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import ChallengeStageView +from authentik.root.middleware import ClientIPMiddleware +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +# All of these headers must only be accepted from "trusted" reverse proxies +# See internal/web/proxy.go:39 +HEADER_PROXY_FORWARDED = "X-Forwarded-Client-Cert" +HEADER_NGINX_FORWARDED = "SSL-Client-Cert" +HEADER_TRAEFIK_FORWARDED = "X-Forwarded-TLS-Client-Cert" +HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate" + + +PLAN_CONTEXT_CERTIFICATE = "certificate" + + +class MTLSStageView(ChallengeStageView): + + def __parse_single_cert(self, raw: str | None) -> list[Certificate]: + """Helper to parse a single certificate""" + if not raw: + return [] + try: + cert = load_pem_x509_certificate(unquote_plus(raw).encode()) + return [cert] + except ValueError as exc: + self.logger.info("Failed to parse certificate", exc=exc) + return [] + + def _parse_cert_xfcc(self) -> list[Certificate]: + """Parse certificates in the format given to us in + the format of the authentik router/envoy""" + xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED) + if not xfcc_raw: + return [] + certs = [] + for r_cert in xfcc_raw.split(","): + el = r_cert.split(";") + raw_cert = {k.split("=")[0]: k.split("=")[1] for k in el} + if "Cert" not in raw_cert: + continue + certs.extend(self.__parse_single_cert(raw_cert["Cert"])) + return certs + + def _parse_cert_nginx(self) -> list[Certificate]: + """Parse certificates in the format nginx-ingress gives to us""" + sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED) + return self.__parse_single_cert(sslcc_raw) + + def _parse_cert_traefik(self) -> list[Certificate]: + """Parse certificates in the format traefik gives to us""" + ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED) + return self.__parse_single_cert(ftcc_raw) + + def _parse_cert_outpost(self) -> list[Certificate]: + """Parse certificates in the format outposts give to us. Also authenticates + the outpost to ensure it has the permission to do so""" + user = ClientIPMiddleware.get_outpost_user(self.request) + if not user: + return [] + if not user.has_perm( + "pass_outpost_certificate", self.executor.current_stage + ) and not user.has_perm("authentik_stages_mtls.pass_outpost_certificate"): + return [] + outpost_raw = self.request.headers.get(HEADER_OUTPOST_FORWARDED) + return self.__parse_single_cert(outpost_raw) + + def get_authorities(self) -> list[CertificateKeyPair] | None: + # We can't access `certificate_authorities` on `self.executor.current_stage`, as that would + # load the certificate into the directly referenced foreign key, which we have to pickle + # as part of the flow plan, and cryptography certs can't be pickled + stage: MutualTLSStage = ( + MutualTLSStage.objects.filter(pk=self.executor.current_stage.pk) + .prefetch_related("certificate_authorities") + .first() + ) + if stage.certificate_authorities.exists(): + return stage.certificate_authorities.order_by("name") + brand: Brand = self.request.brand + if brand.client_certificates.exists(): + return brand.client_certificates.order_by("name") + return None + + def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]): + for _cert in certs: + for ca in authorities: + try: + _cert.verify_directly_issued_by(ca.certificate) + return _cert + except (InvalidSignature, TypeError, ValueError) as exc: + self.logger.warning( + "Discarding cert not issued by authority", cert=_cert, authority=ca, exc=exc + ) + continue + return None + + def check_if_user(self, cert: Certificate): + stage: MutualTLSStage = self.executor.current_stage + cert_attr = None + user_attr = None + match stage.cert_attribute: + case CertAttributes.SUBJECT: + cert_attr = cert.subject.rfc4514_string() + case CertAttributes.COMMON_NAME: + cert_attr = self.get_cert_attribute(cert, NameOID.COMMON_NAME) + case CertAttributes.EMAIL: + cert_attr = self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS) + match stage.user_attribute: + case UserAttributes.USERNAME: + user_attr = "username" + case UserAttributes.EMAIL: + user_attr = "email" + if not user_attr or not cert_attr: + return None + return User.objects.filter(**{user_attr: cert_attr}).first() + + def _cert_to_dict(self, cert: Certificate) -> dict: + """Represent a certificate in a dictionary, as certificate objects cannot be pickled""" + return { + "serial_number": str(cert.serial_number), + "subject": cert.subject.rfc4514_string(), + "issuer": cert.issuer.rfc4514_string(), + "fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"), + "fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"), + } + + def auth_user(self, user: User, cert: Certificate): + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "mtls") + self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) + self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].update( + {"certificate": self._cert_to_dict(cert)} + ) + + def enroll_prepare_user(self, cert: Certificate): + self.executor.plan.context.setdefault(PLAN_CONTEXT_PROMPT, {}) + self.executor.plan.context[PLAN_CONTEXT_PROMPT].update( + { + "email": self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS), + "name": self.get_cert_attribute(cert, NameOID.COMMON_NAME), + } + ) + self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert) + + def get_cert_attribute(self, cert: Certificate, oid: ObjectIdentifier) -> str | None: + attr = cert.subject.get_attributes_for_oid(oid) + if len(attr) < 1: + return None + return str(attr[0].value) + + def dispatch(self, request, *args, **kwargs): + stage: MutualTLSStage = self.executor.current_stage + certs = [ + *self._parse_cert_xfcc(), + *self._parse_cert_nginx(), + *self._parse_cert_traefik(), + *self._parse_cert_outpost(), + ] + authorities = self.get_authorities() + if not authorities: + self.logger.warning("No Certificate authority found") + if stage.mode == TLSMode.OPTIONAL: + return self.executor.stage_ok() + if stage.mode == TLSMode.REQUIRED: + return super().dispatch(request, *args, **kwargs) + cert = self.validate_cert(authorities, certs) + if not cert and stage.mode == TLSMode.REQUIRED: + self.logger.warning("Client certificate required but no certificates given") + return super().dispatch( + request, + *args, + error_message=_("Certificate required but no certificate was given."), + **kwargs, + ) + if not cert and stage.mode == TLSMode.OPTIONAL: + self.logger.info("No certificate given, continuing") + return self.executor.stage_ok() + existing_user = self.check_if_user(cert) + if self.executor.flow.designation == FlowDesignation.ENROLLMENT: + self.enroll_prepare_user(cert) + elif existing_user: + self.auth_user(existing_user, cert) + else: + return super().dispatch( + request, *args, error_message=_("No user found for certificate."), **kwargs + ) + return self.executor.stage_ok() + + def get_challenge(self, *args, error_message: str | None = None, **kwargs): + return AccessDeniedChallenge( + data={ + "component": "ak-stage-access-denied", + "error_message": str(error_message or "Unknown error"), + } + ) diff --git a/authentik/enterprise/stages/mtls/tests/__init__.py b/authentik/enterprise/stages/mtls/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem b/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem new file mode 100644 index 0000000000..68d2c41b97 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFXDCCA0SgAwIBAgIUBmV7zREyC1SPr72/75/L9zpwV18wDQYJKoZIhvcNAQEL +BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl +bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMDUwWhcNMzUw +MzA3MTgzMDUwWjBGMRowGAYDVQQDDBFhdXRoZW50aWsgVGVzdCBDQTESMBAGA1UE +CgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAMc0NxZj7j1mPu0aRToo8oMPdC3T99xgxnqdr18x +LV4pWyi/YLghgZHqNQY2xNP6JIlSeUZD6KFUYT2sPL4Av/zSg5zO8bl+/lf7ckje +O1/Bt5A8xtL0CpmpMDGiI6ibdDElaywM6AohisbxrV29pygSKGq2wugF/urqGtE+ +5z4y5Kt6qMdKkd0iXT+WagbQTIUlykFKgB0+qqTLzDl01lVDa/DoLl8Hqp45mVx2 +pqrGsSa3TCErLIv9hUlZklF7A8UV4ZB4JL20UKcP8dKzQClviNie17tpsUpOuy3A +SQ6+guWTHTLJNCSdLn1xIqc5q+f5wd2dIDf8zXCTHj+Xp0bJE3Vgaq5R31K9+b+1 +2dDWz1KcNJaLEnw2+b0O8M64wTMLxhqOv7QfLUr6Pmg1ZymghjLcZ6bnU9e31Vza +hlPKhxjqYQUC4Kq+oaYF6qdUeJy+dsYf0iDv5tTC+eReZDWIjxTPrNpwA773ZwT7 +WVmL7ULGpuP2g9rNvFBcZiN+i6d7CUoN+jd/iRdo79lrI0dfXiyy4bYgW/2HeZfF +HaOsc1xsoqnJdWbWkX/ooyaCjAfm07kS3HiOzz4q3QW4wgGrwV8lEraLPxYYeOQu +YcGMOM8NfnVkjc8gmyXUxedCje5Vz/Tu5fKrQEInnCmXxVsWbwr/LzEjMKAM/ivY +0TXxAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0G +A1UdDgQWBBTa+Ns6QzqlNvnTGszkouQQtZnVJDANBgkqhkiG9w0BAQsFAAOCAgEA +NpJEDMXjuEIzSzafkxSshvjnt5sMYmzmvjNoRlkxgN2YcWvPoxbalGAYzcpyggT2 +6xZY8R4tvB1oNTCArqwf860kkofUoJCr88D/pU3Cv4JhjCWs4pmXTsvSqlBSlJbo ++jPBZwbn6it/6jcit6Be3rW2PtHe8tASd9Lf8/2r1ZvupXwPzcR84R4Z10ve2lqV +xxcWlMmBh51CaYI0b1/WTe9Ua+wgkCVkxbf9zNcDQXjxw2ICWK+nR/4ld4nmqVm2 +C7nhvXwU8FAHl7ZgR2Z3PLrwPuhd+kd6NXQqNkS9A+n+1vSRLbRjmV8pwIPpdPEq +nslUAGJJBHDUBArxC3gOJSB+WtmaCfzDu2gepMf9Ng1H2ZhwSF/FH3v3fsJqZkzz +NBstT9KuNGQRYiCmAPJaoVAc9BoLa+BFML1govtWtpdmbFk8PZEcuUsP7iAZqFF1 +uuldPyZ8huGpQSR6Oq2bILRHowfGY0npTZAyxg0Vs8UMy1HTwNOp9OuRtArMZmsJ +jFIx1QzRf9S1i6bYpOzOudoXj4ARkS1KmVExGjJFcIT0xlFSSERie2fEKSeEYOyG +G+PA2qRt/F51FGOMm1ZscjPXqk2kt3C4BFbz6Vvxsq7D3lmhvFLn4jVA8+OidsM0 +YUrVMtWET/RkjEIbADbgRXxNUNo+jtQZDU9C1IiAdfk= +-----END CERTIFICATE----- diff --git a/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem b/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem new file mode 100644 index 0000000000..85bb9da1d9 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFOzCCAyOgAwIBAgIUbnIMy+Ewi5RvK7OBDxWMCk7wi08wDQYJKoZIhvcNAQEL +BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl +bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMTE3WhcNMjYw +NDIzMTgzMTE3WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQCdV+GEa7+7ito1i/z637OZW+0azv1kuF2aDwSzv+FJd+4L +6hCroRbVYTUFS3I3YwanOOZfau64xH0+pFM5Js8aREG68eqKBayx8vT27hyAOFhd +giEVmSQJfla4ogvPie1rJ0HVOL7CiR72HDPQvz+9k1iDX3xQ/4sdAb3XurN13e+M +Gtavhjiyqxmoo/H4WRd8BhD/BZQFWtaxWODDY8aKk5R7omw6Xf7aRv1BlHdE4Ucy +Wozvpsj2Kz0l61rRUhiMlE0D9dpijgaRYFB+M7R2casH3CdhGQbBHTRiqBkZa6iq +SDkTiTwNJQQJov8yPTsR+9P8OOuV6QN+DGm/FXJJFaPnsHw/HDy7EAbA1PcdbSyK +XvJ8nVjdNhCEGbLGVSwAQLO+78hChVIN5YH+QSrP84YBSxKZYArnf4z2e9drqAN3 +KmC26TkaUzkXnndnxOXBEIOSmyCdD4Dutg1XPE/bs8rA6rVGIR3pKXbCr29Z8hZn +Cn9jbxwDwTX865ljR1Oc3dnIeCWa9AS/uHaSMdGlbGbDrt4Bj/nyyfu8xc034K/0 +uPh3hF3FLWNAomRVZCvtuh/v7IEIQEgUbvQMWBhZJ8hu3HdtV8V9TIAryVKzEzGy +Q72UHuQyK0njRDTmA/T+jn7P8GWOuf9eNdzd0gH0gcEuhCZFxPPRvUAeDuC7DQID +AQABo1YwVDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwFAYDVR0RAQH/ +BAowCIIGY2xpZW50MB0GA1UdDgQWBBQ5KZwTD8+4CqLnbM/cBSXg8XeLXTANBgkq +hkiG9w0BAQsFAAOCAgEABDkb3iyEOl1xKq1wxyRzf2L8qfPXAQw71FxMbgHArM+a +e44wJGO3mZgPH0trOaJ+tuN6erB5YbZfsoX+xFacwskj9pKyb4QJLr/ENmJZRgyL +wp5P6PB6IUJhvryvy/GxrG938YGmFtYQ+ikeJw5PWhB6218C1aZ9hsi94wZ1Zzrc +Ry0q0D4QvIEZ0X2HL1Harc7gerE3VqhgQ7EWyImM+lCRtNDduwDQnZauwhr2r6cW +XG4VTe1RCNsDA0xinXQE2Xf9voCd0Zf6wOOXJseQtrXpf+tG4N13cy5heF5ihed1 +hDxSeki0KjTM+18kVVfVm4fzxf1Zg0gm54UlzWceIWh9EtnWMUV08H0D1M9YNmW8 +hWTupk7M+jAw8Y+suHOe6/RLi0+fb9NSJpIpq4GqJ5UF2kerXHX0SvuAavoXyB0j +CQrUXkRScEKOO2KAbVExSG56Ff7Ee8cRUAQ6rLC5pQRACq/R0sa6RcUsFPXul3Yv +vbO2rTuArAUPkNVFknwkndheN4lOslRd1If02HunZETmsnal6p+nmuMWt2pQ2fDA +vIguG54FyQ1T1IbF/QhfTEY62CQAebcgutnqqJHt9qe7Jr6ev57hMrJDEjotSzkY +OhOVrcYqgLldr1nBqNVlIK/4VrDaWH8H5dNJ72gA9aMNVH4/bSTJhuO7cJkLnHw= +-----END CERTIFICATE----- diff --git a/authentik/enterprise/stages/mtls/tests/test_stage.py b/authentik/enterprise/stages/mtls/tests/test_stage.py new file mode 100644 index 0000000000..caf8b828b1 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/test_stage.py @@ -0,0 +1,213 @@ +from unittest.mock import MagicMock, patch +from urllib.parse import quote_plus + +from django.urls import reverse +from guardian.shortcuts import assign_perm + +from authentik.core.models import User +from authentik.core.tests.utils import create_test_brand, create_test_flow, create_test_user +from authentik.crypto.models import CertificateKeyPair +from authentik.enterprise.stages.mtls.models import ( + CertAttributes, + MutualTLSStage, + TLSMode, + UserAttributes, +) +from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE +from authentik.flows.models import FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id +from authentik.lib.tests.utils import load_fixture +from authentik.outposts.models import Outpost, OutpostType +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class MTLSStageTests(FlowTestCase): + + def setUp(self): + super().setUp() + self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) + self.ca = CertificateKeyPair.objects.create( + name=generate_id(), + certificate_data=load_fixture("fixtures/ca.pem"), + ) + self.stage = MutualTLSStage.objects.create( + name=generate_id(), + mode=TLSMode.REQUIRED, + cert_attribute=CertAttributes.COMMON_NAME, + user_attribute=UserAttributes.USERNAME, + ) + + self.stage.certificate_authorities.add(self.ca) + self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) + self.client_cert = load_fixture("fixtures/cert_client.pem") + # User matching the certificate + User.objects.filter(username="client").delete() + self.cert_user = create_test_user(username="client") + + def test_parse_xfcc(self): + """Test authentik Proxy/Envoy's XFCC format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-Client-Cert": f"Cert={quote_plus(self.client_cert)}"}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_nginx(self): + """Test nginx's format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"SSL-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_traefik(self): + """Test traefik's format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_object(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + assign_perm("pass_outpost_certificate", outpost.user, self.stage) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_global(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + assign_perm("authentik_stages_mtls.pass_outpost_certificate", outpost.user) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_no_perm(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_auth_no_user(self): + """Test auth with no user""" + User.objects.filter(username="client").delete() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_brand_ca(self): + """Test using a CA from the brand""" + self.stage.certificate_authorities.clear() + + brand = create_test_brand() + brand.client_certificates.add(self.ca) + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_no_ca_optional(self): + """Test using no CA Set""" + self.stage.mode = TLSMode.OPTIONAL + self.stage.certificate_authorities.clear() + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + + def test_no_ca_required(self): + """Test using no CA Set""" + self.stage.certificate_authorities.clear() + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_no_cert_optional(self): + """Test using no cert Set""" + self.stage.mode = TLSMode.OPTIONAL + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + + def test_enroll(self): + """Test Enrollment flow""" + self.flow.designation = FlowDesignation.ENROLLMENT + self.flow.save() + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PROMPT], {"email": None, "name": "client"}) + self.assertEqual( + plan().context[PLAN_CONTEXT_CERTIFICATE], + { + "fingerprint_sha1": ( + "08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc" + ), + "fingerprint_sha256": ( + "08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc" + ), + "issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA", + "serial_number": "630532384467334865093173111400266136879266564943", + "subject": "CN=client", + }, + ) diff --git a/authentik/enterprise/stages/mtls/urls.py b/authentik/enterprise/stages/mtls/urls.py new file mode 100644 index 0000000000..28941718bd --- /dev/null +++ b/authentik/enterprise/stages/mtls/urls.py @@ -0,0 +1,5 @@ +"""API URLs""" + +from authentik.enterprise.stages.mtls.api import MutualTLSStageViewSet + +api_urlpatterns = [("stages/mtls", MutualTLSStageViewSet)] diff --git a/authentik/flows/tests/__init__.py b/authentik/flows/tests/__init__.py index cef2436566..cb861a70b6 100644 --- a/authentik/flows/tests/__init__.py +++ b/authentik/flows/tests/__init__.py @@ -1,7 +1,10 @@ """Test helpers""" +from collections.abc import Callable, Generator +from contextlib import contextmanager from json import loads from typing import Any +from unittest.mock import MagicMock, patch from django.http.response import HttpResponse from django.urls.base import reverse @@ -9,6 +12,8 @@ from rest_framework.test import APITestCase from authentik.core.models import User from authentik.flows.models import Flow +from authentik.flows.planner import FlowPlan +from authentik.flows.views.executor import SESSION_KEY_PLAN class FlowTestCase(APITestCase): @@ -44,3 +49,12 @@ class FlowTestCase(APITestCase): def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]: """Wrapper around assertStageResponse that checks for a redirect""" return self.assertStageResponse(response, component="xak-flow-redirect", to=to) + + @contextmanager + def assertFlowFinishes(self) -> Generator[Callable[[], FlowPlan]]: + """Capture the flow plan before the flow finishes and return it""" + try: + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): + yield lambda: self.client.session.get(SESSION_KEY_PLAN) + finally: + pass diff --git a/blueprints/schema.json b/blueprints/schema.json index 27b434bd62..437f23172e 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3921,6 +3921,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_mtls.mutualtlsstage" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage" + } + } + }, { "type": "object", "required": [ @@ -4867,6 +4907,7 @@ "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", + "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", "authentik.events" ], @@ -4977,6 +5018,7 @@ "authentik_providers_microsoft_entra.microsoftentraprovidermapping", "authentik_providers_ssf.ssfprovider", "authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage", + "authentik_stages_mtls.mutualtlsstage", "authentik_stages_source.sourcestage", "authentik_events.event", "authentik_events.notificationtransport", @@ -7477,6 +7519,11 @@ "authentik_stages_invitation.delete_invitationstage", "authentik_stages_invitation.view_invitation", "authentik_stages_invitation.view_invitationstage", + "authentik_stages_mtls.add_mutualtlsstage", + "authentik_stages_mtls.change_mutualtlsstage", + "authentik_stages_mtls.delete_mutualtlsstage", + "authentik_stages_mtls.pass_outpost_certificate", + "authentik_stages_mtls.view_mutualtlsstage", "authentik_stages_password.add_passwordstage", "authentik_stages_password.change_passwordstage", "authentik_stages_password.delete_passwordstage", @@ -13422,6 +13469,16 @@ "title": "Web certificate", "description": "Web Certificate used by the authentik Core webserver." }, + "client_certificates": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "description": "Certificates used for client authentication." + }, + "title": "Client certificates", + "description": "Certificates used for client authentication." + }, "attributes": { "type": "object", "additionalProperties": true, @@ -14185,6 +14242,11 @@ "authentik_stages_invitation.delete_invitationstage", "authentik_stages_invitation.view_invitation", "authentik_stages_invitation.view_invitationstage", + "authentik_stages_mtls.add_mutualtlsstage", + "authentik_stages_mtls.change_mutualtlsstage", + "authentik_stages_mtls.delete_mutualtlsstage", + "authentik_stages_mtls.pass_outpost_certificate", + "authentik_stages_mtls.view_mutualtlsstage", "authentik_stages_password.add_passwordstage", "authentik_stages_password.change_passwordstage", "authentik_stages_password.delete_passwordstage", @@ -15088,6 +15150,161 @@ } } }, + "model_authentik_stages_mtls.mutualtlsstage": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "flow_set": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "pattern": "^[-a-zA-Z0-9_]+$", + "title": "Slug", + "description": "Visible in the URL." + }, + "title": { + "type": "string", + "minLength": 1, + "title": "Title", + "description": "Shown as the Title in Flow pages." + }, + "designation": { + "type": "string", + "enum": [ + "authentication", + "authorization", + "invalidation", + "enrollment", + "unenrollment", + "recovery", + "stage_configuration" + ], + "title": "Designation", + "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." + }, + "policy_engine_mode": { + "type": "string", + "enum": [ + "all", + "any" + ], + "title": "Policy engine mode" + }, + "compatibility_mode": { + "type": "boolean", + "title": "Compatibility mode", + "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices." + }, + "layout": { + "type": "string", + "enum": [ + "stacked", + "content_left", + "content_right", + "sidebar_left", + "sidebar_right" + ], + "title": "Layout" + }, + "denied_action": { + "type": "string", + "enum": [ + "message_continue", + "message", + "continue" + ], + "title": "Denied action", + "description": "Configure what should happen when a flow denies access to a user." + } + }, + "required": [ + "name", + "slug", + "title", + "designation" + ] + }, + "title": "Flow set" + }, + "mode": { + "type": "string", + "enum": [ + "optional", + "required" + ], + "title": "Mode" + }, + "certificate_authorities": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`." + }, + "title": "Certificate authorities", + "description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`." + }, + "cert_attribute": { + "type": "string", + "enum": [ + "subject", + "common_name", + "email" + ], + "title": "Cert attribute" + }, + "user_attribute": { + "type": "string", + "enum": [ + "username", + "email" + ], + "title": "User attribute" + } + }, + "required": [] + }, + "model_authentik_stages_mtls.mutualtlsstage_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "pass_outpost_certificate", + "add_mutualtlsstage", + "change_mutualtlsstage", + "delete_mutualtlsstage", + "view_mutualtlsstage" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_source.sourcestage": { "type": "object", "properties": { diff --git a/cmd/server/server.go b/cmd/server/server.go index a09451d125..4091c20957 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -19,7 +19,6 @@ import ( sentryutils "goauthentik.io/internal/utils/sentry" webutils "goauthentik.io/internal/utils/web" "goauthentik.io/internal/web" - "goauthentik.io/internal/web/brand_tls" ) var rootCmd = &cobra.Command{ @@ -67,12 +66,12 @@ var rootCmd = &cobra.Command{ } ws := web.NewWebServer() - ws.Core().HealthyCallback = func() { + ws.Core().AddHealthyCallback(func() { if config.Get().Outposts.DisableEmbeddedOutpost { return } go attemptProxyStart(ws, u) - } + }) ws.Start() <-ex l.Info("shutting down webserver") @@ -95,13 +94,8 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) { } continue } - // Init brand_tls here too since it requires an API Client, - // so we just reuse the same one as the outpost uses - tw := brand_tls.NewWatcher(ac.Client) - go tw.Start() - ws.BrandTLS = tw ac.AddRefreshHandler(func() { - tw.Check() + ws.BrandTLS.Check() }) srv := proxyv2.NewProxyServer(ac) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 785ee99917..6b533450de 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -21,10 +21,14 @@ func FullVersion() string { return ver } -func OutpostUserAgent() string { +func UserAgentOutpost() string { return fmt.Sprintf("goauthentik.io/outpost/%s", FullVersion()) } +func UserAgentIPC() string { + return fmt.Sprintf("goauthentik.io/ipc/%s", FullVersion()) +} + func UserAgent() string { return fmt.Sprintf("authentik@%s", FullVersion()) } diff --git a/internal/gounicorn/gounicorn.go b/internal/gounicorn/gounicorn.go index 6478b91981..3013d6daa6 100644 --- a/internal/gounicorn/gounicorn.go +++ b/internal/gounicorn/gounicorn.go @@ -18,8 +18,8 @@ import ( ) type GoUnicorn struct { - Healthcheck func() bool - HealthyCallback func() + Healthcheck func() bool + healthyCallbacks []func() log *log.Entry p *exec.Cmd @@ -32,12 +32,12 @@ type GoUnicorn struct { func New(healthcheck func() bool) *GoUnicorn { logger := log.WithField("logger", "authentik.router.unicorn") g := &GoUnicorn{ - Healthcheck: healthcheck, - log: logger, - started: false, - killed: false, - alive: false, - HealthyCallback: func() {}, + Healthcheck: healthcheck, + log: logger, + started: false, + killed: false, + alive: false, + healthyCallbacks: []func(){}, } g.initCmd() c := make(chan os.Signal, 1) @@ -79,6 +79,10 @@ func (g *GoUnicorn) initCmd() { g.p.Stderr = os.Stderr } +func (g *GoUnicorn) AddHealthyCallback(cb func()) { + g.healthyCallbacks = append(g.healthyCallbacks, cb) +} + func (g *GoUnicorn) IsRunning() bool { return g.alive } @@ -101,7 +105,9 @@ func (g *GoUnicorn) healthcheck() { if g.Healthcheck() { g.alive = true g.log.Debug("backend is alive, backing off with healthchecks") - g.HealthyCallback() + for _, cb := range g.healthyCallbacks { + cb() + } break } g.log.Debug("backend not alive yet") diff --git a/internal/outpost/ak/api.go b/internal/outpost/ak/api.go index 11a69fba30..0d18fc6d1b 100644 --- a/internal/outpost/ak/api.go +++ b/internal/outpost/ak/api.go @@ -62,7 +62,7 @@ func NewAPIController(akURL url.URL, token string) *APIController { apiConfig.Scheme = akURL.Scheme apiConfig.HTTPClient = &http.Client{ Transport: web.NewUserAgentTransport( - constants.OutpostUserAgent(), + constants.UserAgentOutpost(), web.NewTracingTransport( rsp.Context(), GetTLSTransport(), diff --git a/internal/outpost/ak/api_ws.go b/internal/outpost/ak/api_ws.go index 62f4e9ea48..a45738f6c1 100644 --- a/internal/outpost/ak/api_ws.go +++ b/internal/outpost/ak/api_ws.go @@ -38,7 +38,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { header := http.Header{ "Authorization": []string{authHeader}, - "User-Agent": []string{constants.OutpostUserAgent()}, + "User-Agent": []string{constants.UserAgentOutpost()}, } dialer := websocket.Dialer{ diff --git a/internal/outpost/ak/crypto.go b/internal/outpost/ak/crypto.go index 824bd526ef..afab8015d3 100644 --- a/internal/outpost/ak/crypto.go +++ b/internal/outpost/ak/crypto.go @@ -3,6 +3,8 @@ package ak import ( "context" "crypto/tls" + "crypto/x509" + "encoding/pem" log "github.com/sirupsen/logrus" "goauthentik.io/api/v3" @@ -67,16 +69,34 @@ func (cs *CryptoStore) Fetch(uuid string) error { return err } - x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data)) - if err != nil { - return err + var tcert tls.Certificate + if key.Data != "" { + x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data)) + if err != nil { + return err + } + tcert = x509cert + } else { + p, _ := pem.Decode([]byte(cert.Data)) + x509cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return err + } + tcert = tls.Certificate{ + Certificate: [][]byte{x509cert.Raw}, + Leaf: x509cert, + } } - cs.certificates[uuid] = &x509cert + cs.certificates[uuid] = &tcert cs.fingerprints[uuid] = cfp return nil } func (cs *CryptoStore) Get(uuid string) *tls.Certificate { + c, ok := cs.certificates[uuid] + if ok { + return c + } err := cs.Fetch(uuid) if err != nil { cs.log.WithError(err).Warning("failed to fetch certificate") diff --git a/internal/outpost/ak/global.go b/internal/outpost/ak/global.go index 19047fa5d9..4c1072082e 100644 --- a/internal/outpost/ak/global.go +++ b/internal/outpost/ak/global.go @@ -55,7 +55,7 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) { EnableTracing: true, TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)), Release: fmt.Sprintf("authentik@%s", constants.VERSION), - HTTPTransport: webutils.NewUserAgentTransport(constants.OutpostUserAgent(), http.DefaultTransport), + HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport), IgnoreErrors: []string{ http.ErrAbortHandler.Error(), }, diff --git a/internal/outpost/flow/executor.go b/internal/outpost/flow/executor.go index 162aafb7e6..465715472e 100644 --- a/internal/outpost/flow/executor.go +++ b/internal/outpost/flow/executor.go @@ -61,7 +61,7 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config l.WithError(err).Warning("Failed to create cookiejar") panic(err) } - transport := web.NewUserAgentTransport(constants.OutpostUserAgent(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport())) + transport := web.NewUserAgentTransport(constants.UserAgentOutpost(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport())) fe := &FlowExecutor{ Params: url.Values{}, Answers: make(map[StageComponent]string), diff --git a/internal/outpost/proxyv2/application/mode_common.go b/internal/outpost/proxyv2/application/mode_common.go index 3014241092..7972b490ba 100644 --- a/internal/outpost/proxyv2/application/mode_common.go +++ b/internal/outpost/proxyv2/application/mode_common.go @@ -52,7 +52,7 @@ func (a *Application) addHeaders(headers http.Header, c *Claims) { headers.Set("X-authentik-meta-outpost", a.outpostName) headers.Set("X-authentik-meta-provider", a.proxyConfig.Name) headers.Set("X-authentik-meta-app", a.proxyConfig.AssignedApplicationSlug) - headers.Set("X-authentik-meta-version", constants.OutpostUserAgent()) + headers.Set("X-authentik-meta-version", constants.UserAgentOutpost()) if c.Proxy == nil { return diff --git a/internal/outpost/proxyv2/refresh.go b/internal/outpost/proxyv2/refresh.go index cddd4363c1..b4112b4316 100644 --- a/internal/outpost/proxyv2/refresh.go +++ b/internal/outpost/proxyv2/refresh.go @@ -31,7 +31,7 @@ func (ps *ProxyServer) Refresh() error { ua := fmt.Sprintf(" (provider=%s)", provider.Name) hc := &http.Client{ Transport: web.NewUserAgentTransport( - constants.OutpostUserAgent()+ua, + constants.UserAgentOutpost()+ua, web.NewTracingTransport( rsp.Context(), ak.GetTLSTransport(), diff --git a/internal/outpost/rac/connection/connection.go b/internal/outpost/rac/connection/connection.go index 53ca9ecb0b..714d7ccb61 100644 --- a/internal/outpost/rac/connection/connection.go +++ b/internal/outpost/rac/connection/connection.go @@ -61,7 +61,7 @@ func (c *Connection) initSocket(forChannel string) error { header := http.Header{ "Authorization": []string{authHeader}, - "User-Agent": []string{constants.OutpostUserAgent()}, + "User-Agent": []string{constants.UserAgentOutpost()}, } dialer := websocket.Dialer{ diff --git a/internal/utils/web/http_forwarded.go b/internal/utils/web/http_forwarded.go index becf32e94b..95571dead8 100644 --- a/internal/utils/web/http_forwarded.go +++ b/internal/utils/web/http_forwarded.go @@ -1,6 +1,7 @@ package web import ( + "context" "net" "net/http" @@ -9,6 +10,14 @@ import ( "goauthentik.io/internal/config" ) +type allowedProxyRequestContext string + +const allowedProxyRequest allowedProxyRequestContext = "" + +func IsRequestFromTrustedProxy(r *http.Request) bool { + return r.Context().Value(allowedProxyRequest) != nil +} + // ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection // comes from a client that's in a list of trusted CIDRs func ProxyHeaders() func(http.Handler) http.Handler { @@ -20,7 +29,6 @@ func ProxyHeaders() func(http.Handler) http.Handler { } nets = append(nets, cidr) } - ph := handlers.ProxyHeaders return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.RemoteAddr) @@ -30,7 +38,8 @@ func ProxyHeaders() func(http.Handler) http.Handler { for _, allowedCidr := range nets { if remoteAddr != nil && allowedCidr.Contains(remoteAddr) { log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers") - ph(h).ServeHTTP(w, r) + rr := r.WithContext(context.WithValue(r.Context(), allowedProxyRequest, true)) + handlers.ProxyHeaders(h).ServeHTTP(w, rr) return } } diff --git a/internal/web/brand_tls/brand_tls.go b/internal/web/brand_tls/brand_tls.go index 107cf904c5..670fc3b305 100644 --- a/internal/web/brand_tls/brand_tls.go +++ b/internal/web/brand_tls/brand_tls.go @@ -3,6 +3,7 @@ package brand_tls import ( "context" "crypto/tls" + "crypto/x509" "strings" "time" @@ -56,22 +57,37 @@ func (w *Watcher) Check() { return } for _, b := range brands { - kp := b.WebCertificate.Get() - if kp == nil { - continue + kp := b.GetWebCertificate() + if kp != "" { + err := w.cs.AddKeypair(kp) + if err != nil { + w.log.WithError(err).WithField("kp", kp).Warning("failed to add web certificate") + } } - err := w.cs.AddKeypair(*kp) - if err != nil { - w.log.WithError(err).Warning("failed to add certificate") + for _, crt := range b.GetClientCertificates() { + if crt != "" { + err := w.cs.AddKeypair(crt) + if err != nil { + w.log.WithError(err).WithField("kp", kp).Warning("failed to add client certificate") + } + } } } w.brands = brands } -func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { +type CertificateConfig struct { + Web *tls.Certificate + Client *x509.CertPool +} + +func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) *CertificateConfig { var bestSelection *api.Brand + config := CertificateConfig{ + Web: w.fallback, + } for _, t := range w.brands { - if t.WebCertificate.Get() == nil { + if !t.WebCertificate.IsSet() && len(t.GetClientCertificates()) < 1 { continue } if *t.Default { @@ -82,11 +98,20 @@ func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, err } } if bestSelection == nil { - return w.fallback, nil + return &config } - cert := w.cs.Get(bestSelection.GetWebCertificate()) - if cert == nil { - return w.fallback, nil + if bestSelection.GetWebCertificate() != "" { + if cert := w.cs.Get(bestSelection.GetWebCertificate()); cert != nil { + config.Web = cert + } } - return cert, nil + if len(bestSelection.GetClientCertificates()) > 0 { + config.Client = x509.NewCertPool() + for _, kp := range bestSelection.GetClientCertificates() { + if cert := w.cs.Get(kp); cert != nil { + config.Client.AddCert(cert.Leaf) + } + } + } + return &config } diff --git a/internal/web/metrics.go b/internal/web/metrics.go index 21778ba99e..fff462f5ea 100644 --- a/internal/web/metrics.go +++ b/internal/web/metrics.go @@ -1,15 +1,11 @@ package web import ( - "encoding/base64" "fmt" "io" "net/http" - "os" - "path" "github.com/gorilla/mux" - "github.com/gorilla/securecookie" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -18,8 +14,6 @@ import ( "goauthentik.io/internal/utils/sentry" ) -const MetricsKeyFile = "authentik-core-metrics.key" - var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "authentik_main_request_duration_seconds", Help: "API request latencies in seconds", @@ -27,14 +21,6 @@ var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ func (ws *WebServer) runMetricsServer() { l := log.WithField("logger", "authentik.router.metrics") - tmp := os.TempDir() - key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) - keyPath := path.Join(tmp, MetricsKeyFile) - err := os.WriteFile(keyPath, []byte(key), 0o600) - if err != nil { - l.WithError(err).Warning("failed to save metrics key") - return - } m := mux.NewRouter() m.Use(sentry.SentryNoSampleMiddleware) @@ -51,7 +37,7 @@ func (ws *WebServer) runMetricsServer() { l.WithError(err).Warning("failed to get upstream metrics") return } - re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) + re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ws.metricsKey)) res, err := ws.upstreamHttpClient().Do(re) if err != nil { l.WithError(err).Warning("failed to get upstream metrics") @@ -64,13 +50,9 @@ func (ws *WebServer) runMetricsServer() { } }) l.WithField("listen", config.Get().Listen.Metrics).Info("Starting Metrics server") - err = http.ListenAndServe(config.Get().Listen.Metrics, m) + err := http.ListenAndServe(config.Get().Listen.Metrics, m) if err != nil { l.WithError(err).Warning("Failed to start metrics server") } l.WithField("listen", config.Get().Listen.Metrics).Info("Stopping Metrics server") - err = os.Remove(keyPath) - if err != nil { - l.WithError(err).Warning("failed to remove metrics key file") - } } diff --git a/internal/web/proxy.go b/internal/web/proxy.go index 00e6761119..989c1a14d4 100644 --- a/internal/web/proxy.go +++ b/internal/web/proxy.go @@ -2,21 +2,29 @@ package web import ( "encoding/json" + "encoding/pem" "errors" "fmt" "net/http" "net/http/httputil" + "net/url" + "strings" "time" "github.com/prometheus/client_golang/prometheus" "goauthentik.io/internal/config" "goauthentik.io/internal/utils/sentry" + "goauthentik.io/internal/utils/web" ) var ( ErrAuthentikStarting = errors.New("authentik starting") ) +const ( + maxBodyBytes = 32 * 1024 * 1024 +) + func (ws *WebServer) configureProxy() { // Reverse proxy to the application server director := func(req *http.Request) { @@ -26,8 +34,25 @@ func (ws *WebServer) configureProxy() { // explicitly disable User-Agent so it's not set to default value req.Header.Set("User-Agent", "") } + if !web.IsRequestFromTrustedProxy(req) { + // If the request isn't coming from a trusted proxy, delete MTLS headers + req.Header.Del("SSL-Client-Cert") // nginx-ingress + req.Header.Del("X-Forwarded-TLS-Client-Cert") // traefik + req.Header.Del("X-Forwarded-Client-Cert") // envoy + } if req.TLS != nil { req.Header.Set("X-Forwarded-Proto", "https") + if len(req.TLS.PeerCertificates) > 0 { + pems := make([]string, len(req.TLS.PeerCertificates)) + for i, crt := range req.TLS.PeerCertificates { + pem := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: crt.Raw, + }) + pems[i] = "Cert=" + url.QueryEscape(string(pem)) + } + req.Header.Set("X-Forwarded-Client-Cert", strings.Join(pems, ",")) + } } ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend") } @@ -57,7 +82,7 @@ func (ws *WebServer) configureProxy() { Requests.With(prometheus.Labels{ "dest": "core", }).Observe(float64(elapsed) / float64(time.Second)) - r.Body = http.MaxBytesReader(rw, r.Body, 32*1024*1024) + r.Body = http.MaxBytesReader(rw, r.Body, maxBodyBytes) rp.ServeHTTP(rw, r) })) } diff --git a/internal/web/web.go b/internal/web/web.go index d25ee688c9..fcd16543e2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -2,6 +2,7 @@ package web import ( "context" + "encoding/base64" "errors" "fmt" "net" @@ -13,17 +14,27 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/gorilla/securecookie" "github.com/pires/go-proxyproto" log "github.com/sirupsen/logrus" + "goauthentik.io/api/v3" "goauthentik.io/internal/config" + "goauthentik.io/internal/constants" "goauthentik.io/internal/gounicorn" + "goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/proxyv2" "goauthentik.io/internal/utils" "goauthentik.io/internal/utils/web" "goauthentik.io/internal/web/brand_tls" ) +const ( + IPCKeyFile = "authentik-core-ipc.key" + MetricsKeyFile = "authentik-core-metrics.key" + UnixSocketName = "authentik-core.sock" +) + type WebServer struct { Bind string BindTLS bool @@ -40,9 +51,10 @@ type WebServer struct { log *log.Entry upstreamClient *http.Client upstreamURL *url.URL -} -const UnixSocketName = "authentik-core.sock" + metricsKey string + ipcKey string +} func NewWebServer() *WebServer { l := log.WithField("logger", "authentik.router") @@ -76,7 +88,7 @@ func NewWebServer() *WebServer { mainRouter: mainHandler, loggingRouter: loggingHandler, log: l, - gunicornReady: true, + gunicornReady: false, upstreamClient: upstreamClient, upstreamURL: u, } @@ -103,7 +115,59 @@ func NewWebServer() *WebServer { return ws } +func (ws *WebServer) prepareKeys() { + tmp := os.TempDir() + key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) + err := os.WriteFile(path.Join(tmp, MetricsKeyFile), []byte(key), 0o600) + if err != nil { + ws.log.WithError(err).Warning("failed to save metrics key") + return + } + ws.metricsKey = key + + key = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) + err = os.WriteFile(path.Join(tmp, IPCKeyFile), []byte(key), 0o600) + if err != nil { + ws.log.WithError(err).Warning("failed to save ipc key") + return + } + ws.ipcKey = key +} + func (ws *WebServer) Start() { + ws.prepareKeys() + + u, err := url.Parse(fmt.Sprintf("http://%s%s", config.Get().Listen.HTTP, config.Get().Web.Path)) + if err != nil { + panic(err) + } + apiConfig := api.NewConfiguration() + apiConfig.Host = u.Host + apiConfig.Scheme = u.Scheme + apiConfig.HTTPClient = &http.Client{ + Transport: web.NewUserAgentTransport( + constants.UserAgentIPC(), + ak.GetTLSTransport(), + ), + } + apiConfig.Servers = api.ServerConfigurations{ + { + URL: fmt.Sprintf("%sapi/v3", u.Path), + }, + } + apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", ws.ipcKey)) + + // create the API client, with the transport + apiClient := api.NewAPIClient(apiConfig) + + // Init brand_tls here too since it requires an API Client, + // so we just reuse the same one as the outpost uses + tw := brand_tls.NewWatcher(apiClient) + ws.BrandTLS = tw + ws.g.AddHealthyCallback(func() { + go tw.Start() + }) + go ws.runMetricsServer() go ws.attemptStartBackend() go ws.listenPlain() @@ -112,23 +176,23 @@ func (ws *WebServer) Start() { func (ws *WebServer) attemptStartBackend() { for { - if !ws.gunicornReady { + if ws.gunicornReady { return } err := ws.g.Start() - log.WithField("logger", "authentik.router").WithError(err).Warning("gunicorn process died, restarting") + ws.log.WithError(err).Warning("gunicorn process died, restarting") if err != nil { - log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn failed to start, restarting") + ws.log.WithError(err).Error("gunicorn failed to start, restarting") continue } failedChecks := 0 for range time.NewTicker(30 * time.Second).C { if !ws.g.IsRunning() { - log.WithField("logger", "authentik.router").Warningf("gunicorn process failed healthcheck %d times", failedChecks) + ws.log.Warningf("gunicorn process failed healthcheck %d times", failedChecks) failedChecks += 1 } if failedChecks >= 3 { - log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn process failed healthcheck three times, restarting") + ws.log.WithError(err).Error("gunicorn process failed healthcheck three times, restarting") break } } @@ -146,6 +210,15 @@ func (ws *WebServer) upstreamHttpClient() *http.Client { func (ws *WebServer) Shutdown() { ws.log.Info("shutting down gunicorn") ws.g.Kill() + tmp := os.TempDir() + err := os.Remove(path.Join(tmp, MetricsKeyFile)) + if err != nil { + ws.log.WithError(err).Warning("failed to remove metrics key file") + } + err = os.Remove(path.Join(tmp, IPCKeyFile)) + if err != nil { + ws.log.WithError(err).Warning("failed to remove ipc key file") + } ws.stop <- struct{}{} } diff --git a/internal/web/web_tls.go b/internal/web/web_tls.go index 9e006fbdd8..7c4818ea1e 100644 --- a/internal/web/web_tls.go +++ b/internal/web/web_tls.go @@ -12,40 +12,57 @@ import ( "goauthentik.io/internal/utils/web" ) -func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := crypto.GenerateSelfSignedCert() +func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Config, error) { + fallback, err := crypto.GenerateSelfSignedCert() if err != nil { ws.log.WithError(err).Error("failed to generate default cert") } - return func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(ch *tls.ClientHelloInfo) (*tls.Config, error) { + cfg := utils.GetTLSConfig() if ch.ServerName == "" { - return &cert, nil + cfg.Certificates = []tls.Certificate{fallback} + return cfg, nil } if ws.ProxyServer != nil { appCert := ws.ProxyServer.GetCertificate(ch.ServerName) if appCert != nil { - return appCert, nil + cfg.Certificates = []tls.Certificate{*appCert} + return cfg, nil } } if ws.BrandTLS != nil { - return ws.BrandTLS.GetCertificate(ch) + bcert := ws.BrandTLS.GetCertificate(ch) + cfg.Certificates = []tls.Certificate{*bcert.Web} + ws.log.Trace("using brand web Certificate") + if bcert.Client != nil { + cfg.ClientCAs = bcert.Client + cfg.ClientAuth = tls.RequestClientCert + ws.log.Trace("using brand client Certificate") + } + return cfg, nil } ws.log.Trace("using default, self-signed certificate") - return &cert, nil + cfg.Certificates = []tls.Certificate{fallback} + return cfg, nil } } // ServeHTTPS constructs a net.Listener and starts handling HTTPS requests func (ws *WebServer) listenTLS() { tlsConfig := utils.GetTLSConfig() - tlsConfig.GetCertificate = ws.GetCertificate() + tlsConfig.GetConfigForClient = ws.GetCertificate() ln, err := net.Listen("tcp", config.Get().Listen.HTTPS) if err != nil { ws.log.WithError(err).Warning("failed to listen (TLS)") return } - proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()} + proxyListener := &proxyproto.Listener{ + Listener: web.TCPKeepAliveListener{ + TCPListener: ln.(*net.TCPListener), + }, + ConnPolicy: utils.GetProxyConnectionPolicy(), + } defer func() { err := proxyListener.Close() if err != nil { diff --git a/schema.yml b/schema.yml index 160fcb5f52..8974c0f0dc 100644 --- a/schema.yml +++ b/schema.yml @@ -4460,6 +4460,15 @@ paths: name: branding_title schema: type: string + - in: query + name: client_certificates + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form - in: query name: default schema: @@ -24978,6 +24987,7 @@ paths: - authentik_stages_identification.identificationstage - authentik_stages_invitation.invitation - authentik_stages_invitation.invitationstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage @@ -25226,6 +25236,7 @@ paths: - authentik_stages_identification.identificationstage - authentik_stages_invitation.invitation - authentik_stages_invitation.invitationstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage @@ -37718,6 +37729,311 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /stages/mtls/: + get: + operationId: stages_mtls_list + description: MutualTLSStage Viewset + parameters: + - in: query + name: cert_attribute + schema: + type: string + enum: + - common_name + - email + - subject + - in: query + name: certificate_authorities + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form + - in: query + name: mode + schema: + type: string + enum: + - optional + - required + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: stage_uuid + schema: + type: string + format: uuid + - in: query + name: user_attribute + schema: + type: string + enum: + - email + - username + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedMutualTLSStageList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: stages_mtls_create + description: MutualTLSStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/mtls/{stage_uuid}/: + get: + operationId: stages_mtls_retrieve + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: stages_mtls_update + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: stages_mtls_partial_update + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMutualTLSStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: stages_mtls_destroy + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/mtls/{stage_uuid}/used_by/: + get: + operationId: stages_mtls_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/password/: get: operationId: stages_password_list @@ -40946,6 +41262,7 @@ components: - authentik.enterprise.providers.microsoft_entra - authentik.enterprise.providers.ssf - authentik.enterprise.stages.authenticator_endpoint_gdtc + - authentik.enterprise.stages.mtls - authentik.enterprise.stages.source - authentik.events type: string @@ -42609,6 +42926,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} required: - brand_uuid @@ -42673,6 +42996,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} required: - domain @@ -42842,6 +43171,12 @@ components: - name - private_key - public_key + CertAttributeEnum: + enum: + - subject + - common_name + - email + type: string CertificateData: type: object description: Get CertificateKeyPair's data @@ -48368,6 +48703,7 @@ components: - authentik_providers_microsoft_entra.microsoftentraprovidermapping - authentik_providers_ssf.ssfprovider - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_source.sourcestage - authentik_events.event - authentik_events.notificationtransport @@ -48375,6 +48711,96 @@ components: - authentik_events.notificationrule - authentik_events.notificationwebhookmapping type: string + MutualTLSStage: + type: object + description: MutualTLSStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + description: Get object type so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSet' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' + required: + - cert_attribute + - component + - meta_model_name + - mode + - name + - pk + - user_attribute + - verbose_name + - verbose_name_plural + MutualTLSStageModeEnum: + enum: + - optional + - required + type: string + MutualTLSStageRequest: + type: object + description: MutualTLSStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' + required: + - cert_attribute + - mode + - name + - user_attribute NameIdPolicyEnum: enum: - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress @@ -50220,6 +50646,18 @@ components: required: - pagination - results + PaginatedMutualTLSStageList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/MutualTLSStage' + required: + - pagination + - results PaginatedNotificationList: type: object properties: @@ -51896,6 +52334,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} PatchedCaptchaStageRequest: type: object @@ -53079,6 +53523,31 @@ components: type: boolean description: When enabled, provider will not modify or create objects in the remote system. + PatchedMutualTLSStageRequest: + type: object + description: MutualTLSStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' PatchedNotificationRequest: type: object description: Notification Serializer @@ -59793,6 +60262,11 @@ components: - pk - uid - username + UserAttributeEnum: + enum: + - username + - email + type: string UserConsent: type: object description: UserConsent Serializer diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index fa79fdb53c..b3e9a3d1fc 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -1,9 +1,12 @@ +import { certificateProvider, certificateSelector } from "@goauthentik/admin/brands/Certificates"; import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DefaultBrand } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -303,6 +306,17 @@ export class BrandForm extends ModelForm { .certificate=${this.instance?.webCertificate} > + + + [s.pk, s.name, s.name, s]; + +export async function certificateProvider(page = 1, search = "") { + const certificates = await new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + hasKey: undefined, + }); + return { + pagination: certificates.pagination, + options: certificates.results.map(certToSelect), + }; +} + +export function certificateSelector(instanceMappings?: string[]) { + if (!instanceMappings) { + return []; + } + + return async () => { + const pm = new CryptoApi(DEFAULT_CONFIG); + const mappings = await Promise.allSettled( + instanceMappings.map((instanceId) => + pm.cryptoCertificatekeypairsRetrieve({ kpUuid: instanceId }), + ), + ); + + return mappings + .filter((s) => s.status === "fulfilled") + .map((s) => s.value) + .map(certToSelect); + }; +} diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 3056d41d03..0b0cfabbaa 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -16,6 +16,7 @@ import "@goauthentik/admin/stages/dummy/DummyStageForm"; import "@goauthentik/admin/stages/email/EmailStageForm"; import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; +import "@goauthentik/admin/stages/mtls/MTLSStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; import "@goauthentik/admin/stages/redirect/RedirectStageForm"; diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts index 46d99f4a16..7244fada79 100644 --- a/web/src/admin/stages/StageWizard.ts +++ b/web/src/admin/stages/StageWizard.ts @@ -14,6 +14,7 @@ import "@goauthentik/admin/stages/dummy/DummyStageForm"; import "@goauthentik/admin/stages/email/EmailStageForm"; import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; +import "@goauthentik/admin/stages/mtls/MTLSStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; import "@goauthentik/admin/stages/redirect/RedirectStageForm"; diff --git a/web/src/admin/stages/mtls/MTLSStageForm.ts b/web/src/admin/stages/mtls/MTLSStageForm.ts new file mode 100644 index 0000000000..420947219e --- /dev/null +++ b/web/src/admin/stages/mtls/MTLSStageForm.ts @@ -0,0 +1,162 @@ +import { certificateProvider, certificateSelector } from "@goauthentik/admin/brands/Certificates"; +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + CertAttributeEnum, + MutualTLSStage, + MutualTLSStageModeEnum, + StagesApi, + UserAttributeEnum, +} from "@goauthentik/api"; + +@customElement("ak-stage-mtls-form") +export class MTLSStageForm extends BaseStageForm { + loadInstance(pk: string): Promise { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsRetrieve({ + stageUuid: pk, + }); + } + + async send(data: MutualTLSStage): Promise { + if (this.instance) { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsUpdate({ + stageUuid: this.instance.pk || "", + mutualTLSStageRequest: data, + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsCreate({ + mutualTLSStageRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + ${msg("Client-certificate/mTLS authentication/enrollment.")} + + + + + ${msg("Stage-specific settings")} +
+ + + + + + +

+ ${msg( + "Configure the certificate authority client certificates are validated against. The certificate authority can also be configured on a brand, which allows for different certificate authorities for different domains.", + )} +

+
+ + + +

+ ${msg( + "Configure the attribute of the certificate used to look for a user.", + )} +

+
+ + + +

+ ${msg("Configure the attribute of the user used to look for a user.")} +

+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-stage-mtls-form": MTLSStageForm; + } +} From c133ba9bd3f3f2755ed260d849e73dcec0200b51 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Mon, 19 May 2025 23:27:02 +0200 Subject: [PATCH 3/4] enterprise/stages/mtls: update go & web client, fix py client generation (#14576) Signed-off-by: Jens Langhammer --- Makefile | 1 - go.mod | 2 +- go.sum | 4 ++-- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 85be43dd05..6618729e55 100644 --- a/Makefile +++ b/Makefile @@ -162,7 +162,6 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python --additional-properties=packageVersion=${NPM_VERSION} \ --git-repo-id authentik \ --git-user-id goauthentik - uv pip install ${PWD}/${GEN_API_PY} gen-client-go: gen-clean-go ## Build and install the authentik API for Golang mkdir -p ${PWD}/${GEN_API_GO} diff --git a/go.mod b/go.mod index 2b7d71f9fc..3c8be11d7b 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/wwt/guac v1.3.2 - goauthentik.io/api/v3 v3.2025041.1 + goauthentik.io/api/v3 v3.2025041.2 golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.14.0 diff --git a/go.sum b/go.sum index fb8aecf8d2..ba2cba50ac 100644 --- a/go.sum +++ b/go.sum @@ -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.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8= -goauthentik.io/api/v3 v3.2025041.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= +goauthentik.io/api/v3 v3.2025041.2 h1:vFYYnhcDcxL95RczZwhzt3i4LptFXMvIRN+vgf8sQYg= +goauthentik.io/api/v3 v3.2025041.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/web/package-lock.json b/web/package-lock.json index f5ff1cb105..81837c51e2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "@floating-ui/dom": "^1.6.11", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", - "@goauthentik/api": "^2025.4.1-1747332783", + "@goauthentik/api": "^2025.4.1-1747687715", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -1583,9 +1583,9 @@ } }, "node_modules/@goauthentik/api": { - "version": "2025.4.1-1747332783", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.4.1-1747332783.tgz", - "integrity": "sha512-i+aC13BFLIyD7oGyeCFu6CmJcr3I0TV9n98em3zcpkwH81SXL+LuaXTs7GFk4jtvySMK4dyRf+71L5j8hS2Dug==" + "version": "2025.4.1-1747687715", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.4.1-1747687715.tgz", + "integrity": "sha512-YN1iN+OYrBociCVT55AF4QsxFo4g7tk72QFHPXEkPGhBJC+x50h6lLW30S7FtfbEVRqYmvtBUsIW+iqHFhGvtA==" }, "node_modules/@goauthentik/esbuild-plugin-live-reload": { "resolved": "packages/esbuild-plugin-live-reload", diff --git a/web/package.json b/web/package.json index 11869aacaf..78b2e89354 100644 --- a/web/package.json +++ b/web/package.json @@ -56,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.1-1747332783", + "@goauthentik/api": "^2025.4.1-1747687715", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", From 1c5e906a3e032e80ffd3ac0cc26814e91118d649 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Tue, 20 May 2025 02:11:18 +0200 Subject: [PATCH 4/4] web/NPM Workspaces: ESbuild version cleanup (#14541) * web: Check JS files. Add types. * web: Fix issues surrounding Vite/ESBuild types. * web: Clean up version constants. Tidy types * web: Clean up docs, types. * web: Clean up package paths. * web: (ESLint) no-lonely-if * web: Render slot before navbar. * web: Fix line-height alignment. * web: Truncate long headers. * web: Clean up page header declarations. Add story. Update paths. * web: Ignore out directory. * web: Lint Lit. * web: Use private alias. * web: Fix implicit CJS mode. * web: Update deps. * web: await all imports. --- web/.storybook/main.js | 31 +- .../mdx-plugin/node.js} | 12 +- web/bundler/utils/node.js | 29 ++ web/eslint.config.mjs | 3 +- web/package-lock.json | 350 ++++++++++-------- web/package.json | 62 +++- web/packages/{monorepo => core}/LICENSE.txt | 0 web/packages/{monorepo => core}/README.md | 2 +- web/packages/core/environment/node.js | 66 ++++ web/packages/core/index.js | 12 + web/packages/core/package.json | 59 +++ .../{monorepo/paths.js => core/paths/node.js} | 18 +- .../scripting.js => core/scripting/node.js} | 1 + web/packages/{monorepo => core}/tsconfig.json | 2 + web/packages/core/types/node.d.ts | 54 +++ web/packages/core/version/index.js | 21 ++ .../version.js => core/version/node.js} | 13 +- .../client/ESBuildObserver.js | 2 +- .../client/types.d.ts | 23 +- web/packages/monorepo/build.js | 42 --- web/packages/monorepo/constants.js | 16 - web/packages/monorepo/index.js | 7 - web/packages/monorepo/package.json | 28 -- web/packages/monorepo/types/global.d.ts | 15 - web/{paths.js => paths/node.js} | 4 +- web/scripts/build-locales.mjs | 40 +- web/scripts/build-web.mjs | 40 +- web/scripts/eslint.mjs | 5 + web/scripts/eslint.nightmare.mjs | 1 + web/scripts/pseudolocalize.mjs | 33 +- web/src/admin/AdminInterface/AboutModal.ts | 3 +- .../admin/AdminInterface/index.entrypoint.ts | 43 +-- web/src/admin/DebugPage.ts | 12 +- .../admin/admin-overview/AdminOverviewPage.ts | 98 ++--- .../admin/admin-overview/DashboardUserPage.ts | 8 +- .../admin/admin-settings/AdminSettingsPage.ts | 25 +- .../admin/applications/ApplicationViewPage.ts | 30 +- web/src/admin/events/EventViewPage.ts | 16 +- web/src/admin/flows/FlowViewPage.ts | 26 +- web/src/admin/groups/GroupViewPage.ts | 2 +- web/src/admin/providers/ProviderViewPage.ts | 30 +- .../admin/rbac/InitialPermissionsListPage.ts | 16 +- web/src/admin/roles/RoleListPage.ts | 16 +- web/src/admin/roles/RoleViewPage.ts | 24 +- web/src/admin/sources/SourceViewPage.ts | 22 +- .../stages/invitation/InvitationListPage.ts | 24 +- web/src/admin/users/UserViewPage.ts | 76 ++-- web/src/common/api/config.ts | 6 +- web/src/common/constants.ts | 35 +- web/src/common/helpers/plex.ts | 3 +- web/src/common/sentry/index.ts | 3 +- web/src/common/theme.ts | 12 +- web/src/components/ak-event-info.ts | 3 +- web/src/components/ak-page-header.ts | 84 +++++ .../ak-page-navbar.ts} | 138 ++----- .../stories/ak-page-navbar.stories.ts | 58 +++ web/src/elements/banner/VersionBanner.ts | 24 +- .../cards/stories/QuickActionsCard.docs.mdx | 2 +- web/src/elements/table/TablePage.ts | 6 +- web/src/rac/index.entrypoint.ts | 12 +- web/tsconfig.json | 6 + web/types/esbuild.d.ts | 39 ++ web/types/{global.d.ts => node.d.ts} | 12 +- web/wdio.conf.ts | 34 +- 64 files changed, 1141 insertions(+), 798 deletions(-) rename web/{scripts/esbuild/build-mdx-plugin.mjs => bundler/mdx-plugin/node.js} (93%) create mode 100644 web/bundler/utils/node.js rename web/packages/{monorepo => core}/LICENSE.txt (100%) rename web/packages/{monorepo => core}/README.md (82%) create mode 100644 web/packages/core/environment/node.js create mode 100644 web/packages/core/index.js create mode 100644 web/packages/core/package.json rename web/packages/{monorepo/paths.js => core/paths/node.js} (72%) rename web/packages/{monorepo/scripting.js => core/scripting/node.js} (98%) rename web/packages/{monorepo => core}/tsconfig.json (72%) create mode 100644 web/packages/core/types/node.d.ts create mode 100644 web/packages/core/version/index.js rename web/packages/{monorepo/version.js => core/version/node.js} (80%) delete mode 100644 web/packages/monorepo/build.js delete mode 100644 web/packages/monorepo/constants.js delete mode 100644 web/packages/monorepo/index.js delete mode 100644 web/packages/monorepo/package.json delete mode 100644 web/packages/monorepo/types/global.d.ts rename web/{paths.js => paths/node.js} (96%) create mode 100644 web/src/components/ak-page-header.ts rename web/src/{elements/PageHeader.ts => components/ak-page-navbar.ts} (81%) create mode 100644 web/src/components/stories/ak-page-navbar.stories.ts create mode 100644 web/types/esbuild.d.ts rename web/types/{global.d.ts => node.d.ts} (58%) diff --git a/web/.storybook/main.js b/web/.storybook/main.js index ac6c5eb840..dbfce93a98 100644 --- a/web/.storybook/main.js +++ b/web/.storybook/main.js @@ -3,13 +3,10 @@ * @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 CSSImportPattern = /import [\w$]+ from .+\.(css)/g; const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/; /** @@ -30,6 +27,11 @@ const inlineCSSPlugin = { }, }; +/** + * @satisfies {InlineConfig} + */ +// const viteFinal = ; + /** * @satisfies {StorybookConfig} */ @@ -48,22 +50,21 @@ const config = { docs: { autodocs: "tag", }, - viteFinal({ plugins = [], ...config }) { + async viteFinal(config) { + const [{ mergeConfig }, { createBundleDefinitions }] = await Promise.all([ + import("vite"), + import("@goauthentik/web/bundler/utils/node"), + ]); + /** * @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()], + const overrides = { + define: createBundleDefinitions(), + plugins: [inlineCSSPlugin, postcssLit(), tsconfigPaths()], }; - return mergedConfig; + return mergeConfig(config, overrides); }, }; - export default config; diff --git a/web/scripts/esbuild/build-mdx-plugin.mjs b/web/bundler/mdx-plugin/node.js similarity index 93% rename from web/scripts/esbuild/build-mdx-plugin.mjs rename to web/bundler/mdx-plugin/node.js index c6f5533915..1e899298f8 100644 --- a/web/scripts/esbuild/build-mdx-plugin.mjs +++ b/web/bundler/mdx-plugin/node.js @@ -1,10 +1,12 @@ /** + * @file MDX plugin for ESBuild. + * * @import { - OnLoadArgs, - OnLoadResult, - Plugin, - PluginBuild - * } from 'esbuild' + * OnLoadArgs, + * OnLoadResult, + * Plugin, + * PluginBuild + * } from "esbuild" */ import * as fs from "node:fs/promises"; import * as path from "node:path"; diff --git a/web/bundler/utils/node.js b/web/bundler/utils/node.js new file mode 100644 index 0000000000..827fd40983 --- /dev/null +++ b/web/bundler/utils/node.js @@ -0,0 +1,29 @@ +/** + * @file Bundler utilities. + */ +import { NodeEnvironment, serializeEnvironmentVars } from "@goauthentik/core/environment/node"; +import { AuthentikVersion } from "@goauthentik/core/version/node"; + +/** + * Creates a mapping of environment variables to their respective runtime constants. + */ +export function createBundleDefinitions() { + const SerializedNodeEnvironment = /** @type {`"development"` | `"production"`} */ ( + JSON.stringify(NodeEnvironment) + ); + + /** + * @satisfies {Record} + */ + const envRecord = { + AK_VERSION: AuthentikVersion, + AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "", + }; + + return { + ...serializeEnvironmentVars(envRecord), + // We need to explicitly set this for NPM packages that use `process` + // to determine their environment. + "process.env.NODE_ENV": SerializedNodeEnvironment, + }; +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 1d3fd5911d..fc2777eb72 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -12,6 +12,7 @@ export default [ { ignores: [ "dist/", + "out/", // don't lint the cache ".wireit/", // let packages have their own configurations @@ -71,7 +72,7 @@ export default [ ...globals.node, }, }, - files: ["scripts/**/*.mjs", "*.ts", "*.mjs"], + files: ["scripts/**/*.mjs", "*.ts", "*.mjs", "**/node.js"], rules: { "no-unused-vars": "off", // We WANT our scripts to output to the console! diff --git a/web/package-lock.json b/web/package-lock.json index 81837c51e2..6b04513dac 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.0", "license": "MIT", "workspaces": [ - ".", "./packages/*" ], "dependencies": { @@ -72,21 +71,21 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@goauthentik/core": "^1.0.0", "@goauthentik/esbuild-plugin-live-reload": "^1.0.4", - "@goauthentik/monorepo": "^1.0.0", "@goauthentik/prettier-config": "^1.0.4", "@goauthentik/tsconfig": "^1.0.4", "@hcaptcha/types": "^1.0.4", "@lit/localize-tools": "^0.8.0", "@rollup/plugin-replace": "^6.0.1", - "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-essentials": "^8.6.14", "@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", + "@storybook/experimental-addon-test": "^8.6.14", + "@storybook/manager-api": "^8.6.14", + "@storybook/test": "^8.6.14", + "@storybook/web-components": "^8.6.14", + "@storybook/web-components-vite": "^8.6.14", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/chart.js": "^2.9.41", "@types/codemirror": "^5.60.15", @@ -119,10 +118,10 @@ "prettier": "^3.3.3", "pseudolocale": "^2.1.0", "rollup-plugin-postcss-lit": "^2.1.0", - "storybook": "^8.6.12", + "storybook": "^8.6.14", "storybook-addon-mock": "^5.0.0", "turnstile-types": "^1.2.3", - "typescript": "^5.6.2", + "typescript": "^5.8.3", "typescript-eslint": "^8.8.0", "vite-plugin-lit-css": "^2.0.0", "vite-tsconfig-paths": "^5.0.1", @@ -140,6 +139,22 @@ "@rollup/rollup-linux-x64-gnu": "4.23.0" } }, + "../packages/core": { + "name": "@goauthentik/core", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@goauthentik/prettier-config": "^1.0.4", + "@goauthentik/tsconfig": "^1.0.4", + "@types/node": "^22.14.1", + "prettier": "^3.3.3", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=20.11" + } + }, "../packages/monorepo": { "name": "@goauthentik/monorepo", "version": "1.0.4", @@ -157,9 +172,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", "dev": true, "license": "MIT" }, @@ -1587,12 +1602,12 @@ "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.4.1-1747687715.tgz", "integrity": "sha512-YN1iN+OYrBociCVT55AF4QsxFo4g7tk72QFHPXEkPGhBJC+x50h6lLW30S7FtfbEVRqYmvtBUsIW+iqHFhGvtA==" }, - "node_modules/@goauthentik/esbuild-plugin-live-reload": { - "resolved": "packages/esbuild-plugin-live-reload", + "node_modules/@goauthentik/core": { + "resolved": "packages/core", "link": true }, - "node_modules/@goauthentik/monorepo": { - "resolved": "packages/monorepo", + "node_modules/@goauthentik/esbuild-plugin-live-reload": { + "resolved": "packages/esbuild-plugin-live-reload", "link": true }, "node_modules/@goauthentik/prettier-config": { @@ -1621,10 +1636,6 @@ "node": ">=20.11" } }, - "node_modules/@goauthentik/web": { - "resolved": "", - "link": true - }, "node_modules/@goauthentik/web-sfe": { "resolved": "packages/sfe", "link": true @@ -3610,9 +3621,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.12.tgz", - "integrity": "sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", + "integrity": "sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3627,13 +3638,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.12.tgz", - "integrity": "sha512-lmIAma9BiiCTbJ8YfdZkXjpnAIrOUcgboLkt1f6XJ78vNEMnLNzD9gnh7Tssz1qrqvm34v9daDjIb+ggdiKp3Q==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz", + "integrity": "sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==", "dev": true, "license": "MIT", "dependencies": { @@ -3646,13 +3657,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-controls": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.12.tgz", - "integrity": "sha512-9VSRPJWQVb9wLp21uvpxDGNctYptyUX0gbvxIWOHMH3R2DslSoq41lsC/oQ4l4zSHVdL+nq8sCTkhBxIsjKqdQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.14.tgz", + "integrity": "sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==", "dev": true, "license": "MIT", "dependencies": { @@ -3665,20 +3676,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-docs": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.12.tgz", - "integrity": "sha512-kEezQjAf/p3SpDzLABgg4fbT48B6dkT2LiZCKTRmCrJVtuReaAr4R9MMM6Jsph6XjbIj/SvOWf3CMeOPXOs9sg==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.14.tgz", + "integrity": "sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.6.12", - "@storybook/csf-plugin": "8.6.12", - "@storybook/react-dom-shim": "8.6.12", + "@storybook/blocks": "8.6.14", + "@storybook/csf-plugin": "8.6.14", + "@storybook/react-dom-shim": "8.6.14", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -3688,25 +3699,25 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-essentials": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.12.tgz", - "integrity": "sha512-Y/7e8KFlttaNfv7q2zoHMPdX6hPXHdsuQMAjYl5NG9HOAJREu4XBy4KZpbcozRe4ApZ78rYsN/MO1EuA+bNMIA==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.14.tgz", + "integrity": "sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-actions": "8.6.12", - "@storybook/addon-backgrounds": "8.6.12", - "@storybook/addon-controls": "8.6.12", - "@storybook/addon-docs": "8.6.12", - "@storybook/addon-highlight": "8.6.12", - "@storybook/addon-measure": "8.6.12", - "@storybook/addon-outline": "8.6.12", - "@storybook/addon-toolbars": "8.6.12", - "@storybook/addon-viewport": "8.6.12", + "@storybook/addon-actions": "8.6.14", + "@storybook/addon-backgrounds": "8.6.14", + "@storybook/addon-controls": "8.6.14", + "@storybook/addon-docs": "8.6.14", + "@storybook/addon-highlight": "8.6.14", + "@storybook/addon-measure": "8.6.14", + "@storybook/addon-outline": "8.6.14", + "@storybook/addon-toolbars": "8.6.14", + "@storybook/addon-viewport": "8.6.14", "ts-dedent": "^2.0.0" }, "funding": { @@ -3714,13 +3725,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.12.tgz", - "integrity": "sha512-9FITVxdoycZ+eXuAZL9ElWyML/0fPPn9UgnnAkrU7zkMi+Segq/Tx7y+WWanC5zfWZrXAuG6WTOYEXeWQdm//w==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.14.tgz", + "integrity": "sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3731,7 +3742,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-links": { @@ -3759,9 +3770,9 @@ } }, "node_modules/@storybook/addon-measure": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.12.tgz", - "integrity": "sha512-tACmwqqOvutaQSduw8SMb62wICaT1rWaHtMN3vtWXuxgDPSdJQxLP+wdVyRYMAgpxhLyIO7YRf++Hfha9RHgFg==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.14.tgz", + "integrity": "sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==", "dev": true, "license": "MIT", "dependencies": { @@ -3773,13 +3784,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-outline": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.12.tgz", - "integrity": "sha512-1ylwm+n1s40S91No0v9T4tCjZORu3GbnjINlyjYTDLLhQHyBQd3nWR1Y1eewU4xH4cW9SnSLcMQFS/82xHqU6A==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.14.tgz", + "integrity": "sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==", "dev": true, "license": "MIT", "dependencies": { @@ -3791,13 +3802,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.12.tgz", - "integrity": "sha512-HEcSzo1DyFtIu5/ikVOmh5h85C1IvK9iFKSzBR6ice33zBOaehVJK+Z5f487MOXxPsZ63uvWUytwPyViGInj+g==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.14.tgz", + "integrity": "sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==", "dev": true, "license": "MIT", "funding": { @@ -3805,13 +3816,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.12.tgz", - "integrity": "sha512-EXK2LArAnABsPP0leJKy78L/lbMWow+EIJfytEP5fHaW4EhMR6h7Hzaqzre6U0IMMr/jVFa1ci+m0PJ0eQc2bw==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.14.tgz", + "integrity": "sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==", "dev": true, "license": "MIT", "dependencies": { @@ -3822,13 +3833,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/blocks": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.12.tgz", - "integrity": "sha512-DohlTq6HM1jDbHYiXL4ZvZ00VkhpUp5uftzj/CZDLY1fYHRjqtaTwWm2/OpceivMA8zDitLcq5atEZN+f+siTg==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.14.tgz", + "integrity": "sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3842,7 +3853,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^8.6.12" + "storybook": "^8.6.14" }, "peerDependenciesMeta": { "react": { @@ -3854,13 +3865,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.12.tgz", - "integrity": "sha512-Gju21ud/3Qw4v2vLNaa5SuJECsI9ICNRr2G0UyCCzRvCHg8jpA9lDReu2NqhLDyFIuDG+ZYT38gcaHEUoNQ8KQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.14.tgz", + "integrity": "sha512-ajWYhy32ksBWxwWHrjwZzyC0Ii5ZTeu5lsqA95Q/EQBB0P5qWlHWGM3AVyv82Mz/ND03ebGy123uVwgf6olnYQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "8.6.12", + "@storybook/csf-plugin": "8.6.14", "browser-assert": "^1.2.1", "ts-dedent": "^2.0.0" }, @@ -3869,7 +3880,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12", + "storybook": "^8.6.14", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, @@ -3887,9 +3898,9 @@ } }, "node_modules/@storybook/components": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.12.tgz", - "integrity": "sha512-FiaE8xvCdvKC2arYusgtlDNZ77b8ysr8njAYQZwwaIHjy27TbR2tEpLDCmUwSbANNmivtc/xGEiDDwcNppMWlQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.14.tgz", + "integrity": "sha512-HNR2mC5I4Z5ek8kTrVZlIY/B8gJGs5b3XdZPBPBopTIN6U/YHXiDyOjY3JlaS4fSG1fVhp/Qp1TpMn1w/9m1pw==", "dev": true, "license": "MIT", "funding": { @@ -3901,13 +3912,13 @@ } }, "node_modules/@storybook/core": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.12.tgz", - "integrity": "sha512-t+ZuDzAlsXKa6tLxNZT81gEAt4GNwsKP/Id2wluhmUWD/lwYW0uum1JiPUuanw8xD6TdakCW/7ULZc7aQUBLCQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.14.tgz", + "integrity": "sha512-1P/w4FSNRqP8j3JQBOi3yGt8PVOgSRbP66Ok520T78eJBeqx9ukCfl912PQZ7SPbW3TIunBwLXMZOjZwBB/JmA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/theming": "8.6.12", + "@storybook/theming": "8.6.14", "better-opn": "^3.0.2", "browser-assert": "^1.2.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", @@ -3946,9 +3957,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.12.tgz", - "integrity": "sha512-6s8CnP1aoKPb3XtC0jRLUp8M5vTA8RhGAwQDKUsFpCC7g89JR9CaKs9FY2ZSzsNbjR15uASi7b3K8BzeYumYQg==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.14.tgz", + "integrity": "sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3959,20 +3970,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/experimental-addon-test": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/experimental-addon-test/-/experimental-addon-test-8.6.12.tgz", - "integrity": "sha512-auc8Ql0buH0WeaKVuSSuabxIiBWvqvAyxtXCm1sVMkL68GwrX3cmpNMwviz3mvKvM//F8zKi/31HMl1PZ5UnIA==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/experimental-addon-test/-/experimental-addon-test-8.6.14.tgz", + "integrity": "sha512-Ey1WFTRXSx6Tcv2vpxW16fhk03xQKUmsdg0yWKz9llkNVTGqmL5CNMWZ7j1ZnBGneeRJOvxNsz83SwBRrr588A==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.12", - "@storybook/instrumenter": "8.6.12", - "@storybook/test": "8.6.12", + "@storybook/instrumenter": "8.6.14", + "@storybook/test": "8.6.14", "polished": "^4.2.2", "prompts": "^2.4.0", "ts-dedent": "^2.2.0" @@ -3984,7 +3995,7 @@ "peerDependencies": { "@vitest/browser": "^2.1.1 || ^3.0.0", "@vitest/runner": "^2.1.1 || ^3.0.0", - "storybook": "^8.6.12", + "storybook": "^8.6.14", "vitest": "^2.1.1 || ^3.0.0" }, "peerDependenciesMeta": { @@ -4019,9 +4030,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.12.tgz", - "integrity": "sha512-VK5fYAF8jMwWP/u3YsmSwKGh+FeSY8WZn78flzRUwirp2Eg1WWjsqPRubAk7yTpcqcC/km9YMF3KbqfzRv2s/A==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.14.tgz", + "integrity": "sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4033,13 +4044,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/manager-api": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.12.tgz", - "integrity": "sha512-O0SpISeJLNTQvhSBOsWzzkCgs8vCjOq1578rwqHlC6jWWm4QmtfdyXqnv7rR1Hk08kQ+Dzqh0uhwHx0nfwy4nQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.14.tgz", + "integrity": "sha512-ez0Zihuy17udLbfHZQXkGqwtep0mSGgHcNzGN7iZrMP1m+VmNo+7aGCJJdvXi7+iU3yq8weXSQFWg5DqWgLS7g==", "dev": true, "license": "MIT", "funding": { @@ -4051,9 +4062,9 @@ } }, "node_modules/@storybook/preview-api": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.12.tgz", - "integrity": "sha512-84FE3Hrs0AYKHqpDZOwx1S/ffOfxBdL65lhCoeI8GoWwCkzwa9zEP3kvXBo/BnEDO7nAfxvMhjASTZXbKRJh5Q==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.14.tgz", + "integrity": "sha512-2GhcCd4dNMrnD7eooEfvbfL4I83qAqEyO0CO7JQAmIO6Rxb9BsOLLI/GD5HkvQB73ArTJ+PT50rfaO820IExOQ==", "dev": true, "license": "MIT", "funding": { @@ -4065,9 +4076,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.12.tgz", - "integrity": "sha512-51QvoimkBzYs8s3rCYnY5h0cFqLz/Mh0vRcughwYaXckWzDBV8l67WBO5Xf5nBsukCbWyqBVPpEQLww8s7mrLA==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.14.tgz", + "integrity": "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw==", "dev": true, "license": "MIT", "funding": { @@ -4077,18 +4088,18 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/test": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.12.tgz", - "integrity": "sha512-0BK1Eg+VD0lNMB1BtxqHE3tP9FdkUmohtvWG7cq6lWvMrbCmAmh3VWai3RMCCDOukPFpjabOr8BBRLVvhNpv2w==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.14.tgz", + "integrity": "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.12", + "@storybook/instrumenter": "8.6.14", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", @@ -4100,7 +4111,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/test/node_modules/@vitest/spy": { @@ -4117,9 +4128,9 @@ } }, "node_modules/@storybook/theming": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.12.tgz", - "integrity": "sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.14.tgz", + "integrity": "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg==", "dev": true, "license": "MIT", "funding": { @@ -4131,17 +4142,17 @@ } }, "node_modules/@storybook/web-components": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-8.6.12.tgz", - "integrity": "sha512-j+609VT8abBlpV+tB/vqSRO/fKA1QpnKWlbE0JpolzmEbgla//pAZomPysoOnvTLL3lSX3conjiAAaTpwbjyLg==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-8.6.14.tgz", + "integrity": "sha512-Jczq7EbIR3EJXX/n+0bKLW8mZS2g5Q57Y67fkw2hNr2E81OBL/4XSU9Dv4pZJXCstE1Ta+ELHj/fqbBbTj7QZA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/components": "8.6.12", + "@storybook/components": "8.6.14", "@storybook/global": "^5.0.0", - "@storybook/manager-api": "8.6.12", - "@storybook/preview-api": "8.6.12", - "@storybook/theming": "8.6.12", + "@storybook/manager-api": "8.6.14", + "@storybook/preview-api": "8.6.14", + "@storybook/theming": "8.6.14", "tiny-invariant": "^1.3.1", "ts-dedent": "^2.0.0" }, @@ -4154,18 +4165,18 @@ }, "peerDependencies": { "lit": "^2.0.0 || ^3.0.0", - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@storybook/web-components-vite": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-8.6.12.tgz", - "integrity": "sha512-1YVnkk7O9zPF5xTeWD5AxpmfCx9M7C9Du8pIwcypjHQLnb34PwiyjMRztYm/JKh5kCl4BZg187r2j3Q258ytHQ==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-8.6.14.tgz", + "integrity": "sha512-7lxz/hFMTSlhl1gkjqmEp9SchSHcoDCMJTu7LuM6fJWKLHydYB6zwIyL+qYvKDa+E+kgmUKc54yW5SqAWHkjoA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-vite": "8.6.12", - "@storybook/web-components": "8.6.12", + "@storybook/builder-vite": "8.6.14", + "@storybook/web-components": "8.6.14", "magic-string": "^0.30.0" }, "engines": { @@ -4176,7 +4187,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.12" + "storybook": "^8.6.14" } }, "node_modules/@swagger-api/apidom-ast": { @@ -16090,7 +16101,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/markdown-extensions": { "version": "2.0.0", @@ -16498,6 +16510,7 @@ "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", "dev": true, + "license": "MIT", "dependencies": { "map-or-similar": "^1.5.0" } @@ -21294,13 +21307,13 @@ "optional": true }, "node_modules/storybook": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.12.tgz", - "integrity": "sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==", + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz", + "integrity": "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.6.12" + "@storybook/core": "8.6.14" }, "bin": { "getstorybook": "bin/index.cjs", @@ -22771,10 +22784,11 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24867,6 +24881,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/core": { + "name": "@goauthentik/core", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@goauthentik/prettier-config": "^1.0.4", + "@goauthentik/tsconfig": "^1.0.4", + "@types/node": "^22.14.1", + "prettier": "^3.3.3", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=20.11" + } + }, + "packages/core/node_modules/@types/node": { + "version": "22.15.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", + "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/core/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/esbuild-plugin-live-reload": { "name": "@goauthentik/esbuild-plugin-live-reload", "version": "1.0.4", @@ -24910,6 +24956,7 @@ "packages/monorepo": { "name": "@goauthentik/monorepo", "version": "1.0.0", + "extraneous": true, "license": "MIT", "devDependencies": { "@goauthentik/prettier-config": "^1.0.4", @@ -24922,23 +24969,6 @@ "node": ">=20.11" } }, - "packages/monorepo/node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "packages/monorepo/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "packages/sfe": { "name": "@goauthentik/web-sfe", "version": "0.0.0", diff --git a/web/package.json b/web/package.json index 78b2e89354..a320315a39 100644 --- a/web/package.json +++ b/web/package.json @@ -35,15 +35,52 @@ "type": "module", "exports": { "./package.json": "./package.json", - "./paths": "./paths.js", - "./scripts/*": "./scripts/*.mjs", "./elements/*": "./src/elements/*", "./common/*": "./src/common/*", "./components/*": "./src/components/*", "./flow/*": "./src/flow/*", "./locales/*": "./src/locales/*", "./user/*": "./src/user/*", - "./admin/*": "./src/admin/*" + "./admin/*": "./src/admin/*", + "./*/browser": { + "types": "./out/*/browser.d.ts", + "import": "./*/browser.js" + }, + "./*/node": { + "types": "./out/*/node.d.ts", + "import": "./*/node.js" + }, + "./*": { + "types": "./out/*/index.d.ts", + "import": "./*/index.js" + } + }, + "imports": { + "#common/*.css": "./src/common/*.css", + "#common/*": "./src/common/*.js", + "#elements/*.css": "./src/elements/*.css", + "#elements/*": "./src/elements/*.js", + "#components/*.css": "./src/components/*.css", + "#components/*": "./src/components/*.js", + "#user/*.css": "./src/user/*.css", + "#user/*": "./src/user/*.js", + "#admin/*.css": "./src/admin/*.css", + "#admin/*": "./src/admin/*.js", + "#flow/*.css": "./src/flow/*.css", + "#flow/*": "./src/flow/*.js", + "#stories/*": "./src/stories/*.js", + "#*/browser": { + "types": "./out/*/browser.d.ts", + "import": "./*/browser.js" + }, + "#*/node": { + "types": "./out/*/node.d.ts", + "import": "./*/node.js" + }, + "#*": { + "types": "./out/*/index.d.ts", + "import": "./*/index.js" + } }, "dependencies": { "@codemirror/lang-css": "^6.3.1", @@ -105,21 +142,21 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@goauthentik/core": "^1.0.0", "@goauthentik/esbuild-plugin-live-reload": "^1.0.4", - "@goauthentik/monorepo": "^1.0.0", "@goauthentik/prettier-config": "^1.0.4", "@goauthentik/tsconfig": "^1.0.4", "@hcaptcha/types": "^1.0.4", "@lit/localize-tools": "^0.8.0", "@rollup/plugin-replace": "^6.0.1", - "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-essentials": "^8.6.14", "@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", + "@storybook/experimental-addon-test": "^8.6.14", + "@storybook/manager-api": "^8.6.14", + "@storybook/test": "^8.6.14", + "@storybook/web-components": "^8.6.14", + "@storybook/web-components-vite": "^8.6.14", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/chart.js": "^2.9.41", "@types/codemirror": "^5.60.15", @@ -152,10 +189,10 @@ "prettier": "^3.3.3", "pseudolocale": "^2.1.0", "rollup-plugin-postcss-lit": "^2.1.0", - "storybook": "^8.6.12", + "storybook": "^8.6.14", "storybook-addon-mock": "^5.0.0", "turnstile-types": "^1.2.3", - "typescript": "^5.6.2", + "typescript": "^5.8.3", "typescript-eslint": "^8.8.0", "vite-plugin-lit-css": "^2.0.0", "vite-tsconfig-paths": "^5.0.1", @@ -382,7 +419,6 @@ "node": ">=20" }, "workspaces": [ - ".", "./packages/*" ], "prettier": "@goauthentik/prettier-config", diff --git a/web/packages/monorepo/LICENSE.txt b/web/packages/core/LICENSE.txt similarity index 100% rename from web/packages/monorepo/LICENSE.txt rename to web/packages/core/LICENSE.txt diff --git a/web/packages/monorepo/README.md b/web/packages/core/README.md similarity index 82% rename from web/packages/monorepo/README.md rename to web/packages/core/README.md index a3930c1994..774455f595 100644 --- a/web/packages/monorepo/README.md +++ b/web/packages/core/README.md @@ -1,4 +1,4 @@ -# `@goauthentik/monorepo` +# `@goauthentik/core` This package contains utility scripts common to all TypeScript and JavaScript packages in the `@goauthentik` monorepo. diff --git a/web/packages/core/environment/node.js b/web/packages/core/environment/node.js new file mode 100644 index 0000000000..033374a103 --- /dev/null +++ b/web/packages/core/environment/node.js @@ -0,0 +1,66 @@ +/** + * @file Utility functions for working with environment variables. + */ +/// + +//#region Constants + +/** + * The current Node.js environment, defaulting to "development" when not set. + * + * Note, this should only be used during the build process. + * + * If you need to check the environment at runtime, use `process.env.NODE_ENV` to + * ensure that module tree-shaking works correctly. + * + * @category Environment + * @runtime node + */ +export const NodeEnvironment = process.env.NODE_ENV || "development"; + +/** + * A source environment variable, which can be a string, number, boolean, null, or undefined. + * @typedef {string | number | boolean | null | undefined} EnvironmentVariable + */ + +/** + * A type helper for serializing environment variables. + * + * @category Environment + * @template {EnvironmentVariable} T + * @typedef {T extends string ? `"${T}"` : T} JSONify + */ + +//#endregion + +//#region Utilities + +/** + * Given an object of environment variables, serializes them into a mapping of + * environment variable names to their respective runtime constants. + * + * This is useful for defining environment variables while bundling with ESBuild, Vite, etc. + * + * @category Environment + * @runtime node + * + * @template {Record} EnvRecord + * @template {string} [Prefix='import.meta.env.'] + * + * @param {EnvRecord} input + * @param {Prefix} [prefix='import.meta.env.'] + * + * @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify}} + */ +export function serializeEnvironmentVars( + input, + prefix = /** @type {Prefix} */ ("import.meta.env."), +) { + const env = Object.fromEntries( + Object.entries(input).map(([key, value]) => [prefix + key, JSON.stringify(value ?? "")]), + ); + + return /** @type {any} */ (env); +} + +//#endregion diff --git a/web/packages/core/index.js b/web/packages/core/index.js new file mode 100644 index 0000000000..6ec0988b54 --- /dev/null +++ b/web/packages/core/index.js @@ -0,0 +1,12 @@ +/** + * @file Dummy entry point for the `core` package. + * + * This exists to make TypeScript's module resolution more predictable. + * + * @internal + * @ignore + */ + +export {}; + +export default {}; diff --git a/web/packages/core/package.json b/web/packages/core/package.json new file mode 100644 index 0000000000..eea5cfdba6 --- /dev/null +++ b/web/packages/core/package.json @@ -0,0 +1,59 @@ +{ + "name": "@goauthentik/core", + "version": "1.0.0", + "description": "Core functionality for the authentik monorepo", + "license": "MIT", + "private": true, + "scripts": { + "build": "tsc -p .", + "prettier": "prettier --write .", + "prettier-check": "prettier --check ." + }, + "main": "index.js", + "type": "module", + "exports": { + "./package.json": "./package.json", + "./*/browser": { + "types": "./out/*/browser.d.ts", + "import": "./*/browser.js" + }, + "./*/node": { + "types": "./out/*/node.d.ts", + "import": "./*/node.js" + }, + "./*": { + "types": "./out/*/index.d.ts", + "import": "./*/index.js" + }, + ".": { + "import": "./index.js", + "types": "./out/index.d.ts" + } + }, + "imports": { + "#*/browser": { + "types": "./out/*/browser.d.ts", + "import": "./*/browser.js" + }, + "#*/node": { + "types": "./out/*/node.d.ts", + "import": "./*/node.js" + }, + "#*": { + "types": "./out/*/index.d.ts", + "import": "./*/index.js" + } + }, + "devDependencies": { + "@goauthentik/prettier-config": "^1.0.4", + "@goauthentik/tsconfig": "^1.0.4", + "@types/node": "^22.14.1", + "prettier": "^3.3.3", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=20.11" + }, + "types": "./out/index.d.ts", + "prettier": "@goauthentik/prettier-config" +} diff --git a/web/packages/monorepo/paths.js b/web/packages/core/paths/node.js similarity index 72% rename from web/packages/monorepo/paths.js rename to web/packages/core/paths/node.js index ab923a26af..77ec9b1ca3 100644 --- a/web/packages/monorepo/paths.js +++ b/web/packages/core/paths/node.js @@ -10,22 +10,26 @@ const relativeDirname = dirname(fileURLToPath(import.meta.url)); /** * The root of the authentik monorepo. + * + * @runtime node */ -// TODO: Revise when this package is moved to the monorepo's `packages/monorepo` directory. export const MonoRepoRoot = /** @type {MonoRepoRoot} */ ( - resolve(relativeDirname, "..", "..", "..") + resolve(relativeDirname, "..", "..", "..", "..") ); -const require = createRequire(import.meta.url); - /** * Resolve a package name to its location in the monorepo to the single node_modules directory. - * @param {string} packageName * + * @param {string} packageName + * @param {ImportMeta} [meta] The `import.meta` object of the module. + * + * @runtime node * @returns {string} The resolved path to the package. * @throws {Error} If the package cannot be resolved. */ -export function resolvePackage(packageName) { +export function resolvePackage(packageName, meta) { + const require = createRequire(meta ? meta.url : import.meta.url); + const relativePackageJSONPath = join(packageName, "package.json"); /** @type {string} */ @@ -34,7 +38,7 @@ export function resolvePackage(packageName) { try { absolutePackageJSONPath = require.resolve(relativePackageJSONPath); } catch (cause) { - const error = new Error(`Failed to resolve package "${packageName}"`); + const error = new Error(`🚫 Failed to resolve package "${packageName}"`); error.cause = cause; diff --git a/web/packages/monorepo/scripting.js b/web/packages/core/scripting/node.js similarity index 98% rename from web/packages/monorepo/scripting.js rename to web/packages/core/scripting/node.js index 6e260d5287..16513d3d06 100644 --- a/web/packages/monorepo/scripting.js +++ b/web/packages/core/scripting/node.js @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; * @param {ImportMeta} meta The `import.meta` object of the module. * * @return {boolean} Whether the module was run directly. + * @runtime node */ export function isMain(meta) { // Are we not in a module context? diff --git a/web/packages/monorepo/tsconfig.json b/web/packages/core/tsconfig.json similarity index 72% rename from web/packages/monorepo/tsconfig.json rename to web/packages/core/tsconfig.json index 83e94d8cf2..f5338b1c66 100644 --- a/web/packages/monorepo/tsconfig.json +++ b/web/packages/core/tsconfig.json @@ -1,9 +1,11 @@ { "extends": "@goauthentik/tsconfig", "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ESNext"], "resolveJsonModule": true, "baseUrl": ".", "checkJs": true, + "allowJs": true, "emitDeclarationOnly": true } } diff --git a/web/packages/core/types/node.d.ts b/web/packages/core/types/node.d.ts new file mode 100644 index 0000000000..07d3b8119a --- /dev/null +++ b/web/packages/core/types/node.d.ts @@ -0,0 +1,54 @@ +/** + * @file Global variables provided by Node.js + */ + +declare module "module" { + global { + /** + * @deprecated This is not present in ESM files. + * + * ```js + * import { dirname } from "node:path"; + * import { fileURLToPath } from "node:url"; + * + * const relativeDirname = dirname(fileURLToPath(import.meta.url)); + * ``` + */ + // eslint-disable-next-line no-var + var __dirname: string; + } +} + +declare module "process" { + global { + namespace NodeJS { + interface ProcessEnv { + /** + * The port used by the Docker Compose HTTP service. + */ + COMPOSE_PORT_HTTP?: string; + + /** + * The port used by the Docker Compose HTTPS service. + */ + COMPOSE_PORT_HTTPS?: string; + + /** + * An environment variable used to determine + * whether Node.js is running in production mode. + * + * @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production} + * @runtime node + */ + readonly NODE_ENV?: "development" | "production"; + /** + * @todo Determine where this is used and if it is needed, + * give it a better name. + * @deprecated + * @runtime node + */ + readonly AK_API_BASE_PATH?: string; + } + } + } +} diff --git a/web/packages/core/version/index.js b/web/packages/core/version/index.js new file mode 100644 index 0000000000..3bb7c19ba7 --- /dev/null +++ b/web/packages/core/version/index.js @@ -0,0 +1,21 @@ +/** + * @file Utility functions for working with semantic versions. + * + * @runtime common + */ + +/** + * Creates a URL to the release notes for the given version. + * + * @param {string} semver + * @returns {URL} + * @runtime common + */ +export function createReleaseNotesURL(semver) { + const segments = semver.split("."); + const versionFamily = segments.slice(0, -1).join("."); + + const release = `${versionFamily}#fixed-in-${segments.join("")}`; + + return new URL(`/docs/releases/${release}`, "https://goauthentik.io"); +} diff --git a/web/packages/monorepo/version.js b/web/packages/core/version/node.js similarity index 80% rename from web/packages/monorepo/version.js rename to web/packages/core/version/node.js index e738968d7b..7622a0097e 100644 --- a/web/packages/monorepo/version.js +++ b/web/packages/core/version/node.js @@ -1,16 +1,24 @@ +/** + * @file Utility functions for working with semantic versions. + * + * @runtime node + */ +import { MonoRepoRoot } from "#paths/node"; import { execSync } from "node:child_process"; -import PackageJSON from "../../../package.json" with { type: "json" }; -import { MonoRepoRoot } from "./paths.js"; +import PackageJSON from "../../../../package.json" with { type: "json" }; /** * The current version of authentik in SemVer format. * + * @runtime node */ export const AuthentikVersion = /**@type {`${number}.${number}.${number}`} */ (PackageJSON.version); /** * Reads the last commit hash from the current git repository. + * + * @runtime node */ export function readGitBuildHash() { try { @@ -34,6 +42,7 @@ export function readGitBuildHash() { * * This must match the behavior defined in authentik's server-side `get_full_version` function. * + * @runtime node * @see {@link "authentik\_\_init\_\_.py"} */ export function readBuildIdentifier() { diff --git a/web/packages/esbuild-plugin-live-reload/client/ESBuildObserver.js b/web/packages/esbuild-plugin-live-reload/client/ESBuildObserver.js index 2f8ebe3ce2..9207a89508 100644 --- a/web/packages/esbuild-plugin-live-reload/client/ESBuildObserver.js +++ b/web/packages/esbuild-plugin-live-reload/client/ESBuildObserver.js @@ -21,7 +21,7 @@ const log = console.debug.bind(console, logPrefix); * ESBuild may tree-shake it out of production builds. * * ```ts - * if (process.env.NODE_ENV === "development") { + * if (process.env.NODE_ENV=== "development") { * await import("@goauthentik/esbuild-plugin-live-reload/client") * .catch(() => console.warn("Failed to import watcher")) * } diff --git a/web/packages/esbuild-plugin-live-reload/client/types.d.ts b/web/packages/esbuild-plugin-live-reload/client/types.d.ts index c446181716..370d24a148 100644 --- a/web/packages/esbuild-plugin-live-reload/client/types.d.ts +++ b/web/packages/esbuild-plugin-live-reload/client/types.d.ts @@ -4,15 +4,20 @@ export {}; declare global { + /** + * Environment variables injected by ESBuild. + */ + interface ImportMetaEnv { + /** + * The injected watcher URL for ESBuild. + * This is used for live reloading in development mode. + * + * @format url + */ + readonly ESBUILD_WATCHER_URL?: string; + } + interface ImportMeta { - readonly env: { - /** - * The injected watcher URL for ESBuild. - * This is used for live reloading in development mode. - * - * @format url - */ - ESBUILD_WATCHER_URL: string; - }; + readonly env: ImportMetaEnv; } } diff --git a/web/packages/monorepo/build.js b/web/packages/monorepo/build.js deleted file mode 100644 index 343d522cd6..0000000000 --- a/web/packages/monorepo/build.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @file Utility functions for building and copying files. - */ - -/** - * A source environment variable, which can be a string, number, boolean, null, or undefined. - * @typedef {string | number | boolean | null | undefined} EnvironmentVariable - */ - -/** - * A type helper for serializing environment variables. - * - * @template {EnvironmentVariable} T - * @typedef {T extends string ? `"${T}"` : T} JSONify - */ - -/** - * Given an object of environment variables, returns a new object with the same keys and values, but - * with the values serialized as strings. - * - * @template {Record} EnvRecord - * @template {string} [Prefix='process.env.'] - * - * @param {EnvRecord} input - * @param {Prefix} [prefix='process.env.'] - * - * @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify}} - */ -export function serializeEnvironmentVars(input, prefix = /** @type {Prefix} */ ("process.env.")) { - /** - * @type {Record} - */ - const env = {}; - - for (const [key, value] of Object.entries(input)) { - const namespaceKey = prefix + key; - - env[namespaceKey] = JSON.stringify(value || ""); - } - - return /** @type {any} */ (env); -} diff --git a/web/packages/monorepo/constants.js b/web/packages/monorepo/constants.js deleted file mode 100644 index 6abf55e8a5..0000000000 --- a/web/packages/monorepo/constants.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @file Constants for JavaScript and TypeScript files. - */ - -/// - -/** - * The current Node.js environment, defaulting to "development" when not set. - * - * Note, this should only be used during the build process. - * - * If you need to check the environment at runtime, use `process.env.NODE_ENV` to - * ensure that module tree-shaking works correctly. - * - */ -export const NodeEnvironment = process.env.NODE_ENV || "development"; diff --git a/web/packages/monorepo/index.js b/web/packages/monorepo/index.js deleted file mode 100644 index 284d2b5d2b..0000000000 --- a/web/packages/monorepo/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/// - -export * from "./paths.js"; -export * from "./constants.js"; -export * from "./build.js"; -export * from "./version.js"; -export * from "./scripting.js"; diff --git a/web/packages/monorepo/package.json b/web/packages/monorepo/package.json deleted file mode 100644 index 86b0b3aa21..0000000000 --- a/web/packages/monorepo/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@goauthentik/monorepo", - "version": "1.0.0", - "description": "Utilities for the authentik monorepo.", - "license": "MIT", - "private": true, - "main": "index.js", - "type": "module", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./out/index.d.ts", - "import": "./index.js" - } - }, - "devDependencies": { - "@goauthentik/prettier-config": "^1.0.4", - "@goauthentik/tsconfig": "^1.0.4", - "@types/node": "^22.14.1", - "prettier": "^3.3.3", - "typescript": "^5.6.2" - }, - "engines": { - "node": ">=20.11" - }, - "types": "./out/index.d.ts", - "prettier": "@goauthentik/prettier-config" -} diff --git a/web/packages/monorepo/types/global.d.ts b/web/packages/monorepo/types/global.d.ts deleted file mode 100644 index 90ebfa5555..0000000000 --- a/web/packages/monorepo/types/global.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module "process" { - global { - namespace NodeJS { - interface ProcessEnv { - /** - * An environment variable used to determine - * whether Node.js is running in production mode. - * - * @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production} - */ - NODE_ENV?: "production" | "development"; - } - } - } -} diff --git a/web/paths.js b/web/paths/node.js similarity index 96% rename from web/paths.js rename to web/paths/node.js index 5d9dac17de..7570231833 100644 --- a/web/paths.js +++ b/web/paths/node.js @@ -12,7 +12,7 @@ const relativeDirname = dirname(fileURLToPath(import.meta.url)); /** * The root of the web package. */ -export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(relativeDirname)); +export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(relativeDirname, "..")); /** * The name of the distribution directory. @@ -25,7 +25,7 @@ export const DistDirectoryName = "dist"; * This is where the built files are located after running the build process. */ export const DistDirectory = /** @type {`${WebPackageIdentifier}/${DistDirectoryName}`} */ ( - resolve(relativeDirname, DistDirectoryName) + resolve(PackageRoot, DistDirectoryName) ); //#endregion diff --git a/web/scripts/build-locales.mjs b/web/scripts/build-locales.mjs index 991ac8306d..375a7b55e7 100644 --- a/web/scripts/build-locales.mjs +++ b/web/scripts/build-locales.mjs @@ -1,17 +1,33 @@ -import { spawnSync } from "child_process"; -import fs from "fs"; -import path from "path"; -import process from "process"; +/** + * @file Lit Localize build script. + * + * @remarks + * Determines if all the Xliff translation source files are present and + * if the Typescript source files generated from those sources are up-to-date. + * + * If they are not, it runs the locale building script, intercepting the + * long spew of "this string is not translated" and replacing it with a + * summary of how many strings are missing with respect to the source locale. + * + * @import { ConfigFile } from "@lit/localize-tools/lib/types/config" + */ +import { PackageRoot } from "#paths/node"; +import { spawnSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; /** - * Determines if all the Xliff translation source files are present and if the Typescript source - * files generated from those sources are up-to-date. If they are not, it runs the locale building - * script, intercepting the long spew of "this string is not translated" and replacing it with a - * summary of how many strings are missing with respect to the source locale. + * @type {ConfigFile} */ +const localizeRules = JSON.parse( + readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"), +); -const localizeRules = JSON.parse(fs.readFileSync("./lit-localize.json", "utf-8")); - +/** + * + * @param {string} loc + * @returns {boolean} + */ function generatedFileIsUpToDateWithXliffSource(loc) { const xliff = path.join("./xliff", `${loc}.xlf`); const gened = path.join("./src/locales", `${loc}.ts`); @@ -22,7 +38,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) { // generates a unique error message and halts the build. try { - var xlfStat = fs.statSync(xliff); + var xlfStat = statSync(xliff); } catch (_error) { console.error(`lit-localize expected '${loc}.xlf', but XLF file is not present`); process.exit(1); @@ -30,7 +46,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) { // If the generated file doesn't exist, of course it's not up to date. try { - var genedStat = fs.statSync(gened); + var genedStat = statSync(gened); } catch (_error) { return false; } diff --git a/web/scripts/build-web.mjs b/web/scripts/build-web.mjs index 3b1f329e23..d757dd7752 100644 --- a/web/scripts/build-web.mjs +++ b/web/scripts/build-web.mjs @@ -1,17 +1,16 @@ +/// /** * @file ESBuild script for building the authentik web UI. * * @import { BuildOptions } from "esbuild"; */ +import { mdxPlugin } from "#bundler/mdx-plugin/node"; +import { createBundleDefinitions } from "#bundler/utils/node"; +import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "#paths/node"; +import { NodeEnvironment } from "@goauthentik/core/environment/node"; +import { MonoRepoRoot, resolvePackage } from "@goauthentik/core/paths/node"; +import { readBuildIdentifier } from "@goauthentik/core/version/node"; import { liveReloadPlugin } from "@goauthentik/esbuild-plugin-live-reload/plugin"; -import { - MonoRepoRoot, - NodeEnvironment, - readBuildIdentifier, - resolvePackage, - serializeEnvironmentVars, -} from "@goauthentik/monorepo"; -import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "@goauthentik/web/paths"; import { deepmerge } from "deepmerge-ts"; import esbuild from "esbuild"; import copy from "esbuild-plugin-copy"; @@ -19,17 +18,9 @@ import { polyfillNode } from "esbuild-plugin-polyfill-node"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs"; - const logPrefix = "[Build]"; -const definitions = serializeEnvironmentVars({ - NODE_ENV: NodeEnvironment, - CWD: process.cwd(), - AK_API_BASE_PATH: process.env.AK_API_BASE_PATH, -}); - -const patternflyPath = resolvePackage("@patternfly/patternfly"); +const patternflyPath = resolvePackage("@patternfly/patternfly", import.meta); /** * @type {Readonly} @@ -86,7 +77,7 @@ const BASE_ESBUILD_OPTIONS = { root: MonoRepoRoot, }), ], - define: definitions, + define: createBundleDefinitions(), format: "esm", logOverride: { /** @@ -168,6 +159,19 @@ async function doWatch() { await buildContext.rebuild(); await buildContext.watch(); + const httpURL = new URL("http://localhost"); + httpURL.port = process.env.COMPOSE_PORT_HTTP ?? "9000"; + + const httpsURL = new URL("http://localhost"); + httpsURL.port = process.env.COMPOSE_PORT_HTTPS ?? "9443"; + + console.log(`\n${logPrefix} 🚀 Server running\n\n`); + + console.log(` 🔓 ${httpURL.href}`); + console.log(` 🔒 ${httpsURL.href}`); + + console.log(`\n---`); + return /** @type {Promise} */ ( new Promise((resolve) => { process.on("SIGINT", () => { diff --git a/web/scripts/eslint.mjs b/web/scripts/eslint.mjs index 63ba74eebd..3b38f544d1 100644 --- a/web/scripts/eslint.mjs +++ b/web/scripts/eslint.mjs @@ -35,6 +35,11 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url)); const projectRoot = path.join(__dirname, ".."); process.chdir(projectRoot); +/** + * + * @param {string[]} flags + * @returns + */ const hasFlag = (flags) => process.argv.length > 1 && flags.includes(process.argv[2]); const [configFile, files] = hasFlag(["-n", "--nightmare"]) diff --git a/web/scripts/eslint.nightmare.mjs b/web/scripts/eslint.nightmare.mjs index 484621b01b..346e293470 100644 --- a/web/scripts/eslint.nightmare.mjs +++ b/web/scripts/eslint.nightmare.mjs @@ -152,6 +152,7 @@ export default [ { ignores: [ "dist/", + "out/", ".wireit/", "packages/", // don't ever lint node_modules diff --git a/web/scripts/pseudolocalize.mjs b/web/scripts/pseudolocalize.mjs index d0495f15c8..416fa9630f 100644 --- a/web/scripts/pseudolocalize.mjs +++ b/web/scripts/pseudolocalize.mjs @@ -1,22 +1,36 @@ -import { readFileSync } from "fs"; -import path from "path"; +/** + * @file Pseudo-localization script. + * + * @import { ConfigFile } from "@lit/localize-tools/lib/types/config.js" + * @import { Config } from '@lit/localize-tools/lib/types/config.js'; + * @import { ProgramMessage } from "@lit/localize-tools/src/messages.js" + * @import { Locale } from "@lit/localize-tools/src/types/locale.js" + */ +import { PackageRoot } from "#paths/node"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import pseudolocale from "pseudolocale"; -import { fileURLToPath } from "url"; import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js"; import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js"; import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js"; -const __dirname = fileURLToPath(new URL(".", import.meta.url)); -const pseudoLocale = "pseudo-LOCALE"; +const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE"); const targetLocales = [pseudoLocale]; -const baseConfig = JSON.parse(readFileSync(path.join(__dirname, "../lit-localize.json"), "utf-8")); + +/** + * @type {ConfigFile} + */ +const baseConfig = JSON.parse(readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8")); // Need to make some internal specifications to satisfy the transformer. It doesn't actually matter // which Localizer we use (transformer or runtime), because all of the functionality we care about // is in their common parent class, but I had to pick one. Everything else here is just pure // exploitation of the lit/localize-tools internals. +/** + * @satisfies {Config} + */ const config = { ...baseConfig, baseDir: path.join(__dirname, ".."), @@ -28,6 +42,11 @@ const config = { resolve: (path) => path, }; +/** + * + * @param {ProgramMessage} message + * @returns + */ const pseudoMessagify = (message) => ({ name: message.name, contents: message.contents.map((content) => @@ -36,7 +55,7 @@ const pseudoMessagify = (message) => ({ }); const localizer = new TransformLitLocalizer(config); -const messages = localizer.extractSourceMessages().messages; +const { messages } = localizer.extractSourceMessages(); const translations = messages.map(pseudoMessagify); const sorted = sortProgramMessages([...messages]); const formatter = makeFormatter(config); diff --git a/web/src/admin/AdminInterface/AboutModal.ts b/web/src/admin/AdminInterface/AboutModal.ts index d947ba43ac..d5b7e7e71a 100644 --- a/web/src/admin/AdminInterface/AboutModal.ts +++ b/web/src/admin/AdminInterface/AboutModal.ts @@ -1,5 +1,4 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { DefaultBrand } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/EmptyState"; @@ -45,7 +44,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) } return [ [msg("Version"), version.versionCurrent], - [msg("UI Version"), VERSION], + [msg("UI Version"), import.meta.env.AK_VERSION], [msg("Build"), build], [msg("Python version"), status.runtime.pythonVersion], [msg("Platform"), status.runtime.platform], diff --git a/web/src/admin/AdminInterface/index.entrypoint.ts b/web/src/admin/AdminInterface/index.entrypoint.ts index 9b6480078e..5a0290c71e 100644 --- a/web/src/admin/AdminInterface/index.entrypoint.ts +++ b/web/src/admin/AdminInterface/index.entrypoint.ts @@ -1,29 +1,26 @@ +import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants"; +import { configureSentry } from "#common/sentry/index"; +import { me } from "#common/users"; +import { WebsocketClient } from "#common/ws"; +import { AuthenticatedInterface } from "#elements/Interface/Interface"; +import { WithLicenseSummary } from "#elements/Interface/licenseSummaryProvider"; +import "#elements/ak-locale-context/ak-locale-context"; +import "#elements/banner/EnterpriseStatusBanner"; +import "#elements/banner/EnterpriseStatusBanner"; +import "#elements/banner/VersionBanner"; +import "#elements/banner/VersionBanner"; +import "#elements/messages/MessageContainer"; +import "#elements/messages/MessageContainer"; +import "#elements/notifications/APIDrawer"; +import "#elements/notifications/NotificationDrawer"; +import { getURLParam, updateURLParams } from "#elements/router/RouteMatch"; +import "#elements/router/RouterOutlet"; +import "#elements/sidebar/Sidebar"; +import "#elements/sidebar/SidebarItem"; import "@goauthentik/admin/AdminInterface/AboutModal"; import type { AboutModal } from "@goauthentik/admin/AdminInterface/AboutModal"; import { ROUTES } from "@goauthentik/admin/Routes"; -import { - EVENT_API_DRAWER_TOGGLE, - EVENT_NOTIFICATION_DRAWER_TOGGLE, -} from "@goauthentik/common/constants"; -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"; -import "@goauthentik/elements/banner/VersionBanner"; -import "@goauthentik/elements/banner/VersionBanner"; -import "@goauthentik/elements/messages/MessageContainer"; -import "@goauthentik/elements/messages/MessageContainer"; -import "@goauthentik/elements/notifications/APIDrawer"; -import "@goauthentik/elements/notifications/NotificationDrawer"; -import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; -import "@goauthentik/elements/router/RouterOutlet"; -import "@goauthentik/elements/sidebar/Sidebar"; -import "@goauthentik/elements/sidebar/SidebarItem"; +import { SidebarToggleEventDetail } from "@goauthentik/components/ak-page-header.js"; import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, eventOptions, property, query } from "lit/decorators.js"; diff --git a/web/src/admin/DebugPage.ts b/web/src/admin/DebugPage.ts index 050e242fec..1e2b64c641 100644 --- a/web/src/admin/DebugPage.ts +++ b/web/src/admin/DebugPage.ts @@ -1,9 +1,9 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network"; -import { MessageLevel } from "@goauthentik/common/messages"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/PageHeader"; -import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; +import { MessageLevel } from "#common/messages"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import { showMessage } from "#elements/messages/MessageContainer"; import * as Sentry from "@sentry/browser"; import { CSSResult, TemplateResult, html } from "lit"; diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index 8a750f15fc..5ba4ce6b9b 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -1,29 +1,27 @@ -import "@goauthentik/admin/admin-overview/TopApplicationsTable"; -import "@goauthentik/admin/admin-overview/cards/AdminStatusCard"; -import "@goauthentik/admin/admin-overview/cards/FipsStatusCard"; -import "@goauthentik/admin/admin-overview/cards/RecentEventsCard"; -import "@goauthentik/admin/admin-overview/cards/SystemStatusCard"; -import "@goauthentik/admin/admin-overview/cards/VersionStatusCard"; -import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard"; -import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart"; -import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart"; -import "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; -import { VERSION } from "@goauthentik/common/constants"; -import { me } from "@goauthentik/common/users"; -import { AKElement } from "@goauthentik/elements/Base"; -import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/cards/AggregatePromiseCard"; -import "@goauthentik/elements/cards/QuickActionsCard.js"; -import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js"; -import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; +import "#admin/admin-overview/TopApplicationsTable"; +import "#admin/admin-overview/cards/AdminStatusCard"; +import "#admin/admin-overview/cards/FipsStatusCard"; +import "#admin/admin-overview/cards/RecentEventsCard"; +import "#admin/admin-overview/cards/SystemStatusCard"; +import "#admin/admin-overview/cards/VersionStatusCard"; +import "#admin/admin-overview/cards/WorkerStatusCard"; +import "#admin/admin-overview/charts/AdminLoginAuthorizeChart"; +import "#admin/admin-overview/charts/OutpostStatusChart"; +import "#admin/admin-overview/charts/SyncStatusChart"; +import { me } from "#common/users"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import { WithLicenseSummary } from "#elements/Interface/licenseSummaryProvider"; +import "#elements/cards/AggregatePromiseCard"; +import type { QuickAction } from "#elements/cards/QuickActionsCard"; +import "#elements/cards/QuickActionsCard"; +import { paramURL } from "#elements/router/RouterOutlet"; +import { createReleaseNotesURL } from "@goauthentik/core/version"; import { msg, str } from "@lit/localize"; import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; -import { map } from "lit/directives/map.js"; -import { when } from "lit/directives/when.js"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFDivider from "@patternfly/patternfly/components/Divider/divider.css"; @@ -33,21 +31,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { SessionUser } from "@goauthentik/api"; -export function versionFamily(): string { - const parts = VERSION.split("."); - parts.pop(); - return parts.join("."); -} - -const RELEASE = `${VERSION.split(".").slice(0, -1).join(".")}#fixed-in-${VERSION.replaceAll( - ".", - "", -)}`; - const AdminOverviewBase = WithLicenseSummary(AKElement); -type Renderer = () => TemplateResult | typeof nothing; - @customElement("ak-admin-overview") export class AdminOverviewPage extends AdminOverviewBase { static get styles(): CSSResult[] { @@ -83,7 +68,11 @@ export class AdminOverviewPage extends AdminOverviewBase { [msg("Check the logs"), paramURL("/events/log")], [msg("Explore integrations"), "https://goauthentik.io/integrations/", true], [msg("Manage users"), paramURL("/identity/users")], - [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true], + [ + msg("Check the release notes"), + createReleaseNotesURL(import.meta.env.AK_VERSION).href, + true, + ], ]; @state() @@ -193,45 +182,6 @@ export class AdminOverviewPage extends AdminOverviewBase { ` : nothing} `; } - - renderActions() { - const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`; - - const quickActions: [string, string][] = [ - [msg("Create a new application"), paramURL("/core/applications", { createForm: true })], - [msg("Check the logs"), paramURL("/events/log")], - [msg("Explore integrations"), "https://goauthentik.io/integrations/"], - [msg("Manage users"), paramURL("/identity/users")], - [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`], - ]; - - const action = ([label, url]: [string, string]) => { - const isExternal = url.startsWith("https://"); - const ex = (truecase: Renderer, falsecase: Renderer) => - when(isExternal, truecase, falsecase); - - const content = html`${label}${ex( - () => html``, - () => nothing, - )}`; - - return html`
  • - ${ex( - () => - html`${content}`, - () => html`${content}`, - )} -
  • `; - }; - - return html`${map(quickActions, action)}`; - } } declare global { diff --git a/web/src/admin/admin-overview/DashboardUserPage.ts b/web/src/admin/admin-overview/DashboardUserPage.ts index 3fd5483422..eb1b168bd0 100644 --- a/web/src/admin/admin-overview/DashboardUserPage.ts +++ b/web/src/admin/admin-overview/DashboardUserPage.ts @@ -1,7 +1,7 @@ -import "@goauthentik/admin/admin-overview/charts/AdminModelPerDay"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/cards/AggregatePromiseCard"; +import "#admin/admin-overview/charts/AdminModelPerDay"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import "#elements/cards/AggregatePromiseCard"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; diff --git a/web/src/admin/admin-settings/AdminSettingsPage.ts b/web/src/admin/admin-settings/AdminSettingsPage.ts index b1d1a34a42..9693750a0c 100644 --- a/web/src/admin/admin-settings/AdminSettingsPage.ts +++ b/web/src/admin/admin-settings/AdminSettingsPage.ts @@ -1,16 +1,15 @@ -import "@goauthentik/admin/admin-settings/AdminSettingsForm"; -import { AdminSettingsForm } from "@goauthentik/admin/admin-settings/AdminSettingsForm"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import "@goauthentik/components/events/ObjectChangelog"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/CodeMirror"; -import "@goauthentik/elements/EmptyState"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/Tabs"; -import "@goauthentik/elements/buttons/ModalButton"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/forms/ModalForm"; +import "#admin/admin-settings/AdminSettingsForm"; +import { AdminSettingsForm } from "#admin/admin-settings/AdminSettingsForm"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import "#components/ak-page-header"; +import "#components/events/ObjectChangelog"; +import { AKElement } from "#elements/Base"; +import "#elements/CodeMirror"; +import "#elements/EmptyState"; +import "#elements/Tabs"; +import "#elements/buttons/ModalButton"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; +import "#elements/forms/ModalForm"; import { msg } from "@lit/localize"; import { html, nothing } from "lit"; diff --git a/web/src/admin/applications/ApplicationViewPage.ts b/web/src/admin/applications/ApplicationViewPage.ts index 6cd06094f9..1de763df35 100644 --- a/web/src/admin/applications/ApplicationViewPage.ts +++ b/web/src/admin/applications/ApplicationViewPage.ts @@ -1,18 +1,18 @@ -import "@goauthentik/admin/applications/ApplicationAuthorizeChart"; -import "@goauthentik/admin/applications/ApplicationCheckAccessForm"; -import "@goauthentik/admin/applications/ApplicationForm"; -import "@goauthentik/admin/applications/entitlements/ApplicationEntitlementPage"; -import "@goauthentik/admin/policies/BoundPoliciesList"; -import "@goauthentik/admin/rbac/ObjectPermissionsPage"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { PFSize } from "@goauthentik/common/enums.js"; -import "@goauthentik/components/events/ObjectChangelog"; -import "@goauthentik/elements/AppIcon"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/Tabs"; -import "@goauthentik/elements/buttons/SpinnerButton"; +import "#admin/applications/ApplicationAuthorizeChart"; +import "#admin/applications/ApplicationCheckAccessForm"; +import "#admin/applications/ApplicationForm"; +import "#admin/applications/entitlements/ApplicationEntitlementPage"; +import "#admin/policies/BoundPoliciesList"; +import "#admin/rbac/ObjectPermissionsPage"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { PFSize } from "#common/enums"; +import "#components/ak-page-header"; +import "#components/events/ObjectChangelog"; +import "#elements/AppIcon"; +import { AKElement } from "#elements/Base"; +import "#elements/EmptyState"; +import "#elements/Tabs"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; import { msg } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; diff --git a/web/src/admin/events/EventViewPage.ts b/web/src/admin/events/EventViewPage.ts index ca24b4cfc5..45c5badd23 100644 --- a/web/src/admin/events/EventViewPage.ts +++ b/web/src/admin/events/EventViewPage.ts @@ -1,11 +1,11 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { EventWithContext } from "@goauthentik/common/events"; -import { actionToLabel } from "@goauthentik/common/labels"; -import { formatElapsedTime } from "@goauthentik/common/temporal"; -import "@goauthentik/components/ak-event-info"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/PageHeader"; +import { EventGeo, EventUser } from "#admin/events/utils"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { EventWithContext } from "#common/events"; +import { actionToLabel } from "#common/labels"; +import { formatElapsedTime } from "#common/temporal"; +import "#components/ak-event-info"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; import { msg, str } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; diff --git a/web/src/admin/flows/FlowViewPage.ts b/web/src/admin/flows/FlowViewPage.ts index 718235b397..6aee2ba9dd 100644 --- a/web/src/admin/flows/FlowViewPage.ts +++ b/web/src/admin/flows/FlowViewPage.ts @@ -1,16 +1,16 @@ -import "@goauthentik/admin/flows/BoundStagesList"; -import "@goauthentik/admin/flows/FlowDiagram"; -import "@goauthentik/admin/flows/FlowForm"; -import { DesignationToLabel } from "@goauthentik/admin/flows/utils"; -import "@goauthentik/admin/policies/BoundPoliciesList"; -import "@goauthentik/admin/rbac/ObjectPermissionsPage"; -import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { isResponseErrorLike } from "@goauthentik/common/errors/network"; -import "@goauthentik/components/events/ObjectChangelog"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/Tabs"; -import "@goauthentik/elements/buttons/SpinnerButton"; +import "#admin/flows/BoundStagesList"; +import "#admin/flows/FlowDiagram"; +import "#admin/flows/FlowForm"; +import { DesignationToLabel } from "#admin/flows/utils"; +import "#admin/policies/BoundPoliciesList"; +import "#admin/rbac/ObjectPermissionsPage"; +import { AndNext, DEFAULT_CONFIG } from "#common/api/config"; +import { isResponseErrorLike } from "#common/errors/network"; +import "#components/ak-page-header"; +import "#components/events/ObjectChangelog"; +import { AKElement } from "#elements/Base"; +import "#elements/Tabs"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; import { msg } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; diff --git a/web/src/admin/groups/GroupViewPage.ts b/web/src/admin/groups/GroupViewPage.ts index a9c023e767..7441c40740 100644 --- a/web/src/admin/groups/GroupViewPage.ts +++ b/web/src/admin/groups/GroupViewPage.ts @@ -3,11 +3,11 @@ import "@goauthentik/admin/groups/RelatedUserList"; import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/ak-page-header"; import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/events/ObjectChangelog"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/CodeMirror"; -import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/SpinnerButton"; diff --git a/web/src/admin/providers/ProviderViewPage.ts b/web/src/admin/providers/ProviderViewPage.ts index 4afad2f86b..b40abc409e 100644 --- a/web/src/admin/providers/ProviderViewPage.ts +++ b/web/src/admin/providers/ProviderViewPage.ts @@ -1,18 +1,18 @@ -import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage"; -import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage"; -import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage"; -import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage"; -import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage"; -import "@goauthentik/admin/providers/rac/RACProviderViewPage"; -import "@goauthentik/admin/providers/radius/RadiusProviderViewPage"; -import "@goauthentik/admin/providers/saml/SAMLProviderViewPage"; -import "@goauthentik/admin/providers/scim/SCIMProviderViewPage"; -import "@goauthentik/admin/providers/ssf/SSFProviderViewPage"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/buttons/SpinnerButton"; +import "#admin/providers/google_workspace/GoogleWorkspaceProviderViewPage"; +import "#admin/providers/ldap/LDAPProviderViewPage"; +import "#admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage"; +import "#admin/providers/oauth2/OAuth2ProviderViewPage"; +import "#admin/providers/proxy/ProxyProviderViewPage"; +import "#admin/providers/rac/RACProviderViewPage"; +import "#admin/providers/radius/RadiusProviderViewPage"; +import "#admin/providers/saml/SAMLProviderViewPage"; +import "#admin/providers/scim/SCIMProviderViewPage"; +import "#admin/providers/ssf/SSFProviderViewPage"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import "#elements/EmptyState"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; import { CSSResult, TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; diff --git a/web/src/admin/rbac/InitialPermissionsListPage.ts b/web/src/admin/rbac/InitialPermissionsListPage.ts index 15b8206444..fb1808ed08 100644 --- a/web/src/admin/rbac/InitialPermissionsListPage.ts +++ b/web/src/admin/rbac/InitialPermissionsListPage.ts @@ -1,11 +1,11 @@ -import "@goauthentik/admin/rbac/InitialPermissionsForm"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/forms/DeleteBulkForm"; -import "@goauthentik/elements/forms/ModalForm"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import { TableColumn } from "@goauthentik/elements/table/Table"; -import { TablePage } from "@goauthentik/elements/table/TablePage"; +import "#admin/rbac/InitialPermissionsForm"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; +import "#elements/forms/DeleteBulkForm"; +import "#elements/forms/ModalForm"; +import { PaginatedResponse } from "#elements/table/Table"; +import { TableColumn } from "#elements/table/Table"; +import { TablePage } from "#elements/table/TablePage"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; diff --git a/web/src/admin/roles/RoleListPage.ts b/web/src/admin/roles/RoleListPage.ts index fd68f93c0d..4d086528fe 100644 --- a/web/src/admin/roles/RoleListPage.ts +++ b/web/src/admin/roles/RoleListPage.ts @@ -1,11 +1,11 @@ -import "@goauthentik/admin/roles/RoleForm"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/forms/DeleteBulkForm"; -import "@goauthentik/elements/forms/ModalForm"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import { TableColumn } from "@goauthentik/elements/table/Table"; -import { TablePage } from "@goauthentik/elements/table/TablePage"; +import "#admin/roles/RoleForm"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; +import "#elements/forms/DeleteBulkForm"; +import "#elements/forms/ModalForm"; +import { PaginatedResponse } from "#elements/table/Table"; +import { TableColumn } from "#elements/table/Table"; +import { TablePage } from "#elements/table/TablePage"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; diff --git a/web/src/admin/roles/RoleViewPage.ts b/web/src/admin/roles/RoleViewPage.ts index b21e88acaa..50c26d28f3 100644 --- a/web/src/admin/roles/RoleViewPage.ts +++ b/web/src/admin/roles/RoleViewPage.ts @@ -1,15 +1,15 @@ -import "@goauthentik/admin/groups/RelatedGroupList"; -import "@goauthentik/admin/rbac/ObjectPermissionsPage"; -import "@goauthentik/admin/roles/RoleForm"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { renderDescriptionList } from "@goauthentik/components/DescriptionList"; -import "@goauthentik/components/events/ObjectChangelog"; -import "@goauthentik/components/events/UserEvents"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/Tabs"; -import "@goauthentik/elements/forms/ModalForm"; +import "#admin/groups/RelatedGroupList"; +import "#admin/rbac/ObjectPermissionsPage"; +import "#admin/roles/RoleForm"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { EVENT_REFRESH } from "#common/constants"; +import { renderDescriptionList } from "#components/DescriptionList"; +import "#components/ak-page-header"; +import "#components/events/ObjectChangelog"; +import "#components/events/UserEvents"; +import { AKElement } from "#elements/Base"; +import "#elements/Tabs"; +import "#elements/forms/ModalForm"; import { msg, str } from "@lit/localize"; import { css, html, nothing } from "lit"; diff --git a/web/src/admin/sources/SourceViewPage.ts b/web/src/admin/sources/SourceViewPage.ts index 2badf3543b..e08c43882c 100644 --- a/web/src/admin/sources/SourceViewPage.ts +++ b/web/src/admin/sources/SourceViewPage.ts @@ -1,14 +1,14 @@ -import "@goauthentik/admin/sources/kerberos/KerberosSourceViewPage"; -import "@goauthentik/admin/sources/ldap/LDAPSourceViewPage"; -import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage"; -import "@goauthentik/admin/sources/plex/PlexSourceViewPage"; -import "@goauthentik/admin/sources/saml/SAMLSourceViewPage"; -import "@goauthentik/admin/sources/scim/SCIMSourceViewPage"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/buttons/SpinnerButton"; +import "#admin/sources/kerberos/KerberosSourceViewPage"; +import "#admin/sources/ldap/LDAPSourceViewPage"; +import "#admin/sources/oauth/OAuthSourceViewPage"; +import "#admin/sources/plex/PlexSourceViewPage"; +import "#admin/sources/saml/SAMLSourceViewPage"; +import "#admin/sources/scim/SCIMSourceViewPage"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import "#elements/EmptyState"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; diff --git a/web/src/admin/stages/invitation/InvitationListPage.ts b/web/src/admin/stages/invitation/InvitationListPage.ts index 08fd7695eb..eeb8dc72b4 100644 --- a/web/src/admin/stages/invitation/InvitationListPage.ts +++ b/web/src/admin/stages/invitation/InvitationListPage.ts @@ -1,15 +1,15 @@ -import "@goauthentik/admin/rbac/ObjectPermissionModal"; -import "@goauthentik/admin/stages/invitation/InvitationForm"; -import "@goauthentik/admin/stages/invitation/InvitationListLink"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { PFColor } from "@goauthentik/elements/Label"; -import "@goauthentik/elements/buttons/ModalButton"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/forms/DeleteBulkForm"; -import "@goauthentik/elements/forms/ModalForm"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import { TableColumn } from "@goauthentik/elements/table/Table"; -import { TablePage } from "@goauthentik/elements/table/TablePage"; +import "#admin/rbac/ObjectPermissionModal"; +import "#admin/stages/invitation/InvitationForm"; +import "#admin/stages/invitation/InvitationListLink"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { PFColor } from "#elements/Label"; +import "#elements/buttons/ModalButton"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; +import "#elements/forms/DeleteBulkForm"; +import "#elements/forms/ModalForm"; +import { PaginatedResponse } from "#elements/table/Table"; +import { TableColumn } from "#elements/table/Table"; +import { TablePage } from "#elements/table/TablePage"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index eb11589dc7..1629c34e1d 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -1,44 +1,38 @@ -import "@goauthentik/admin/groups/RelatedGroupList"; -import "@goauthentik/admin/providers/rac/ConnectionTokenList"; -import "@goauthentik/admin/rbac/ObjectPermissionsPage"; -import "@goauthentik/admin/users/UserActiveForm"; -import "@goauthentik/admin/users/UserApplicationTable"; -import "@goauthentik/admin/users/UserChart"; -import "@goauthentik/admin/users/UserForm"; -import "@goauthentik/admin/users/UserImpersonateForm"; -import { - renderRecoveryEmailRequest, - requestRecoveryLink, -} from "@goauthentik/admin/users/UserListPage"; -import "@goauthentik/admin/users/UserPasswordForm"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { PFSize } from "@goauthentik/common/enums.js"; -import { userTypeToLabel } from "@goauthentik/common/labels"; -import { formatElapsedTime } from "@goauthentik/common/temporal"; -import { me } from "@goauthentik/common/users"; -import "@goauthentik/components/DescriptionList"; -import { - type DescriptionPair, - renderDescriptionList, -} from "@goauthentik/components/DescriptionList"; -import "@goauthentik/components/ak-status-label"; -import "@goauthentik/components/events/ObjectChangelog"; -import "@goauthentik/components/events/UserEvents"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/CodeMirror"; -import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider"; -import "@goauthentik/elements/PageHeader"; -import "@goauthentik/elements/Tabs"; -import "@goauthentik/elements/buttons/ActionButton"; -import "@goauthentik/elements/buttons/SpinnerButton"; -import "@goauthentik/elements/forms/ModalForm"; -import "@goauthentik/elements/oauth/UserAccessTokenList"; -import "@goauthentik/elements/oauth/UserRefreshTokenList"; -import "@goauthentik/elements/user/SessionList"; -import "@goauthentik/elements/user/UserConsentList"; -import "@goauthentik/elements/user/UserReputationList"; -import "@goauthentik/elements/user/sources/SourceSettings"; +import "#admin/groups/RelatedGroupList"; +import "#admin/providers/rac/ConnectionTokenList"; +import "#admin/rbac/ObjectPermissionsPage"; +import "#admin/users/UserActiveForm"; +import "#admin/users/UserApplicationTable"; +import "#admin/users/UserChart"; +import "#admin/users/UserForm"; +import "#admin/users/UserImpersonateForm"; +import { renderRecoveryEmailRequest, requestRecoveryLink } from "#admin/users/UserListPage"; +import "#admin/users/UserPasswordForm"; +import { DEFAULT_CONFIG } from "#common/api/config"; +import { EVENT_REFRESH } from "#common/constants"; +import { PFSize } from "#common/enums"; +import { userTypeToLabel } from "#common/labels"; +import { formatElapsedTime } from "#common/temporal"; +import { me } from "#common/users"; +import "#components/DescriptionList"; +import { type DescriptionPair, renderDescriptionList } from "#components/DescriptionList"; +import "#components/ak-page-header"; +import "#components/ak-status-label"; +import "#components/events/ObjectChangelog"; +import "#components/events/UserEvents"; +import { AKElement } from "#elements/Base"; +import "#elements/CodeMirror"; +import { WithCapabilitiesConfig } from "#elements/Interface/capabilitiesProvider"; +import "#elements/Tabs"; +import "#elements/buttons/ActionButton/ak-action-button"; +import "#elements/buttons/SpinnerButton/ak-spinner-button"; +import "#elements/forms/ModalForm"; +import "#elements/oauth/UserAccessTokenList"; +import "#elements/oauth/UserRefreshTokenList"; +import "#elements/user/SessionList"; +import "#elements/user/UserConsentList"; +import "#elements/user/UserReputationList"; +import "#elements/user/sources/SourceSettings"; import { msg, str } from "@lit/localize"; import { TemplateResult, css, html, nothing } from "lit"; diff --git a/web/src/common/api/config.ts b/web/src/common/api/config.ts index d003cb5029..a22a214aac 100644 --- a/web/src/common/api/config.ts +++ b/web/src/common/api/config.ts @@ -3,7 +3,7 @@ import { EventMiddleware, LoggingMiddleware, } from "@goauthentik/common/api/middleware.js"; -import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants.js"; +import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants.js"; import { globalAK } from "@goauthentik/common/global.js"; import { SentryMiddleware } from "@goauthentik/common/sentry/middleware"; @@ -79,4 +79,6 @@ export function AndNext(url: string): string { return `?next=${encodeURIComponent(url)}`; } -console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); +console.debug( + `authentik(early): version ${import.meta.env.AK_VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`, +); diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts index 917d480457..040105aca2 100644 --- a/web/src/common/constants.ts +++ b/web/src/common/constants.ts @@ -1,12 +1,35 @@ +/** + * @file Global constants used throughout the application. + * + * @todo Much of this content can be moved to a specific file, element, or component. + */ + +/// + +//#region Patternfly + export const SECONDARY_CLASS = "pf-m-secondary"; export const SUCCESS_CLASS = "pf-m-success"; export const ERROR_CLASS = "pf-m-danger"; export const PROGRESS_CLASS = "pf-m-in-progress"; export const CURRENT_CLASS = "pf-m-current"; -export const VERSION = "2025.4.1"; + +//#endregion + +//#region Application + export const TITLE_DEFAULT = "authentik"; +/** + * The delimiter used to parse the URL for the current route. + * + * @todo Move this to the ak-router. + */ export const ROUTE_SEPARATOR = ";"; +//#endregion + +//#region Events + export const EVENT_REFRESH = "ak-refresh"; export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle"; export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle"; @@ -20,7 +43,17 @@ export const EVENT_MESSAGE = "ak-message"; export const EVENT_THEME_CHANGE = "ak-theme-change"; export const EVENT_REFRESH_ENTERPRISE = "ak-refresh-enterprise"; +//#endregion + +//#region WebSocket + export const WS_MSG_TYPE_MESSAGE = "message"; export const WS_MSG_TYPE_REFRESH = "refresh"; +//#endregion + +//#region LocalStorage + export const LOCALSTORAGE_AUTHENTIK_KEY = "authentik-local-settings"; + +//#endregion diff --git a/web/src/common/helpers/plex.ts b/web/src/common/helpers/plex.ts index 228a505a07..4172d6672e 100644 --- a/web/src/common/helpers/plex.ts +++ b/web/src/common/helpers/plex.ts @@ -1,4 +1,3 @@ -import { VERSION } from "@goauthentik/common/constants"; import { SentryIgnoredError } from "@goauthentik/common/sentry"; export interface PlexPinResponse { @@ -19,7 +18,7 @@ export const DEFAULT_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", "X-Plex-Product": "authentik", - "X-Plex-Version": VERSION, + "X-Plex-Version": import.meta.env.AK_VERSION, "X-Plex-Device-Vendor": "goauthentik.io", }; diff --git a/web/src/common/sentry/index.ts b/web/src/common/sentry/index.ts index 7aede31d78..37150e5f24 100644 --- a/web/src/common/sentry/index.ts +++ b/web/src/common/sentry/index.ts @@ -1,4 +1,3 @@ -import { VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { me } from "@goauthentik/common/users"; import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils"; @@ -41,7 +40,7 @@ export function configureSentry(canDoPpi = false) { /MutationObserver.observe/gi, /NS_ERROR_FAILURE/gi, ], - release: `authentik@${VERSION}`, + release: `authentik@${import.meta.env.AK_VERSION}`, integrations: [ browserTracingIntegration({ // https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing diff --git a/web/src/common/theme.ts b/web/src/common/theme.ts index 063fb8c0da..bb105f5d16 100644 --- a/web/src/common/theme.ts +++ b/web/src/common/theme.ts @@ -1,15 +1,11 @@ /** * @file Theme utilities. */ -import { - type StyleRoot, - createStyleSheetUnsafe, - setAdoptedStyleSheets, -} from "@goauthentik/web/common/stylesheets.js"; -import { UIConfig } from "@goauthentik/web/common/ui/config.js"; +import { type StyleRoot, createStyleSheetUnsafe, setAdoptedStyleSheets } from "#common/stylesheets"; +import { UIConfig } from "#common/ui/config"; -import AKBase from "@goauthentik/web/common/styles/authentik.css"; -import AKBaseDark from "@goauthentik/web/common/styles/theme-dark.css"; +import AKBase from "#common/styles/authentik.css"; +import AKBaseDark from "#common/styles/theme-dark.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; diff --git a/web/src/components/ak-event-info.ts b/web/src/components/ak-event-info.ts index 2b2af8f284..23847ba8bc 100644 --- a/web/src/components/ak-event-info.ts +++ b/web/src/components/ak-event-info.ts @@ -1,5 +1,4 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { VERSION } from "@goauthentik/common/constants"; import { PFSize } from "@goauthentik/common/enums.js"; import { EventContext, @@ -76,7 +75,7 @@ ${context.message as string} **Version and Deployment (please complete the following information):** -- authentik version: ${VERSION} +- authentik version: ${import.meta.env.AK_VERSION} - Deployment: [e.g. docker-compose, helm] **Additional context** diff --git a/web/src/components/ak-page-header.ts b/web/src/components/ak-page-header.ts new file mode 100644 index 0000000000..be178b15b8 --- /dev/null +++ b/web/src/components/ak-page-header.ts @@ -0,0 +1,84 @@ +import "#components/ak-nav-buttons"; +import { AKPageNavbar } from "#components/ak-page-navbar"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { CSSResult, LitElement, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export interface PageHeaderInit { + header?: string; + description?: string; + icon?: string; + iconImage?: boolean; +} + +//#region Events + +export interface SidebarToggleEventDetail { + open?: boolean; +} + +//#endregion + +//#region Page Header + +/** + * A page header component, used to display the page title and description. + * + * Internally, this component dispatches the `ak-page-header` event, which is + * listened to by the `ak-page-navbar` component. + * + * @singleton + */ +@customElement("ak-page-header") +export class AKPageHeader extends LitElement implements PageHeaderInit { + @property({ type: String }) + header?: string; + + @property({ type: String }) + description?: string; + + @property({ type: String }) + icon?: string; + + @property({ type: Boolean }) + iconImage = false; + + static get styles(): CSSResult[] { + return [ + css` + :host { + display: none; + } + `, + ]; + } + + connectedCallback(): void { + super.connectedCallback(); + + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); + } + + updated(): void { + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); + } +} + +//#endregion + +declare global { + interface HTMLElementTagNameMap { + "ak-page-header": AKPageHeader; + } +} diff --git a/web/src/elements/PageHeader.ts b/web/src/components/ak-page-navbar.ts similarity index 81% rename from web/src/elements/PageHeader.ts rename to web/src/components/ak-page-navbar.ts index 909fd8599c..63856fcfc3 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/components/ak-page-navbar.ts @@ -1,17 +1,18 @@ -import { EVENT_WS_MESSAGE, TITLE_DEFAULT } from "@goauthentik/common/constants"; -import { globalAK } from "@goauthentik/common/global"; -import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config"; -import { DefaultBrand } from "@goauthentik/common/ui/config"; -import { me } from "@goauthentik/common/users"; -import "@goauthentik/components/ak-nav-buttons"; -import { AKElement } from "@goauthentik/elements/Base"; -import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; -import { isAdminRoute } from "@goauthentik/elements/router/utils"; -import { themeImage } from "@goauthentik/elements/utils/images"; +import { EVENT_WS_MESSAGE, TITLE_DEFAULT } from "#common/constants"; +import { globalAK } from "#common/global"; +import { UIConfig, UserDisplay, getConfigForUser } from "#common/ui/config"; +import { DefaultBrand } from "#common/ui/config"; +import { me } from "#common/users"; +import "#components/ak-nav-buttons"; +import type { PageHeaderInit, SidebarToggleEventDetail } from "#components/ak-page-header"; +import { AKElement } from "#elements/Base"; +import { WithBrandConfig } from "#elements/Interface/brandProvider"; +import { isAdminRoute } from "#elements/router/utils"; +import { themeImage } from "#elements/utils/images"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; -import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; @@ -25,23 +26,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { SessionUser } from "@goauthentik/api"; -//#region Events - -export interface SidebarToggleEventDetail { - open?: boolean; -} - -//#endregion - -//#region Page Navbar - -export interface PageNavbarDetails { - header?: string; - description?: string; - icon?: string; - iconImage?: boolean; -} - /** * A global navbar component at the top of the page. * @@ -51,13 +35,13 @@ export interface PageNavbarDetails { @customElement("ak-page-navbar") export class AKPageNavbar extends WithBrandConfig(AKElement) - implements PageNavbarDetails, SidebarToggleEventDetail + implements PageHeaderInit, SidebarToggleEventDetail { //#region Static Properties private static elementRef: AKPageNavbar | null = null; - static readonly setNavbarDetails = (detail: Partial): void => { + static readonly setNavbarDetails = (detail: Partial): void => { const { elementRef } = AKPageNavbar; if (!elementRef) { console.debug( @@ -115,7 +99,6 @@ export class AKPageNavbar flex-direction: row; display: grid; - row-gap: var(--pf-global--spacer--sm); column-gap: var(--pf-global--spacer--sm); grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto; grid-template-rows: auto auto; @@ -123,7 +106,7 @@ export class AKPageNavbar "brand toggle primary secondary" "brand toggle description secondary"; - @media (min-width: 426px) { + @media (min-width: 769px) { height: var(--host-navbar-height); } @@ -146,9 +129,17 @@ export class AKPageNavbar grid-column: primary; grid-row: primary / description; - align-content: center; + align-self: center; padding-block: var(--pf-global--spacer--md); + @media (max-width: 768px) { + padding-block: var(--pf-global--spacer--sm); + } + + &.block-sibling { + align-self: end; + } + @media (min-width: 426px) { &.block-sibling { padding-block-end: 0; @@ -156,10 +147,6 @@ export class AKPageNavbar } } - @media (max-width: 768px) { - padding-block: var(--pf-global--spacer--sm); - } - .accent-icon { height: 1em; width: 1em; @@ -249,9 +236,14 @@ export class AKPageNavbar color: var(--pf-global--active-color--100); } - .page-title { - display: flex; - gap: var(--pf-global--spacer--xs); + .pf-c-content .page-title { + display: box; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + box-orient: vertical; + -webkit-box-orient: vertical; + overflow: hidden; } h1 { @@ -370,7 +362,8 @@ export class AKPageNavbar } render(): TemplateResult { - return html` + return html` +