enterprise/stages: Add MTLS stage (#14296)
* prepare client auth with inbuilt server Signed-off-by: Jens Langhammer <jens@goauthentik.io> * introduce better IPC auth Signed-off-by: Jens Langhammer <jens@goauthentik.io> * init Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> * only allow trusted proxies to set MTLS headers Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more stage progress Signed-off-by: Jens Langhammer <jens@goauthentik.io> * dont fail if ipc_key doesn't exist Signed-off-by: Jens Langhammer <jens@goauthentik.io> * actually install app Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add some tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update API Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix unquote Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix int serial number not jsonable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * init ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add UI Signed-off-by: Jens Langhammer <jens@goauthentik.io> * unrelated: fix git pull in makefile Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix parse helper Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add test for outpost Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more tests and improvements Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improve labels Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add support for multiple CAs on brand Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add support for multiple CAs to MTLS stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> * dont log ipcuser secret views Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix go mod Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		
							
								
								
									
										3
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								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
 | 
			
		||||
 | 
			
		||||
@ -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"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/enterprise/stages/mtls/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/enterprise/stages/mtls/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										31
									
								
								authentik/enterprise/stages/mtls/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								authentik/enterprise/stages/mtls/api.py
									
									
									
									
									
										Normal file
									
								
							@ -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"]
 | 
			
		||||
							
								
								
									
										12
									
								
								authentik/enterprise/stages/mtls/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/enterprise/stages/mtls/apps.py
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
							
								
								
									
										68
									
								
								authentik/enterprise/stages/mtls/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								authentik/enterprise/stages/mtls/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@ -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",),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										71
									
								
								authentik/enterprise/stages/mtls/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								authentik/enterprise/stages/mtls/models.py
									
									
									
									
									
										Normal file
									
								
							@ -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.")),
 | 
			
		||||
        ]
 | 
			
		||||
							
								
								
									
										215
									
								
								authentik/enterprise/stages/mtls/stage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								authentik/enterprise/stages/mtls/stage.py
									
									
									
									
									
										Normal file
									
								
							@ -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"),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/enterprise/stages/mtls/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/enterprise/stages/mtls/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										31
									
								
								authentik/enterprise/stages/mtls/tests/fixtures/ca.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								authentik/enterprise/stages/mtls/tests/fixtures/ca.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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-----
 | 
			
		||||
							
								
								
									
										30
									
								
								authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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-----
 | 
			
		||||
							
								
								
									
										213
									
								
								authentik/enterprise/stages/mtls/tests/test_stage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								authentik/enterprise/stages/mtls/tests/test_stage.py
									
									
									
									
									
										Normal file
									
								
							@ -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",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										5
									
								
								authentik/enterprise/stages/mtls/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								authentik/enterprise/stages/mtls/urls.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
"""API URLs"""
 | 
			
		||||
 | 
			
		||||
from authentik.enterprise.stages.mtls.api import MutualTLSStageViewSet
 | 
			
		||||
 | 
			
		||||
api_urlpatterns = [("stages/mtls", MutualTLSStageViewSet)]
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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": {
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
@ -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(),
 | 
			
		||||
 | 
			
		||||
@ -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{
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
 | 
			
		||||
@ -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(),
 | 
			
		||||
			},
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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(),
 | 
			
		||||
 | 
			
		||||
@ -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{
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
	}))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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{}{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										474
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										474
									
								
								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
 | 
			
		||||
 | 
			
		||||
@ -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<Brand, string> {
 | 
			
		||||
                            .certificate=${this.instance?.webCertificate}
 | 
			
		||||
                        ></ak-crypto-certificate-search>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal
 | 
			
		||||
                        label=${msg("Client Certificates")}
 | 
			
		||||
                        name="clientCertificates"
 | 
			
		||||
                    >
 | 
			
		||||
                        <ak-dual-select-dynamic-selected
 | 
			
		||||
                            .provider=${certificateProvider}
 | 
			
		||||
                            .selector=${certificateSelector(this.instance?.clientCertificates)}
 | 
			
		||||
                            available-label=${msg("Available Certificates")}
 | 
			
		||||
                            selected-label=${msg("Selected Certificates")}
 | 
			
		||||
                        ></ak-dual-select-dynamic-selected>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal label=${msg("Attributes")} name="attributes">
 | 
			
		||||
                        <ak-codemirror
 | 
			
		||||
                            mode=${CodeMirrorMode.YAML}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										39
									
								
								web/src/admin/brands/Certificates.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								web/src/admin/brands/Certificates.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
 | 
			
		||||
import { CertificateKeyPair, CryptoApi } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
const certToSelect = (s: CertificateKeyPair) => [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);
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -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";
 | 
			
		||||
 | 
			
		||||
@ -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";
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										162
									
								
								web/src/admin/stages/mtls/MTLSStageForm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								web/src/admin/stages/mtls/MTLSStageForm.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<MutualTLSStage> {
 | 
			
		||||
    loadInstance(pk: string): Promise<MutualTLSStage> {
 | 
			
		||||
        return new StagesApi(DEFAULT_CONFIG).stagesMtlsRetrieve({
 | 
			
		||||
            stageUuid: pk,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async send(data: MutualTLSStage): Promise<MutualTLSStage> {
 | 
			
		||||
        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`
 | 
			
		||||
            <span> ${msg("Client-certificate/mTLS authentication/enrollment.")} </span>
 | 
			
		||||
            <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
 | 
			
		||||
                <input
 | 
			
		||||
                    type="text"
 | 
			
		||||
                    value="${ifDefined(this.instance?.name || "")}"
 | 
			
		||||
                    class="pf-c-form-control"
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-group .expanded=${true}>
 | 
			
		||||
                <span slot="header"> ${msg("Stage-specific settings")} </span>
 | 
			
		||||
                <div slot="body" class="pf-c-form">
 | 
			
		||||
                    <ak-form-element-horizontal label=${msg("Mode")} required name="mode">
 | 
			
		||||
                        <ak-radio
 | 
			
		||||
                            .options=${[
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Certificate optional"),
 | 
			
		||||
                                    value: MutualTLSStageModeEnum.Optional,
 | 
			
		||||
                                    default: true,
 | 
			
		||||
                                    description: html`${msg(
 | 
			
		||||
                                        "If no certificate was provided, this stage will succeed and continue to the next stage.",
 | 
			
		||||
                                    )}`,
 | 
			
		||||
                                },
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Certificate required"),
 | 
			
		||||
                                    value: MutualTLSStageModeEnum.Required,
 | 
			
		||||
                                    description: html`${msg(
 | 
			
		||||
                                        "If no certificate was provided, this stage will stop flow execution.",
 | 
			
		||||
                                    )}`,
 | 
			
		||||
                                },
 | 
			
		||||
                            ]}
 | 
			
		||||
                            .value=${this.instance?.mode}
 | 
			
		||||
                        >
 | 
			
		||||
                        </ak-radio>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal
 | 
			
		||||
                        label=${msg("Certificate authorities")}
 | 
			
		||||
                        name="certificateAuthorities"
 | 
			
		||||
                    >
 | 
			
		||||
                        <ak-dual-select-dynamic-selected
 | 
			
		||||
                            .provider=${certificateProvider}
 | 
			
		||||
                            .selector=${certificateSelector(this.instance?.certificateAuthorities)}
 | 
			
		||||
                            available-label=${msg("Available Certificates")}
 | 
			
		||||
                            selected-label=${msg("Selected Certificates")}
 | 
			
		||||
                        ></ak-dual-select-dynamic-selected>
 | 
			
		||||
                        <p class="pf-c-form__helper-text">
 | 
			
		||||
                            ${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.",
 | 
			
		||||
                            )}
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal
 | 
			
		||||
                        label=${msg("Certificate attribute")}
 | 
			
		||||
                        required
 | 
			
		||||
                        name="certAttribute"
 | 
			
		||||
                    >
 | 
			
		||||
                        <ak-radio
 | 
			
		||||
                            .options=${[
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Common Name"),
 | 
			
		||||
                                    value: CertAttributeEnum.CommonName,
 | 
			
		||||
                                },
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Email"),
 | 
			
		||||
                                    value: CertAttributeEnum.Email,
 | 
			
		||||
                                    default: true,
 | 
			
		||||
                                },
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Subject"),
 | 
			
		||||
                                    value: CertAttributeEnum.Subject,
 | 
			
		||||
                                },
 | 
			
		||||
                            ]}
 | 
			
		||||
                            .value=${this.instance?.certAttribute}
 | 
			
		||||
                        >
 | 
			
		||||
                        </ak-radio>
 | 
			
		||||
                        <p class="pf-c-form__helper-text">
 | 
			
		||||
                            ${msg(
 | 
			
		||||
                                "Configure the attribute of the certificate used to look for a user.",
 | 
			
		||||
                            )}
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal
 | 
			
		||||
                        label=${msg("User attribute")}
 | 
			
		||||
                        required
 | 
			
		||||
                        name="userAttribute"
 | 
			
		||||
                    >
 | 
			
		||||
                        <ak-radio
 | 
			
		||||
                            .options=${[
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Username"),
 | 
			
		||||
                                    value: UserAttributeEnum.Username,
 | 
			
		||||
                                },
 | 
			
		||||
                                {
 | 
			
		||||
                                    label: msg("Email"),
 | 
			
		||||
                                    value: UserAttributeEnum.Email,
 | 
			
		||||
                                    default: true,
 | 
			
		||||
                                },
 | 
			
		||||
                            ]}
 | 
			
		||||
                            .value=${this.instance?.userAttribute}
 | 
			
		||||
                        >
 | 
			
		||||
                        </ak-radio>
 | 
			
		||||
                        <p class="pf-c-form__helper-text">
 | 
			
		||||
                            ${msg("Configure the attribute of the user used to look for a user.")}
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ak-form-group>
 | 
			
		||||
        `;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
    interface HTMLElementTagNameMap {
 | 
			
		||||
        "ak-stage-mtls-form": MTLSStageForm;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user