Compare commits
	
		
			76 Commits
		
	
	
		
			website/in
			...
			web/cleanu
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| faaed59b44 | |||
| 3eb55c6766 | |||
| cadac325f5 | |||
| 91aa33875a | |||
| 6f2996a43e | |||
| dca9d84ff3 | |||
| d97d149685 | |||
| 26ae6e2da6 | |||
| e2a9957308 | |||
| d8c0793f5d | |||
| 3f691b8a76 | |||
| fcc7c39ee2 | |||
| b73481d97f | |||
| 32db275015 | |||
| 6650f6baa7 | |||
| 05145639ba | |||
| 22a8ccfba6 | |||
| 934f778b97 | |||
| 5752497b4d | |||
| 79d73b0d57 | |||
| 3797cc25be | |||
| 35f96df66e | |||
| 9428fd866e | |||
| bd6651ebfe | |||
| 83bac1ff6d | |||
| 04355d8c4e | |||
| 41c362eaf9 | |||
| c46fc3e100 | |||
| 234d52de3b | |||
| beab52d342 | |||
| 99f8802122 | |||
| 09803fee11 | |||
| 9133ab6870 | |||
| 3fae9e5102 | |||
| ac8ecf163b | |||
| 61717b10c7 | |||
| fffc8c7c0c | |||
| 3d532d4feb | |||
| e1d565d40e | |||
| ee37e9235b | |||
| 8248163958 | |||
| 9acebec1f6 | |||
| 2a96900dc7 | |||
| ca42506fa0 | |||
| 34de6bfd3a | |||
| 2d94b16411 | |||
| 98503f6009 | |||
| ac4ba5d9e2 | |||
| f19ed14bf8 | |||
| 085debf170 | |||
| cacdf64408 | |||
| 23665d173f | |||
| 272fdc516b | |||
| b08dcc2289 | |||
| c84be1d961 | |||
| 875fc5c735 | |||
| 66cefcc918 | |||
| 5d4c38032f | |||
| 7123b2c57b | |||
| fc00bdee63 | |||
| a056703da0 | |||
| 3f9502072d | |||
| 2d254d6a7e | |||
| a7e3dca917 | |||
| 5d8408287f | |||
| 30beca9118 | |||
| 8946b81dbd | |||
| db96e1a901 | |||
| 8b4e0361c4 | |||
| 22cb5b7379 | |||
| 2d0117d096 | |||
| 035bda4eac | |||
| 50906214e5 | |||
| e505f274b6 | |||
| fe52f44dca | |||
| 3146e5a50f | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 2024.10.4
 | 
			
		||||
current_version = 2024.10.0
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							@ -116,7 +116,7 @@ jobs:
 | 
			
		||||
          poetry run make test
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        uses: codecov/codecov-action@v4
 | 
			
		||||
        with:
 | 
			
		||||
          flags: unit
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
@ -140,7 +140,7 @@ jobs:
 | 
			
		||||
          poetry run coverage run manage.py test tests/integration
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        uses: codecov/codecov-action@v4
 | 
			
		||||
        with:
 | 
			
		||||
          flags: integration
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
@ -198,7 +198,7 @@ jobs:
 | 
			
		||||
          poetry run coverage run manage.py test ${{ matrix.job.glob }}
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        uses: codecov/codecov-action@v4
 | 
			
		||||
        with:
 | 
			
		||||
          flags: e2e
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
 | 
			
		||||
@ -80,7 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
 | 
			
		||||
    go build -o /go/authentik ./cmd/server
 | 
			
		||||
 | 
			
		||||
# Stage 4: MaxMind GeoIP
 | 
			
		||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
 | 
			
		||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 AS geoip
 | 
			
		||||
 | 
			
		||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
 | 
			
		||||
ENV GEOIPUPDATE_VERBOSE="1"
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from os import environ
 | 
			
		||||
 | 
			
		||||
__version__ = "2024.10.4"
 | 
			
		||||
__version__ = "2024.10.0"
 | 
			
		||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ API Browser - {{ brand.branding_title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% versioned_script 'dist/standalone/api-browser/index-%v.js' %}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
 | 
			
		||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -27,8 +27,7 @@ def blueprint_tester(file_name: Path) -> Callable:
 | 
			
		||||
        base = Path("blueprints/")
 | 
			
		||||
        rel_path = Path(file_name).relative_to(base)
 | 
			
		||||
        importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
 | 
			
		||||
        validation, logs = importer.validate()
 | 
			
		||||
        self.assertTrue(validation, logs)
 | 
			
		||||
        self.assertTrue(importer.validate()[0])
 | 
			
		||||
        self.assertTrue(importer.apply())
 | 
			
		||||
 | 
			
		||||
    return tester
 | 
			
		||||
 | 
			
		||||
@ -293,11 +293,7 @@ class Importer:
 | 
			
		||||
 | 
			
		||||
        serializer_kwargs = {}
 | 
			
		||||
        model_instance = existing_models.first()
 | 
			
		||||
        if (
 | 
			
		||||
            not isinstance(model(), BaseMetaModel)
 | 
			
		||||
            and model_instance
 | 
			
		||||
            and entry.state != BlueprintEntryDesiredState.MUST_CREATED
 | 
			
		||||
        ):
 | 
			
		||||
        if not isinstance(model(), BaseMetaModel) and model_instance:
 | 
			
		||||
            self.logger.debug(
 | 
			
		||||
                "Initialise serializer with instance",
 | 
			
		||||
                model=model,
 | 
			
		||||
@ -307,12 +303,11 @@ class Importer:
 | 
			
		||||
            serializer_kwargs["instance"] = model_instance
 | 
			
		||||
            serializer_kwargs["partial"] = True
 | 
			
		||||
        elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED:
 | 
			
		||||
            msg = (
 | 
			
		||||
                f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} "
 | 
			
		||||
                "and object exists already",
 | 
			
		||||
            )
 | 
			
		||||
            raise EntryInvalidError.from_entry(
 | 
			
		||||
                ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"),
 | 
			
		||||
                (
 | 
			
		||||
                    f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} "
 | 
			
		||||
                    "and object exists already",
 | 
			
		||||
                ),
 | 
			
		||||
                entry,
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ from collections.abc import Callable
 | 
			
		||||
 | 
			
		||||
from django.http.request import HttpRequest
 | 
			
		||||
from django.http.response import HttpResponse
 | 
			
		||||
from django.utils.translation import override
 | 
			
		||||
from django.utils.translation import activate
 | 
			
		||||
 | 
			
		||||
from authentik.brands.utils import get_brand_for_request
 | 
			
		||||
 | 
			
		||||
@ -18,12 +18,10 @@ class BrandMiddleware:
 | 
			
		||||
        self.get_response = get_response
 | 
			
		||||
 | 
			
		||||
    def __call__(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        locale_to_set = None
 | 
			
		||||
        if not hasattr(request, "brand"):
 | 
			
		||||
            brand = get_brand_for_request(request)
 | 
			
		||||
            request.brand = brand
 | 
			
		||||
            locale = brand.default_locale
 | 
			
		||||
            if locale != "":
 | 
			
		||||
                locale_to_set = locale
 | 
			
		||||
        with override(locale_to_set):
 | 
			
		||||
                activate(locale)
 | 
			
		||||
        return self.get_response(request)
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
"""Authenticator Devices API Views"""
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
			
		||||
from rest_framework.fields import (
 | 
			
		||||
@ -41,11 +40,7 @@ class DeviceSerializer(MetaNameSerializer):
 | 
			
		||||
    def get_extra_description(self, instance: Device) -> str:
 | 
			
		||||
        """Get extra description"""
 | 
			
		||||
        if isinstance(instance, WebAuthnDevice):
 | 
			
		||||
            return (
 | 
			
		||||
                instance.device_type.description
 | 
			
		||||
                if instance.device_type
 | 
			
		||||
                else _("Extra description not available")
 | 
			
		||||
            )
 | 
			
		||||
            return instance.device_type.description
 | 
			
		||||
        if isinstance(instance, EndpointDevice):
 | 
			
		||||
            return instance.data.get("deviceSignals", {}).get("deviceModel")
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,10 @@
 | 
			
		||||
"""transactional application and provider creation"""
 | 
			
		||||
 | 
			
		||||
from django.apps import apps
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field
 | 
			
		||||
from rest_framework.exceptions import PermissionDenied, ValidationError
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField
 | 
			
		||||
from rest_framework.permissions import IsAuthenticated
 | 
			
		||||
from rest_framework.permissions import IsAdminUser
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.views import APIView
 | 
			
		||||
@ -24,7 +22,6 @@ from authentik.core.api.applications import ApplicationSerializer
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
from authentik.lib.utils.reflection import all_subclasses
 | 
			
		||||
from authentik.policies.api.bindings import PolicyBindingSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_provider_serializer_mapping():
 | 
			
		||||
@ -48,13 +45,6 @@ class TransactionProviderField(DictField):
 | 
			
		||||
    """Dictionary field which can hold provider creation data"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
 | 
			
		||||
    """PolicyBindingSerializer which does not require target as target is set implicitly"""
 | 
			
		||||
 | 
			
		||||
    class Meta(PolicyBindingSerializer.Meta):
 | 
			
		||||
        fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TransactionApplicationSerializer(PassiveSerializer):
 | 
			
		||||
    """Serializer for creating a provider and an application in one transaction"""
 | 
			
		||||
 | 
			
		||||
@ -62,8 +52,6 @@ class TransactionApplicationSerializer(PassiveSerializer):
 | 
			
		||||
    provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys()))
 | 
			
		||||
    provider = TransactionProviderField()
 | 
			
		||||
 | 
			
		||||
    policy_bindings = TransactionPolicyBindingSerializer(many=True, required=False)
 | 
			
		||||
 | 
			
		||||
    _provider_model: type[Provider] = None
 | 
			
		||||
 | 
			
		||||
    def validate_provider_model(self, fq_model_name: str) -> str:
 | 
			
		||||
@ -108,19 +96,6 @@ class TransactionApplicationSerializer(PassiveSerializer):
 | 
			
		||||
                id="app",
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        for binding in attrs.get("policy_bindings", []):
 | 
			
		||||
            binding["target"] = KeyOf(None, ScalarNode(tag="", value="app"))
 | 
			
		||||
            for key, value in binding.items():
 | 
			
		||||
                if not isinstance(value, Model):
 | 
			
		||||
                    continue
 | 
			
		||||
                binding[key] = value.pk
 | 
			
		||||
            blueprint.entries.append(
 | 
			
		||||
                BlueprintEntry(
 | 
			
		||||
                    model="authentik_policies.policybinding",
 | 
			
		||||
                    state=BlueprintEntryDesiredState.MUST_CREATED,
 | 
			
		||||
                    identifiers=binding,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        importer = Importer(blueprint, {})
 | 
			
		||||
        try:
 | 
			
		||||
            valid, _ = importer.validate(raise_validation_errors=True)
 | 
			
		||||
@ -145,7 +120,8 @@ class TransactionApplicationResponseSerializer(PassiveSerializer):
 | 
			
		||||
class TransactionalApplicationView(APIView):
 | 
			
		||||
    """Create provider and application and attach them in a single transaction"""
 | 
			
		||||
 | 
			
		||||
    permission_classes = [IsAuthenticated]
 | 
			
		||||
    # TODO: Migrate to a more specific permission
 | 
			
		||||
    permission_classes = [IsAdminUser]
 | 
			
		||||
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        request=TransactionApplicationSerializer(),
 | 
			
		||||
@ -157,23 +133,8 @@ class TransactionalApplicationView(APIView):
 | 
			
		||||
        """Convert data into a blueprint, validate it and apply it"""
 | 
			
		||||
        data = TransactionApplicationSerializer(data=request.data)
 | 
			
		||||
        data.is_valid(raise_exception=True)
 | 
			
		||||
        blueprint: Blueprint = data.validated_data
 | 
			
		||||
        for entry in blueprint.entries:
 | 
			
		||||
            full_model = entry.get_model(blueprint)
 | 
			
		||||
            app, __, model = full_model.partition(".")
 | 
			
		||||
            if not request.user.has_perm(f"{app}.add_{model}"):
 | 
			
		||||
                raise PermissionDenied(
 | 
			
		||||
                    {
 | 
			
		||||
                        entry.id: _(
 | 
			
		||||
                            "User lacks permission to create {model}".format_map(
 | 
			
		||||
                                {
 | 
			
		||||
                                    "model": full_model,
 | 
			
		||||
                                }
 | 
			
		||||
                            )
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
        importer = Importer(blueprint, {})
 | 
			
		||||
 | 
			
		||||
        importer = Importer(data.validated_data, {})
 | 
			
		||||
        applied = importer.apply()
 | 
			
		||||
        response = {"applied": False, "logs": []}
 | 
			
		||||
        response["applied"] = applied
 | 
			
		||||
 | 
			
		||||
@ -666,12 +666,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @permission_required("authentik_core.impersonate")
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        request=inline_serializer(
 | 
			
		||||
            "ImpersonationSerializer",
 | 
			
		||||
            {
 | 
			
		||||
                "reason": CharField(required=True),
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        request=OpenApiTypes.NONE,
 | 
			
		||||
        responses={
 | 
			
		||||
            "204": OpenApiResponse(description="Successfully started impersonation"),
 | 
			
		||||
            "401": OpenApiResponse(description="Access denied"),
 | 
			
		||||
@ -684,7 +679,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
            LOGGER.debug("User attempted to impersonate", user=request.user)
 | 
			
		||||
            return Response(status=401)
 | 
			
		||||
        user_to_be = self.get_object()
 | 
			
		||||
        reason = request.data.get("reason", "")
 | 
			
		||||
        # Check both object-level perms and global perms
 | 
			
		||||
        if not request.user.has_perm(
 | 
			
		||||
            "authentik_core.impersonate", user_to_be
 | 
			
		||||
@ -694,16 +688,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        if user_to_be.pk == self.request.user.pk:
 | 
			
		||||
            LOGGER.debug("User attempted to impersonate themselves", user=request.user)
 | 
			
		||||
            return Response(status=401)
 | 
			
		||||
        if not reason and request.tenant.impersonation_require_reason:
 | 
			
		||||
            LOGGER.debug(
 | 
			
		||||
                "User attempted to impersonate without providing a reason", user=request.user
 | 
			
		||||
            )
 | 
			
		||||
            return Response(status=401)
 | 
			
		||||
 | 
			
		||||
        request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
 | 
			
		||||
        request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
 | 
			
		||||
 | 
			
		||||
        Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be)
 | 
			
		||||
        Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
 | 
			
		||||
 | 
			
		||||
        return Response(status=201)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ from contextvars import ContextVar
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.utils.translation import override
 | 
			
		||||
from django.utils.translation import activate
 | 
			
		||||
from sentry_sdk.api import set_tag
 | 
			
		||||
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
 | 
			
		||||
 | 
			
		||||
@ -31,18 +31,16 @@ class ImpersonateMiddleware:
 | 
			
		||||
    def __call__(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        # No permission checks are done here, they need to be checked before
 | 
			
		||||
        # SESSION_KEY_IMPERSONATE_USER is set.
 | 
			
		||||
        locale_to_set = None
 | 
			
		||||
        if request.user.is_authenticated:
 | 
			
		||||
            locale = request.user.locale(request)
 | 
			
		||||
            if locale != "":
 | 
			
		||||
                locale_to_set = locale
 | 
			
		||||
                activate(locale)
 | 
			
		||||
 | 
			
		||||
        if SESSION_KEY_IMPERSONATE_USER in request.session:
 | 
			
		||||
            request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
 | 
			
		||||
            # Ensure that the user is active, otherwise nothing will work
 | 
			
		||||
            request.user.is_active = True
 | 
			
		||||
 | 
			
		||||
        with override(locale_to_set):
 | 
			
		||||
        return self.get_response(request)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -129,11 +129,6 @@ class SourceFlowManager:
 | 
			
		||||
            )
 | 
			
		||||
            new_connection.user = self.request.user
 | 
			
		||||
            new_connection = self.update_user_connection(new_connection, **kwargs)
 | 
			
		||||
            if existing := self.user_connection_type.objects.filter(
 | 
			
		||||
                source=self.source, identifier=self.identifier
 | 
			
		||||
            ).first():
 | 
			
		||||
                existing = self.update_user_connection(existing)
 | 
			
		||||
                return Action.AUTH, existing
 | 
			
		||||
            return Action.LINK, new_connection
 | 
			
		||||
 | 
			
		||||
        action, connection = self.matcher.get_user_action(self.identifier, self.user_properties)
 | 
			
		||||
 | 
			
		||||
@ -15,8 +15,8 @@
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
 | 
			
		||||
        <script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
 | 
			
		||||
        <script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
 | 
			
		||||
        {% versioned_script "dist/poly-%v.js" %}
 | 
			
		||||
        {% versioned_script "dist/standalone/loading/index-%v.js" %}
 | 
			
		||||
        {% block head %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <meta name="sentry-trace" content="{{ sentry_trace }}" />
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/admin/AdminInterface-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
			
		||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
			
		||||
{% include "base/header_js.html" %}
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/user/UserInterface-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
 | 
			
		||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
 | 
			
		||||
{% include "base/header_js.html" %}
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
from django.templatetags.static import static as static_loader
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
 | 
			
		||||
from authentik import get_full_version
 | 
			
		||||
 | 
			
		||||
@ -11,4 +12,10 @@ register = template.Library()
 | 
			
		||||
@register.simple_tag()
 | 
			
		||||
def versioned_script(path: str) -> str:
 | 
			
		||||
    """Wrapper around {% static %} tag that supports setting the version"""
 | 
			
		||||
    return static_loader(path.replace("%v", get_full_version()))
 | 
			
		||||
    returned_lines = [
 | 
			
		||||
        (
 | 
			
		||||
            f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
 | 
			
		||||
            '" type="module"></script>'
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
    return mark_safe("".join(returned_lines))  # nosec
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.policies.dummy.models import DummyPolicy
 | 
			
		||||
from authentik.policies.models import PolicyBinding
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
from authentik.providers.proxy.models import ProxyProvider
 | 
			
		||||
from authentik.providers.saml.models import SAMLProvider
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ class TestApplicationsAPI(APITestCase):
 | 
			
		||||
        self.user = create_test_admin_user()
 | 
			
		||||
        self.provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
 | 
			
		||||
            redirect_uris="http://some-other-domain",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
        )
 | 
			
		||||
        self.allowed: Application = Application.objects.create(
 | 
			
		||||
 | 
			
		||||
@ -29,8 +29,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:user-impersonate",
 | 
			
		||||
                kwargs={"pk": self.other_user.pk},
 | 
			
		||||
            ),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(reverse("authentik_api:user-me"))
 | 
			
		||||
@ -56,8 +55,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:user-impersonate",
 | 
			
		||||
                kwargs={"pk": self.other_user.pk},
 | 
			
		||||
            ),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 201)
 | 
			
		||||
 | 
			
		||||
@ -77,8 +75,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:user-impersonate",
 | 
			
		||||
                kwargs={"pk": self.other_user.pk},
 | 
			
		||||
            ),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 201)
 | 
			
		||||
 | 
			
		||||
@ -92,8 +89,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
        self.client.force_login(self.other_user)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
@ -109,8 +105,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 401)
 | 
			
		||||
 | 
			
		||||
@ -123,22 +118,7 @@ class TestImpersonation(APITestCase):
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
 | 
			
		||||
            data={"reason": "some reason"},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 401)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(reverse("authentik_api:user-me"))
 | 
			
		||||
        response_body = loads(response.content.decode())
 | 
			
		||||
        self.assertEqual(response_body["user"]["username"], self.user.username)
 | 
			
		||||
 | 
			
		||||
    def test_impersonate_reason_required(self):
 | 
			
		||||
        """test impersonation that user must provide reason"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
 | 
			
		||||
            data={"reason": ""},
 | 
			
		||||
            reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 401)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -81,22 +81,6 @@ class TestSourceFlowManager(TestCase):
 | 
			
		||||
            reverse("authentik_core:if-user") + "#/settings;page-sources",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_authenticated_auth(self):
 | 
			
		||||
        """Test authenticated user linking"""
 | 
			
		||||
        user = User.objects.create(username="foo", email="foo@bar.baz")
 | 
			
		||||
        UserOAuthSourceConnection.objects.create(
 | 
			
		||||
            user=user, source=self.source, identifier=self.identifier
 | 
			
		||||
        )
 | 
			
		||||
        request = get_request("/", user=user)
 | 
			
		||||
        flow_manager = OAuthSourceFlowManager(
 | 
			
		||||
            self.source, request, self.identifier, {"info": {}}, {}
 | 
			
		||||
        )
 | 
			
		||||
        action, connection = flow_manager.get_action()
 | 
			
		||||
        self.assertEqual(action, Action.AUTH)
 | 
			
		||||
        self.assertIsNotNone(connection.pk)
 | 
			
		||||
        response = flow_manager.get_flow()
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
 | 
			
		||||
    def test_unauthenticated_link(self):
 | 
			
		||||
        """Test un-authenticated user linking"""
 | 
			
		||||
        flow_manager = OAuthSourceFlowManager(
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,11 @@
 | 
			
		||||
"""Test Transactional API"""
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from guardian.shortcuts import assign_perm
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application, Group
 | 
			
		||||
from authentik.core.tests.utils import create_test_flow, create_test_user
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.policies.models import PolicyBinding
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -15,9 +13,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
 | 
			
		||||
    """Test Transactional API"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = create_test_user()
 | 
			
		||||
        assign_perm("authentik_core.add_application", self.user)
 | 
			
		||||
        assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user)
 | 
			
		||||
        self.user = create_test_admin_user()
 | 
			
		||||
 | 
			
		||||
    def test_create_transactional(self):
 | 
			
		||||
        """Test transactional Application + provider creation"""
 | 
			
		||||
@ -35,7 +31,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "authorization_flow": str(create_test_flow().pk),
 | 
			
		||||
                    "invalidation_flow": str(create_test_flow().pk),
 | 
			
		||||
                    "redirect_uris": [],
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
@ -46,66 +41,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
 | 
			
		||||
        self.assertIsNotNone(app)
 | 
			
		||||
        self.assertEqual(app.provider.pk, provider.pk)
 | 
			
		||||
 | 
			
		||||
    def test_create_transactional_permission_denied(self):
 | 
			
		||||
        """Test transactional Application + provider creation (missing permissions)"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        uid = generate_id()
 | 
			
		||||
        response = self.client.put(
 | 
			
		||||
            reverse("authentik_api:core-transactional-application"),
 | 
			
		||||
            data={
 | 
			
		||||
                "app": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "slug": uid,
 | 
			
		||||
                },
 | 
			
		||||
                "provider_model": "authentik_providers_saml.samlprovider",
 | 
			
		||||
                "provider": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "authorization_flow": str(create_test_flow().pk),
 | 
			
		||||
                    "invalidation_flow": str(create_test_flow().pk),
 | 
			
		||||
                    "acs_url": "https://goauthentik.io",
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            response.content.decode(),
 | 
			
		||||
            {"provider": "User lacks permission to create authentik_providers_saml.samlprovider"},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_create_transactional_bindings(self):
 | 
			
		||||
        """Test transactional Application + provider creation"""
 | 
			
		||||
        assign_perm("authentik_policies.add_policybinding", self.user)
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        uid = generate_id()
 | 
			
		||||
        group = Group.objects.create(name=generate_id())
 | 
			
		||||
        authorization_flow = create_test_flow()
 | 
			
		||||
        response = self.client.put(
 | 
			
		||||
            reverse("authentik_api:core-transactional-application"),
 | 
			
		||||
            data={
 | 
			
		||||
                "app": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "slug": uid,
 | 
			
		||||
                },
 | 
			
		||||
                "provider_model": "authentik_providers_oauth2.oauth2provider",
 | 
			
		||||
                "provider": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "authorization_flow": str(authorization_flow.pk),
 | 
			
		||||
                    "invalidation_flow": str(authorization_flow.pk),
 | 
			
		||||
                    "redirect_uris": [],
 | 
			
		||||
                },
 | 
			
		||||
                "policy_bindings": [{"group": group.pk, "order": 0}],
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []})
 | 
			
		||||
        provider = OAuth2Provider.objects.filter(name=uid).first()
 | 
			
		||||
        self.assertIsNotNone(provider)
 | 
			
		||||
        app = Application.objects.filter(slug=uid).first()
 | 
			
		||||
        self.assertIsNotNone(app)
 | 
			
		||||
        self.assertEqual(app.provider.pk, provider.pk)
 | 
			
		||||
        binding = PolicyBinding.objects.filter(target=app).first()
 | 
			
		||||
        self.assertIsNotNone(binding)
 | 
			
		||||
        self.assertEqual(binding.target, app)
 | 
			
		||||
        self.assertEqual(binding.group, group)
 | 
			
		||||
 | 
			
		||||
    def test_create_transactional_invalid(self):
 | 
			
		||||
        """Test transactional Application + provider creation"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
@ -122,7 +57,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "authorization_flow": "",
 | 
			
		||||
                    "invalidation_flow": "",
 | 
			
		||||
                    "redirect_uris": [],
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
@ -135,32 +69,3 @@ class TestTransactionalApplicationsAPI(APITestCase):
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_create_transactional_duplicate_name_provider(self):
 | 
			
		||||
        """Test transactional Application + provider creation"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        uid = generate_id()
 | 
			
		||||
        OAuth2Provider.objects.create(
 | 
			
		||||
            name=uid,
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            invalidation_flow=create_test_flow(),
 | 
			
		||||
        )
 | 
			
		||||
        response = self.client.put(
 | 
			
		||||
            reverse("authentik_api:core-transactional-application"),
 | 
			
		||||
            data={
 | 
			
		||||
                "app": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "slug": uid,
 | 
			
		||||
                },
 | 
			
		||||
                "provider_model": "authentik_providers_oauth2.oauth2provider",
 | 
			
		||||
                "provider": {
 | 
			
		||||
                    "name": uid,
 | 
			
		||||
                    "authorization_flow": str(create_test_flow().pk),
 | 
			
		||||
                    "invalidation_flow": str(create_test_flow().pk),
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            response.content.decode(),
 | 
			
		||||
            {"provider": {"name": ["State is set to must_created and object exists already"]}},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,6 @@ from rest_framework.fields import (
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.validators import UniqueValidator
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -182,10 +181,7 @@ class CertificateDataSerializer(PassiveSerializer):
 | 
			
		||||
class CertificateGenerationSerializer(PassiveSerializer):
 | 
			
		||||
    """Certificate generation parameters"""
 | 
			
		||||
 | 
			
		||||
    common_name = CharField(
 | 
			
		||||
        validators=[UniqueValidator(queryset=CertificateKeyPair.objects.all())],
 | 
			
		||||
        source="name",
 | 
			
		||||
    )
 | 
			
		||||
    common_name = CharField()
 | 
			
		||||
    subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name"))
 | 
			
		||||
    validity_days = IntegerField(initial=365)
 | 
			
		||||
    alg = ChoiceField(default=PrivateKeyAlg.RSA, choices=PrivateKeyAlg.choices)
 | 
			
		||||
@ -246,10 +242,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    def generate(self, request: Request) -> Response:
 | 
			
		||||
        """Generate a new, self-signed certificate-key pair"""
 | 
			
		||||
        data = CertificateGenerationSerializer(data=request.data)
 | 
			
		||||
        data.is_valid(raise_exception=True)
 | 
			
		||||
        if not data.is_valid():
 | 
			
		||||
            return Response(data.errors, status=400)
 | 
			
		||||
        raw_san = data.validated_data.get("subject_alt_name", "")
 | 
			
		||||
        sans = raw_san.split(",") if raw_san != "" else []
 | 
			
		||||
        builder = CertificateBuilder(data.validated_data["name"])
 | 
			
		||||
        builder = CertificateBuilder(data.validated_data["common_name"])
 | 
			
		||||
        builder.alg = data.validated_data["alg"]
 | 
			
		||||
        builder.build(
 | 
			
		||||
            subject_alt_names=sans,
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.generators import generate_id, generate_key
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCrypto(APITestCase):
 | 
			
		||||
@ -89,17 +89,6 @@ class TestCrypto(APITestCase):
 | 
			
		||||
        self.assertIsInstance(ext[1], DNSName)
 | 
			
		||||
        self.assertEqual(ext[1].value, "baz")
 | 
			
		||||
 | 
			
		||||
    def test_builder_api_duplicate(self):
 | 
			
		||||
        """Test Builder (via API)"""
 | 
			
		||||
        cert = create_test_cert()
 | 
			
		||||
        self.client.force_login(create_test_admin_user())
 | 
			
		||||
        res = self.client.post(
 | 
			
		||||
            reverse("authentik_api:certificatekeypair-generate"),
 | 
			
		||||
            data={"common_name": cert.name, "subject_alt_name": "bar,baz", "validity_days": 3},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 400)
 | 
			
		||||
        self.assertJSONEqual(res.content, {"common_name": ["This field must be unique."]})
 | 
			
		||||
 | 
			
		||||
    def test_builder_api_empty_san(self):
 | 
			
		||||
        """Test Builder (via API)"""
 | 
			
		||||
        self.client.force_login(create_test_admin_user())
 | 
			
		||||
@ -274,7 +263,7 @@ class TestCrypto(APITestCase):
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            client_secret=generate_key(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=keypair,
 | 
			
		||||
        )
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
@ -306,7 +295,7 @@ class TestCrypto(APITestCase):
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            client_secret=generate_key(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=keypair,
 | 
			
		||||
        )
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
 | 
			
		||||
@ -16,28 +16,13 @@ class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = RACProvider
 | 
			
		||||
        fields = [
 | 
			
		||||
            "pk",
 | 
			
		||||
            "name",
 | 
			
		||||
            "authentication_flow",
 | 
			
		||||
            "authorization_flow",
 | 
			
		||||
            "property_mappings",
 | 
			
		||||
            "component",
 | 
			
		||||
            "assigned_application_slug",
 | 
			
		||||
            "assigned_application_name",
 | 
			
		||||
            "assigned_backchannel_application_slug",
 | 
			
		||||
            "assigned_backchannel_application_name",
 | 
			
		||||
            "verbose_name",
 | 
			
		||||
            "verbose_name_plural",
 | 
			
		||||
            "meta_model_name",
 | 
			
		||||
        fields = ProviderSerializer.Meta.fields + [
 | 
			
		||||
            "settings",
 | 
			
		||||
            "outpost_set",
 | 
			
		||||
            "connection_expiry",
 | 
			
		||||
            "delete_token_on_disconnect",
 | 
			
		||||
        ]
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "authorization_flow": {"required": True, "allow_null": False},
 | 
			
		||||
        }
 | 
			
		||||
        extra_kwargs = ProviderSerializer.Meta.extra_kwargs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RACProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
{% load authentik_core %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/enterprise/rac/index-%v.js" %}
 | 
			
		||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
			
		||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
			
		||||
<link rel="icon" href="{{ tenant.branding_favicon }}">
 | 
			
		||||
 | 
			
		||||
@ -1,46 +0,0 @@
 | 
			
		||||
"""Test RAC Provider"""
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from time import mktime
 | 
			
		||||
from unittest.mock import MagicMock, patch
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.enterprise.license import LicenseKey
 | 
			
		||||
from authentik.enterprise.models import License
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAPI(APITestCase):
 | 
			
		||||
    """Test Provider API"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = create_test_admin_user()
 | 
			
		||||
 | 
			
		||||
    @patch(
 | 
			
		||||
        "authentik.enterprise.license.LicenseKey.validate",
 | 
			
		||||
        MagicMock(
 | 
			
		||||
            return_value=LicenseKey(
 | 
			
		||||
                aud="",
 | 
			
		||||
                exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
 | 
			
		||||
                name=generate_id(),
 | 
			
		||||
                internal_users=100,
 | 
			
		||||
                external_users=100,
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    def test_create(self):
 | 
			
		||||
        """Test creation of RAC Provider"""
 | 
			
		||||
        License.objects.create(key=generate_id())
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:racprovider-list"),
 | 
			
		||||
            data={
 | 
			
		||||
                "name": generate_id(),
 | 
			
		||||
                "authorization_flow": create_test_flow().pk,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 201)
 | 
			
		||||
@ -68,6 +68,7 @@ class TestEndpointsAPI(APITestCase):
 | 
			
		||||
                            "name": self.provider.name,
 | 
			
		||||
                            "authentication_flow": None,
 | 
			
		||||
                            "authorization_flow": None,
 | 
			
		||||
                            "invalidation_flow": None,
 | 
			
		||||
                            "property_mappings": [],
 | 
			
		||||
                            "connection_expiry": "hours=8",
 | 
			
		||||
                            "delete_token_on_disconnect": False,
 | 
			
		||||
@ -120,6 +121,7 @@ class TestEndpointsAPI(APITestCase):
 | 
			
		||||
                            "name": self.provider.name,
 | 
			
		||||
                            "authentication_flow": None,
 | 
			
		||||
                            "authorization_flow": None,
 | 
			
		||||
                            "invalidation_flow": None,
 | 
			
		||||
                            "property_mappings": [],
 | 
			
		||||
                            "component": "ak-provider-rac-form",
 | 
			
		||||
                            "assigned_application_slug": self.app.slug,
 | 
			
		||||
@ -149,6 +151,7 @@ class TestEndpointsAPI(APITestCase):
 | 
			
		||||
                            "name": self.provider.name,
 | 
			
		||||
                            "authentication_flow": None,
 | 
			
		||||
                            "authorization_flow": None,
 | 
			
		||||
                            "invalidation_flow": None,
 | 
			
		||||
                            "property_mappings": [],
 | 
			
		||||
                            "component": "ak-provider-rac-form",
 | 
			
		||||
                            "assigned_application_slug": self.app.slug,
 | 
			
		||||
 | 
			
		||||
@ -60,7 +60,7 @@ def default_event_duration():
 | 
			
		||||
    """Default duration an Event is saved.
 | 
			
		||||
    This is used as a fallback when no brand is available"""
 | 
			
		||||
    try:
 | 
			
		||||
        tenant = get_current_tenant(only=["event_retention"])
 | 
			
		||||
        tenant = get_current_tenant()
 | 
			
		||||
        return now() + timedelta_from_string(tenant.event_retention)
 | 
			
		||||
    except Tenant.DoesNotExist:
 | 
			
		||||
        return now() + timedelta(days=365)
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ window.authentik.flow = {
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
 | 
			
		||||
{% versioned_script "dist/flow/FlowInterface-%v.js" %}
 | 
			
		||||
<style>
 | 
			
		||||
:root {
 | 
			
		||||
    --ak-flow-background: url("{{ flow.background_url }}");
 | 
			
		||||
 | 
			
		||||
@ -89,10 +89,6 @@ class PasswordPolicy(Policy):
 | 
			
		||||
 | 
			
		||||
    def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult:
 | 
			
		||||
        """Check static rules"""
 | 
			
		||||
        error_message = self.error_message
 | 
			
		||||
        if error_message == "":
 | 
			
		||||
            error_message = _("Invalid password.")
 | 
			
		||||
 | 
			
		||||
        if len(password) < self.length_min:
 | 
			
		||||
            LOGGER.debug("password failed", check="static", reason="length")
 | 
			
		||||
            return PolicyResult(False, self.error_message)
 | 
			
		||||
 | 
			
		||||
@ -159,10 +159,7 @@ class LDAPOutpostConfigViewSet(ListModelMixin, GenericViewSet):
 | 
			
		||||
        access_response = PolicyResult(result.passing)
 | 
			
		||||
        response = self.LDAPCheckAccessSerializer(
 | 
			
		||||
            instance={
 | 
			
		||||
                "has_search_permission": (
 | 
			
		||||
                    request.user.has_perm("search_full_directory", provider)
 | 
			
		||||
                    or request.user.has_perm("authentik_providers_ldap.search_full_directory")
 | 
			
		||||
                ),
 | 
			
		||||
                "has_search_permission": request.user.has_perm("search_full_directory", provider),
 | 
			
		||||
                "access": access_response,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,15 @@
 | 
			
		||||
"""OAuth2Provider API Views"""
 | 
			
		||||
 | 
			
		||||
from copy import copy
 | 
			
		||||
from re import compile
 | 
			
		||||
from re import error as RegexError
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField, ChoiceField
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.generics import get_object_or_404
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
@ -23,39 +20,13 @@ from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
from authentik.providers.oauth2.id_token import IDToken
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.rbac.decorators import permission_required
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RedirectURISerializer(PassiveSerializer):
 | 
			
		||||
    """A single allowed redirect URI entry"""
 | 
			
		||||
 | 
			
		||||
    matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices)
 | 
			
		||||
    url = CharField()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuth2ProviderSerializer(ProviderSerializer):
 | 
			
		||||
    """OAuth2Provider Serializer"""
 | 
			
		||||
 | 
			
		||||
    redirect_uris = RedirectURISerializer(many=True, source="_redirect_uris")
 | 
			
		||||
 | 
			
		||||
    def validate_redirect_uris(self, data: list) -> list:
 | 
			
		||||
        for entry in data:
 | 
			
		||||
            if entry.get("matching_mode") == RedirectURIMatchingMode.REGEX:
 | 
			
		||||
                url = entry.get("url")
 | 
			
		||||
                try:
 | 
			
		||||
                    compile(url)
 | 
			
		||||
                except RegexError:
 | 
			
		||||
                    raise ValidationError(
 | 
			
		||||
                        _("Invalid Regex Pattern: {url}".format(url=url))
 | 
			
		||||
                    ) from None
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = OAuth2Provider
 | 
			
		||||
        fields = ProviderSerializer.Meta.fields + [
 | 
			
		||||
@ -108,6 +79,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        "refresh_token_validity",
 | 
			
		||||
        "include_claims_in_id_token",
 | 
			
		||||
        "signing_key",
 | 
			
		||||
        "redirect_uris",
 | 
			
		||||
        "sub_mode",
 | 
			
		||||
        "property_mappings",
 | 
			
		||||
        "issuer_mode",
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.lib.views import bad_request_message
 | 
			
		||||
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
 | 
			
		||||
from authentik.providers.oauth2.models import GrantTypes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuth2Error(SentryIgnoredException):
 | 
			
		||||
@ -46,9 +46,9 @@ class RedirectUriError(OAuth2Error):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    provided_uri: str
 | 
			
		||||
    allowed_uris: list[RedirectURI]
 | 
			
		||||
    allowed_uris: list[str]
 | 
			
		||||
 | 
			
		||||
    def __init__(self, provided_uri: str, allowed_uris: list[RedirectURI]) -> None:
 | 
			
		||||
    def __init__(self, provided_uri: str, allowed_uris: list[str]) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.provided_uri = provided_uri
 | 
			
		||||
        self.allowed_uris = allowed_uris
 | 
			
		||||
 | 
			
		||||
@ -11,16 +11,13 @@ class Migration(migrations.Migration):
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Original preserved
 | 
			
		||||
    # See https://github.com/goauthentik/authentik/issues/11874
 | 
			
		||||
    # operations = [
 | 
			
		||||
    #     migrations.AddIndex(
 | 
			
		||||
    #         model_name="accesstoken",
 | 
			
		||||
    #         index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
 | 
			
		||||
    #     ),
 | 
			
		||||
    #     migrations.AddIndex(
 | 
			
		||||
    #         model_name="refreshtoken",
 | 
			
		||||
    #         index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
 | 
			
		||||
    #     ),
 | 
			
		||||
    # ]
 | 
			
		||||
    operations = []
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="accesstoken",
 | 
			
		||||
            index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="refreshtoken",
 | 
			
		||||
            index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -11,24 +11,21 @@ class Migration(migrations.Migration):
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Original preserved
 | 
			
		||||
    # See https://github.com/goauthentik/authentik/issues/11874
 | 
			
		||||
    # operations = [
 | 
			
		||||
    #     migrations.RemoveIndex(
 | 
			
		||||
    #         model_name="accesstoken",
 | 
			
		||||
    #         name="authentik_p_token_4bc870_idx",
 | 
			
		||||
    #     ),
 | 
			
		||||
    #     migrations.RemoveIndex(
 | 
			
		||||
    #         model_name="refreshtoken",
 | 
			
		||||
    #         name="authentik_p_token_1a841f_idx",
 | 
			
		||||
    #     ),
 | 
			
		||||
    #     migrations.AddIndex(
 | 
			
		||||
    #         model_name="accesstoken",
 | 
			
		||||
    #         index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
 | 
			
		||||
    #     ),
 | 
			
		||||
    #     migrations.AddIndex(
 | 
			
		||||
    #         model_name="refreshtoken",
 | 
			
		||||
    #         index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
 | 
			
		||||
    #     ),
 | 
			
		||||
    # ]
 | 
			
		||||
    operations = []
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveIndex(
 | 
			
		||||
            model_name="accesstoken",
 | 
			
		||||
            name="authentik_p_token_4bc870_idx",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RemoveIndex(
 | 
			
		||||
            model_name="refreshtoken",
 | 
			
		||||
            name="authentik_p_token_1a841f_idx",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="accesstoken",
 | 
			
		||||
            index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="refreshtoken",
 | 
			
		||||
            index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,7 @@ def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
 | 
			
		||||
        ("authentik_core", "0040_provider_invalidation_flow"),
 | 
			
		||||
        ("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,31 +0,0 @@
 | 
			
		||||
# Generated by Django 5.0.9 on 2024-10-31 14:28
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.indexes
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
 | 
			
		||||
        ("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_f99422_idx;"),
 | 
			
		||||
        migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_a1d921_idx;"),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="accesstoken",
 | 
			
		||||
            index=django.contrib.postgres.indexes.HashIndex(
 | 
			
		||||
                fields=["token"], name="authentik_p_token_e00883_hash"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="refreshtoken",
 | 
			
		||||
            index=django.contrib.postgres.indexes.HashIndex(
 | 
			
		||||
                fields=["token"], name="authentik_p_token_32e2b7_hash"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,49 +0,0 @@
 | 
			
		||||
# Generated by Django 5.0.9 on 2024-11-04 12:56
 | 
			
		||||
from dataclasses import asdict
 | 
			
		||||
from django.apps.registry import Apps
 | 
			
		||||
 | 
			
		||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
    from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode
 | 
			
		||||
 | 
			
		||||
    OAuth2Provider = apps.get_model("authentik_providers_oauth2", "oauth2provider")
 | 
			
		||||
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    for provider in OAuth2Provider.objects.using(db_alias).all():
 | 
			
		||||
        uris = []
 | 
			
		||||
        for old in provider.old_redirect_uris.split("\n"):
 | 
			
		||||
            mode = RedirectURIMatchingMode.STRICT
 | 
			
		||||
            if old == "*" or old == ".*":
 | 
			
		||||
                mode = RedirectURIMatchingMode.REGEX
 | 
			
		||||
            uris.append(asdict(RedirectURI(mode, url=old)))
 | 
			
		||||
        provider._redirect_uris = uris
 | 
			
		||||
        provider.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            old_name="redirect_uris",
 | 
			
		||||
            new_name="old_redirect_uris",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            name="_redirect_uris",
 | 
			
		||||
            field=models.JSONField(default=dict, verbose_name="Redirect URIs"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(migrate_redirect_uris, lambda *args: ...),
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            name="old_redirect_uris",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
import base64
 | 
			
		||||
import binascii
 | 
			
		||||
import json
 | 
			
		||||
from dataclasses import asdict, dataclass
 | 
			
		||||
from dataclasses import asdict
 | 
			
		||||
from functools import cached_property
 | 
			
		||||
from hashlib import sha256
 | 
			
		||||
from typing import Any
 | 
			
		||||
@ -12,9 +12,7 @@ from urllib.parse import urlparse, urlunparse
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
 | 
			
		||||
from dacite import Config
 | 
			
		||||
from dacite.core import from_dict
 | 
			
		||||
from django.contrib.postgres.indexes import HashIndex
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from django.templatetags.static import static
 | 
			
		||||
@ -78,25 +76,11 @@ class IssuerMode(models.TextChoices):
 | 
			
		||||
    """Configure how the `iss` field is created."""
 | 
			
		||||
 | 
			
		||||
    GLOBAL = "global", _("Same identifier is used for all providers")
 | 
			
		||||
    PER_PROVIDER = (
 | 
			
		||||
        "per_provider",
 | 
			
		||||
        _("Each provider has a different issuer, based on the application slug."),
 | 
			
		||||
    PER_PROVIDER = "per_provider", _(
 | 
			
		||||
        "Each provider has a different issuer, based on the application slug."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RedirectURIMatchingMode(models.TextChoices):
 | 
			
		||||
    STRICT = "strict", _("Strict URL comparison")
 | 
			
		||||
    REGEX = "regex", _("Regular Expression URL matching")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class RedirectURI:
 | 
			
		||||
    """A single redirect URI entry"""
 | 
			
		||||
 | 
			
		||||
    matching_mode: RedirectURIMatchingMode
 | 
			
		||||
    url: str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ResponseTypes(models.TextChoices):
 | 
			
		||||
    """Response Type required by the client."""
 | 
			
		||||
 | 
			
		||||
@ -171,9 +155,11 @@ class OAuth2Provider(WebfingerProvider, Provider):
 | 
			
		||||
        verbose_name=_("Client Secret"),
 | 
			
		||||
        default=generate_client_secret,
 | 
			
		||||
    )
 | 
			
		||||
    _redirect_uris = models.JSONField(
 | 
			
		||||
        default=dict,
 | 
			
		||||
    redirect_uris = models.TextField(
 | 
			
		||||
        default="",
 | 
			
		||||
        blank=True,
 | 
			
		||||
        verbose_name=_("Redirect URIs"),
 | 
			
		||||
        help_text=_("Enter each URI on a new line."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    include_claims_in_id_token = models.BooleanField(
 | 
			
		||||
@ -284,33 +270,12 @@ class OAuth2Provider(WebfingerProvider, Provider):
 | 
			
		||||
        except Provider.application.RelatedObjectDoesNotExist:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def redirect_uris(self) -> list[RedirectURI]:
 | 
			
		||||
        uris = []
 | 
			
		||||
        for entry in self._redirect_uris:
 | 
			
		||||
            uris.append(
 | 
			
		||||
                from_dict(
 | 
			
		||||
                    RedirectURI,
 | 
			
		||||
                    entry,
 | 
			
		||||
                    config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}),
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        return uris
 | 
			
		||||
 | 
			
		||||
    @redirect_uris.setter
 | 
			
		||||
    def redirect_uris(self, value: list[RedirectURI]):
 | 
			
		||||
        cleansed = []
 | 
			
		||||
        for entry in value:
 | 
			
		||||
            cleansed.append(asdict(entry))
 | 
			
		||||
        self._redirect_uris = cleansed
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def launch_url(self) -> str | None:
 | 
			
		||||
        """Guess launch_url based on first redirect_uri"""
 | 
			
		||||
        redirects = self.redirect_uris
 | 
			
		||||
        if len(redirects) < 1:
 | 
			
		||||
        if self.redirect_uris == "":
 | 
			
		||||
            return None
 | 
			
		||||
        main_url = redirects[0].url
 | 
			
		||||
        main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
 | 
			
		||||
        try:
 | 
			
		||||
            launch_url = urlparse(main_url)._replace(path="")
 | 
			
		||||
            return urlunparse(launch_url)
 | 
			
		||||
@ -453,7 +418,7 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        indexes = [
 | 
			
		||||
            HashIndex(fields=["token"]),
 | 
			
		||||
            models.Index(fields=["token", "provider"]),
 | 
			
		||||
        ]
 | 
			
		||||
        verbose_name = _("OAuth2 Access Token")
 | 
			
		||||
        verbose_name_plural = _("OAuth2 Access Tokens")
 | 
			
		||||
@ -499,7 +464,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        indexes = [
 | 
			
		||||
            HashIndex(fields=["token"]),
 | 
			
		||||
            models.Index(fields=["token", "provider"]),
 | 
			
		||||
        ]
 | 
			
		||||
        verbose_name = _("OAuth2 Refresh Token")
 | 
			
		||||
        verbose_name_plural = _("OAuth2 Refresh Tokens")
 | 
			
		||||
 | 
			
		||||
@ -10,13 +10,7 @@ from rest_framework.test import APITestCase
 | 
			
		||||
from authentik.blueprints.tests import apply_blueprint
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAPI(APITestCase):
 | 
			
		||||
@ -27,7 +21,7 @@ class TestAPI(APITestCase):
 | 
			
		||||
        self.provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
        self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
 | 
			
		||||
@ -56,29 +50,9 @@ class TestAPI(APITestCase):
 | 
			
		||||
    @skipUnless(version_info >= (3, 11, 4), "This behaviour is only Python 3.11.4 and up")
 | 
			
		||||
    def test_launch_url(self):
 | 
			
		||||
        """Test launch_url"""
 | 
			
		||||
        self.provider.redirect_uris = [
 | 
			
		||||
            RedirectURI(
 | 
			
		||||
                RedirectURIMatchingMode.REGEX,
 | 
			
		||||
                "https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/",
 | 
			
		||||
            ),
 | 
			
		||||
        ]
 | 
			
		||||
        self.provider.redirect_uris = (
 | 
			
		||||
            "https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/\n"
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.save()
 | 
			
		||||
        self.provider.refresh_from_db()
 | 
			
		||||
        self.assertIsNone(self.provider.launch_url)
 | 
			
		||||
 | 
			
		||||
    def test_validate_redirect_uris(self):
 | 
			
		||||
        """Test redirect_uris API"""
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:oauth2provider-list"),
 | 
			
		||||
            data={
 | 
			
		||||
                "name": generate_id(),
 | 
			
		||||
                "authorization_flow": create_test_flow().pk,
 | 
			
		||||
                "invalidation_flow": create_test_flow().pk,
 | 
			
		||||
                "redirect_uris": [
 | 
			
		||||
                    {"matching_mode": "strict", "url": "http://goauthentik.io"},
 | 
			
		||||
                    {"matching_mode": "regex", "url": "**"},
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
@ -19,8 +19,6 @@ from authentik.providers.oauth2.models import (
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    GrantTypes,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
@ -41,7 +39,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
 | 
			
		||||
            redirect_uris="http://local.invalid/Foo",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(AuthorizeError):
 | 
			
		||||
            request = self.factory.get(
 | 
			
		||||
@ -66,7 +64,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
 | 
			
		||||
            redirect_uris="http://local.invalid/Foo",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(AuthorizeError):
 | 
			
		||||
            request = self.factory.get(
 | 
			
		||||
@ -86,7 +84,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(RedirectUriError):
 | 
			
		||||
            request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
 | 
			
		||||
@ -108,7 +106,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "data:local.invalid")],
 | 
			
		||||
            redirect_uris="data:local.invalid",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(RedirectUriError):
 | 
			
		||||
            request = self.factory.get(
 | 
			
		||||
@ -127,7 +125,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[],
 | 
			
		||||
            redirect_uris="",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(RedirectUriError):
 | 
			
		||||
            request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
 | 
			
		||||
@ -142,7 +140,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
        )
 | 
			
		||||
        OAuthAuthorizationParams.from_request(request)
 | 
			
		||||
        provider.refresh_from_db()
 | 
			
		||||
        self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
 | 
			
		||||
        self.assertEqual(provider.redirect_uris, "+")
 | 
			
		||||
 | 
			
		||||
    def test_invalid_redirect_uri_regex(self):
 | 
			
		||||
        """test missing/invalid redirect URI"""
 | 
			
		||||
@ -150,7 +148,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid?")],
 | 
			
		||||
            redirect_uris="http://local.invalid?",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(RedirectUriError):
 | 
			
		||||
            request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
 | 
			
		||||
@ -172,7 +170,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "+")],
 | 
			
		||||
            redirect_uris="+",
 | 
			
		||||
        )
 | 
			
		||||
        with self.assertRaises(RedirectUriError):
 | 
			
		||||
            request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
 | 
			
		||||
@ -215,7 +213,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
 | 
			
		||||
            redirect_uris="http://local.invalid/Foo",
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
            ScopeMapping.objects.filter(
 | 
			
		||||
@ -303,7 +301,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
 | 
			
		||||
            redirect_uris="foo://localhost",
 | 
			
		||||
            access_code_validity="seconds=100",
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
@ -345,7 +343,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
@ -422,7 +420,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
            encryption_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
@ -488,7 +486,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
@ -543,7 +541,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id=generate_id(),
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
@ -601,7 +599,7 @@ class TestAuthorize(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id=generate_id(),
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,6 @@
 | 
			
		||||
from urllib.parse import urlencode
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from rest_framework.test import APIClient
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application, Group
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
 | 
			
		||||
@ -35,10 +34,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
 | 
			
		||||
        self.brand.flow_device_code = self.device_flow
 | 
			
		||||
        self.brand.save()
 | 
			
		||||
 | 
			
		||||
        self.api_client = APIClient()
 | 
			
		||||
        self.api_client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
    def test_device_init_get(self):
 | 
			
		||||
    def test_device_init(self):
 | 
			
		||||
        """Test device init"""
 | 
			
		||||
        res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
 | 
			
		||||
        self.assertEqual(res.status_code, 302)
 | 
			
		||||
@ -52,76 +48,6 @@ class TesOAuth2DeviceInit(OAuthTestCase):
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_device_init_post(self):
 | 
			
		||||
        """Test device init"""
 | 
			
		||||
        res = self.api_client.get(reverse("authentik_providers_oauth2_root:device-login"))
 | 
			
		||||
        self.assertEqual(res.status_code, 302)
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            res.url,
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_core:if-flow",
 | 
			
		||||
                kwargs={
 | 
			
		||||
                    "flow_slug": self.device_flow.slug,
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        res = self.api_client.get(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:flow-executor",
 | 
			
		||||
                kwargs={
 | 
			
		||||
                    "flow_slug": self.device_flow.slug,
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 200)
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            res.content,
 | 
			
		||||
            {
 | 
			
		||||
                "component": "ak-provider-oauth2-device-code",
 | 
			
		||||
                "flow_info": {
 | 
			
		||||
                    "background": "/static/dist/assets/images/flow_background.jpg",
 | 
			
		||||
                    "cancel_url": "/flows/-/cancel/",
 | 
			
		||||
                    "layout": "stacked",
 | 
			
		||||
                    "title": self.device_flow.title,
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
 | 
			
		||||
        token = DeviceToken.objects.create(
 | 
			
		||||
            provider=provider,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        res = self.api_client.post(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:flow-executor",
 | 
			
		||||
                kwargs={
 | 
			
		||||
                    "flow_slug": self.device_flow.slug,
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            data={
 | 
			
		||||
                "component": "ak-provider-oauth2-device-code",
 | 
			
		||||
                "code": token.user_code,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 200)
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            res.content,
 | 
			
		||||
            {
 | 
			
		||||
                "component": "xak-flow-redirect",
 | 
			
		||||
                "to": reverse(
 | 
			
		||||
                    "authentik_core:if-flow",
 | 
			
		||||
                    kwargs={
 | 
			
		||||
                        "flow_slug": provider.authorization_flow.slug,
 | 
			
		||||
                    },
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_no_flow(self):
 | 
			
		||||
        """Test no flow"""
 | 
			
		||||
        self.brand.flow_device_code = None
 | 
			
		||||
 | 
			
		||||
@ -11,14 +11,7 @@ from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    IDToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -30,7 +23,7 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
			
		||||
        self.provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
 | 
			
		||||
            redirect_uris="",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.app = Application.objects.create(
 | 
			
		||||
@ -125,7 +118,7 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
			
		||||
        provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
 | 
			
		||||
            redirect_uris="",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
 | 
			
		||||
from authentik.crypto.builder import PrivateKeyAlg
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
TEST_CORDS_CERT = """
 | 
			
		||||
@ -49,7 +49,7 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name="test", slug="test", provider=provider)
 | 
			
		||||
@ -68,7 +68,7 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name="test", slug="test", provider=provider)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
@ -82,7 +82,7 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name="test", slug="test", provider=provider)
 | 
			
		||||
@ -99,7 +99,7 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
 | 
			
		||||
            encryption_key=create_test_cert(PrivateKeyAlg.ECDSA),
 | 
			
		||||
        )
 | 
			
		||||
@ -122,7 +122,7 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=cert,
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name="test", slug="test", provider=provider)
 | 
			
		||||
 | 
			
		||||
@ -10,14 +10,7 @@ from django.utils import timezone
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    IDToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -29,7 +22,7 @@ class TesOAuth2Revoke(OAuthTestCase):
 | 
			
		||||
        self.provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
 | 
			
		||||
            redirect_uris="",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.app = Application.objects.create(
 | 
			
		||||
 | 
			
		||||
@ -22,8 +22,6 @@ from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
@ -44,7 +42,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
 | 
			
		||||
            redirect_uris="http://TestServer",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
 | 
			
		||||
@ -71,7 +69,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
 | 
			
		||||
@ -92,7 +90,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
 | 
			
		||||
@ -120,7 +118,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        # Needs to be assigned to an application for iss to be set
 | 
			
		||||
@ -159,7 +157,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
            encryption_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
@ -190,7 +188,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
@ -252,7 +250,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
@ -310,7 +308,7 @@ class TestToken(OAuthTestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=self.keypair,
 | 
			
		||||
        )
 | 
			
		||||
        provider.property_mappings.set(
 | 
			
		||||
 | 
			
		||||
@ -19,12 +19,7 @@ from authentik.providers.oauth2.constants import (
 | 
			
		||||
    SCOPE_OPENID_PROFILE,
 | 
			
		||||
    TOKEN_TYPE,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
from authentik.providers.oauth2.views.jwks import JWKSView
 | 
			
		||||
from authentik.sources.oauth.models import OAuthSource
 | 
			
		||||
@ -59,7 +54,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
 | 
			
		||||
        self.provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=self.cert,
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.jwks_sources.add(self.source)
 | 
			
		||||
 | 
			
		||||
@ -19,13 +19,7 @@ from authentik.providers.oauth2.constants import (
 | 
			
		||||
    TOKEN_TYPE,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.errors import TokenError
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -39,7 +33,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
 | 
			
		||||
        self.provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
@ -113,48 +107,6 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
 | 
			
		||||
            {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_incorrect_scopes(self):
 | 
			
		||||
        """test scope that isn't configured"""
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_providers_oauth2:token"),
 | 
			
		||||
            {
 | 
			
		||||
                "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
 | 
			
		||||
                "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE} extra_scope",
 | 
			
		||||
                "client_id": self.provider.client_id,
 | 
			
		||||
                "client_secret": self.provider.client_secret,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        body = loads(response.content.decode())
 | 
			
		||||
        self.assertEqual(body["token_type"], TOKEN_TYPE)
 | 
			
		||||
        token = AccessToken.objects.filter(
 | 
			
		||||
            provider=self.provider, token=body["access_token"]
 | 
			
		||||
        ).first()
 | 
			
		||||
        self.assertSetEqual(
 | 
			
		||||
            set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE}
 | 
			
		||||
        )
 | 
			
		||||
        _, alg = self.provider.jwt_key
 | 
			
		||||
        jwt = decode(
 | 
			
		||||
            body["access_token"],
 | 
			
		||||
            key=self.provider.signing_key.public_key,
 | 
			
		||||
            algorithms=[alg],
 | 
			
		||||
            audience=self.provider.client_id,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            jwt["given_name"], "Autogenerated user from application test (client credentials)"
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
 | 
			
		||||
        jwt = decode(
 | 
			
		||||
            body["id_token"],
 | 
			
		||||
            key=self.provider.signing_key.public_key,
 | 
			
		||||
            algorithms=[alg],
 | 
			
		||||
            audience=self.provider.client_id,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            jwt["given_name"], "Autogenerated user from application test (client credentials)"
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
 | 
			
		||||
 | 
			
		||||
    def test_successful(self):
 | 
			
		||||
        """test successful"""
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
 | 
			
		||||
@ -20,12 +20,7 @@ from authentik.providers.oauth2.constants import (
 | 
			
		||||
    TOKEN_TYPE,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.errors import TokenError
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -39,7 +34,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
 | 
			
		||||
        self.provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
 | 
			
		||||
@ -19,12 +19,7 @@ from authentik.providers.oauth2.constants import (
 | 
			
		||||
    TOKEN_TYPE,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.errors import TokenError
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -38,7 +33,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
 | 
			
		||||
        self.provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
 | 
			
		||||
@ -9,19 +9,8 @@ from authentik.blueprints.tests import apply_blueprint
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_code_fixed_length, generate_id
 | 
			
		||||
from authentik.providers.oauth2.constants import (
 | 
			
		||||
    GRANT_TYPE_DEVICE_CODE,
 | 
			
		||||
    SCOPE_OPENID,
 | 
			
		||||
    SCOPE_OPENID_EMAIL,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    DeviceToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
 | 
			
		||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -35,7 +24,7 @@ class TestTokenDeviceCode(OAuthTestCase):
 | 
			
		||||
        self.provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
 | 
			
		||||
            redirect_uris="http://testserver",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
@ -91,28 +80,3 @@ class TestTokenDeviceCode(OAuthTestCase):
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_code_mismatched_scope(self):
 | 
			
		||||
        """Test code with user (mismatched scopes)"""
 | 
			
		||||
        device_token = DeviceToken.objects.create(
 | 
			
		||||
            provider=self.provider,
 | 
			
		||||
            user_code=generate_code_fixed_length(),
 | 
			
		||||
            device_code=generate_id(),
 | 
			
		||||
            user=self.user,
 | 
			
		||||
            scope=[SCOPE_OPENID, SCOPE_OPENID_EMAIL],
 | 
			
		||||
        )
 | 
			
		||||
        res = self.client.post(
 | 
			
		||||
            reverse("authentik_providers_oauth2:token"),
 | 
			
		||||
            data={
 | 
			
		||||
                "client_id": self.provider.client_id,
 | 
			
		||||
                "grant_type": GRANT_TYPE_DEVICE_CODE,
 | 
			
		||||
                "device_code": device_token.device_code,
 | 
			
		||||
                "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 200)
 | 
			
		||||
        body = loads(res.content)
 | 
			
		||||
        token = AccessToken.objects.filter(
 | 
			
		||||
            provider=self.provider, token=body["access_token"]
 | 
			
		||||
        ).first()
 | 
			
		||||
        self.assertSetEqual(set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL})
 | 
			
		||||
 | 
			
		||||
@ -10,12 +10,7 @@ from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -35,7 +30,7 @@ class TestTokenPKCE(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
 | 
			
		||||
            redirect_uris="foo://localhost",
 | 
			
		||||
            access_code_validity="seconds=100",
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
@ -98,7 +93,7 @@ class TestTokenPKCE(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
 | 
			
		||||
            redirect_uris="foo://localhost",
 | 
			
		||||
            access_code_validity="seconds=100",
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
@ -159,7 +154,7 @@ class TestTokenPKCE(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
 | 
			
		||||
            redirect_uris="foo://localhost",
 | 
			
		||||
            access_code_validity="seconds=100",
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
@ -215,7 +210,7 @@ class TestTokenPKCE(OAuthTestCase):
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
 | 
			
		||||
            redirect_uris="foo://localhost",
 | 
			
		||||
            access_code_validity="seconds=100",
 | 
			
		||||
        )
 | 
			
		||||
        Application.objects.create(name="app", slug="app", provider=provider)
 | 
			
		||||
 | 
			
		||||
@ -11,14 +11,7 @@ from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    IDToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -32,7 +25,7 @@ class TestUserinfo(OAuthTestCase):
 | 
			
		||||
        self.provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
 | 
			
		||||
            redirect_uris="",
 | 
			
		||||
            signing_key=create_test_cert(),
 | 
			
		||||
        )
 | 
			
		||||
        self.provider.property_mappings.set(ScopeMapping.objects.all())
 | 
			
		||||
 | 
			
		||||
@ -56,8 +56,6 @@ from authentik.providers.oauth2.models import (
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    GrantTypes,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ResponseMode,
 | 
			
		||||
    ResponseTypes,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
@ -189,39 +187,40 @@ class OAuthAuthorizationParams:
 | 
			
		||||
 | 
			
		||||
    def check_redirect_uri(self):
 | 
			
		||||
        """Redirect URI validation."""
 | 
			
		||||
        allowed_redirect_urls = self.provider.redirect_uris
 | 
			
		||||
        allowed_redirect_urls = self.provider.redirect_uris.split()
 | 
			
		||||
        if not self.redirect_uri:
 | 
			
		||||
            LOGGER.warning("Missing redirect uri.")
 | 
			
		||||
            raise RedirectUriError("", allowed_redirect_urls)
 | 
			
		||||
 | 
			
		||||
        if len(allowed_redirect_urls) < 1:
 | 
			
		||||
        if self.provider.redirect_uris == "":
 | 
			
		||||
            LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
 | 
			
		||||
            self.provider.redirect_uris = [
 | 
			
		||||
                RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri)
 | 
			
		||||
            ]
 | 
			
		||||
            self.provider.redirect_uris = self.redirect_uri
 | 
			
		||||
            self.provider.save()
 | 
			
		||||
            allowed_redirect_urls = self.provider.redirect_uris
 | 
			
		||||
            allowed_redirect_urls = self.provider.redirect_uris.split()
 | 
			
		||||
 | 
			
		||||
        if self.provider.redirect_uris == "*":
 | 
			
		||||
            LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
 | 
			
		||||
            self.provider.redirect_uris = ".*"
 | 
			
		||||
            self.provider.save()
 | 
			
		||||
            allowed_redirect_urls = self.provider.redirect_uris.split()
 | 
			
		||||
 | 
			
		||||
        match_found = False
 | 
			
		||||
        for allowed in allowed_redirect_urls:
 | 
			
		||||
            if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
 | 
			
		||||
                if self.redirect_uri == allowed.url:
 | 
			
		||||
                    match_found = True
 | 
			
		||||
                    break
 | 
			
		||||
            if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
 | 
			
		||||
        try:
 | 
			
		||||
                    if fullmatch(allowed.url, self.redirect_uri):
 | 
			
		||||
                        match_found = True
 | 
			
		||||
                        break
 | 
			
		||||
                except RegexError as exc:
 | 
			
		||||
            if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
 | 
			
		||||
                LOGGER.warning(
 | 
			
		||||
                        "Failed to parse regular expression",
 | 
			
		||||
                        exc=exc,
 | 
			
		||||
                        url=allowed.url,
 | 
			
		||||
                        provider=self.provider,
 | 
			
		||||
                    "Invalid redirect uri (regex comparison)",
 | 
			
		||||
                    redirect_uri_given=self.redirect_uri,
 | 
			
		||||
                    redirect_uri_expected=allowed_redirect_urls,
 | 
			
		||||
                )
 | 
			
		||||
        if not match_found:
 | 
			
		||||
                raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
 | 
			
		||||
        except RegexError as exc:
 | 
			
		||||
            LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
 | 
			
		||||
            if not any(x == self.redirect_uri for x in allowed_redirect_urls):
 | 
			
		||||
                LOGGER.warning(
 | 
			
		||||
                    "Invalid redirect uri (strict comparison)",
 | 
			
		||||
                    redirect_uri_given=self.redirect_uri,
 | 
			
		||||
                    redirect_uri_expected=allowed_redirect_urls,
 | 
			
		||||
                )
 | 
			
		||||
                raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) from None
 | 
			
		||||
        # Check against forbidden schemes
 | 
			
		||||
        if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
 | 
			
		||||
            raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ from typing import Any
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.fields import CharField, IntegerField
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.brands.models import Brand
 | 
			
		||||
@ -47,9 +47,6 @@ class CodeValidatorView(PolicyAccessView):
 | 
			
		||||
        self.provider = self.token.provider
 | 
			
		||||
        self.application = self.token.provider.application
 | 
			
		||||
 | 
			
		||||
    def post(self, request: HttpRequest, *args, **kwargs):
 | 
			
		||||
        return self.get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs):
 | 
			
		||||
        scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
 | 
			
		||||
        planner = FlowPlanner(self.provider.authorization_flow)
 | 
			
		||||
@ -125,7 +122,7 @@ class OAuthDeviceCodeChallenge(Challenge):
 | 
			
		||||
class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
 | 
			
		||||
    """Response that includes the user-entered device code"""
 | 
			
		||||
 | 
			
		||||
    code = CharField()
 | 
			
		||||
    code = IntegerField()
 | 
			
		||||
    component = CharField(default="ak-provider-oauth2-device-code")
 | 
			
		||||
 | 
			
		||||
    def validate_code(self, code: int) -> HttpResponse | None:
 | 
			
		||||
 | 
			
		||||
@ -162,5 +162,5 @@ class ProviderInfoView(View):
 | 
			
		||||
            OAuth2Provider, pk=application.provider_id
 | 
			
		||||
        )
 | 
			
		||||
        response = super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        cors_allow(request, response, *[x.url for x in self.provider.redirect_uris])
 | 
			
		||||
        cors_allow(request, response, *self.provider.redirect_uris.split("\n"))
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
@ -58,9 +58,7 @@ from authentik.providers.oauth2.models import (
 | 
			
		||||
    ClientTypes,
 | 
			
		||||
    DeviceToken,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
 | 
			
		||||
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
 | 
			
		||||
@ -79,7 +77,7 @@ class TokenParams:
 | 
			
		||||
    redirect_uri: str
 | 
			
		||||
    grant_type: str
 | 
			
		||||
    state: str
 | 
			
		||||
    scope: set[str]
 | 
			
		||||
    scope: list[str]
 | 
			
		||||
 | 
			
		||||
    provider: OAuth2Provider
 | 
			
		||||
 | 
			
		||||
@ -114,26 +112,11 @@ class TokenParams:
 | 
			
		||||
            redirect_uri=request.POST.get("redirect_uri", ""),
 | 
			
		||||
            grant_type=request.POST.get("grant_type", ""),
 | 
			
		||||
            state=request.POST.get("state", ""),
 | 
			
		||||
            scope=set(request.POST.get("scope", "").split()),
 | 
			
		||||
            scope=request.POST.get("scope", "").split(),
 | 
			
		||||
            # PKCE parameter.
 | 
			
		||||
            code_verifier=request.POST.get("code_verifier"),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def __check_scopes(self):
 | 
			
		||||
        allowed_scope_names = set(
 | 
			
		||||
            ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
 | 
			
		||||
                "scope_name", flat=True
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        scopes_to_check = self.scope
 | 
			
		||||
        if not scopes_to_check.issubset(allowed_scope_names):
 | 
			
		||||
            LOGGER.info(
 | 
			
		||||
                "Application requested scopes not configured, setting to overlap",
 | 
			
		||||
                scope_allowed=allowed_scope_names,
 | 
			
		||||
                scope_given=self.scope,
 | 
			
		||||
            )
 | 
			
		||||
            self.scope = self.scope.intersection(allowed_scope_names)
 | 
			
		||||
 | 
			
		||||
    def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
 | 
			
		||||
        with start_span(
 | 
			
		||||
            op="authentik.providers.oauth2.token.policy",
 | 
			
		||||
@ -166,7 +149,7 @@ class TokenParams:
 | 
			
		||||
                    client_id=self.provider.client_id,
 | 
			
		||||
                )
 | 
			
		||||
                raise TokenError("invalid_client")
 | 
			
		||||
        self.__check_scopes()
 | 
			
		||||
 | 
			
		||||
        if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
 | 
			
		||||
            with start_span(
 | 
			
		||||
                op="authentik.providers.oauth2.post.parse.code",
 | 
			
		||||
@ -196,7 +179,42 @@ class TokenParams:
 | 
			
		||||
            LOGGER.warning("Missing authorization code")
 | 
			
		||||
            raise TokenError("invalid_grant")
 | 
			
		||||
 | 
			
		||||
        self.__check_redirect_uri(request)
 | 
			
		||||
        allowed_redirect_urls = self.provider.redirect_uris.split()
 | 
			
		||||
        # At this point, no provider should have a blank redirect_uri, in case they do
 | 
			
		||||
        # this will check an empty array and raise an error
 | 
			
		||||
        try:
 | 
			
		||||
            if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
 | 
			
		||||
                LOGGER.warning(
 | 
			
		||||
                    "Invalid redirect uri (regex comparison)",
 | 
			
		||||
                    redirect_uri=self.redirect_uri,
 | 
			
		||||
                    expected=allowed_redirect_urls,
 | 
			
		||||
                )
 | 
			
		||||
                Event.new(
 | 
			
		||||
                    EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                    message="Invalid redirect URI used by provider",
 | 
			
		||||
                    provider=self.provider,
 | 
			
		||||
                    redirect_uri=self.redirect_uri,
 | 
			
		||||
                    expected=allowed_redirect_urls,
 | 
			
		||||
                ).from_http(request)
 | 
			
		||||
                raise TokenError("invalid_client")
 | 
			
		||||
        except RegexError as exc:
 | 
			
		||||
            LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
 | 
			
		||||
            if not any(x == self.redirect_uri for x in allowed_redirect_urls):
 | 
			
		||||
                LOGGER.warning(
 | 
			
		||||
                    "Invalid redirect uri (strict comparison)",
 | 
			
		||||
                    redirect_uri=self.redirect_uri,
 | 
			
		||||
                    expected=allowed_redirect_urls,
 | 
			
		||||
                )
 | 
			
		||||
                Event.new(
 | 
			
		||||
                    EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                    message="Invalid redirect_uri configured",
 | 
			
		||||
                    provider=self.provider,
 | 
			
		||||
                ).from_http(request)
 | 
			
		||||
                raise TokenError("invalid_client") from None
 | 
			
		||||
 | 
			
		||||
        # Check against forbidden schemes
 | 
			
		||||
        if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
 | 
			
		||||
            raise TokenError("invalid_request")
 | 
			
		||||
 | 
			
		||||
        self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
 | 
			
		||||
        if not self.authorization_code:
 | 
			
		||||
@ -236,48 +254,6 @@ class TokenParams:
 | 
			
		||||
        if not self.authorization_code.code_challenge and self.code_verifier:
 | 
			
		||||
            raise TokenError("invalid_grant")
 | 
			
		||||
 | 
			
		||||
    def __check_redirect_uri(self, request: HttpRequest):
 | 
			
		||||
        allowed_redirect_urls = self.provider.redirect_uris
 | 
			
		||||
        # At this point, no provider should have a blank redirect_uri, in case they do
 | 
			
		||||
        # this will check an empty array and raise an error
 | 
			
		||||
 | 
			
		||||
        match_found = False
 | 
			
		||||
        for allowed in allowed_redirect_urls:
 | 
			
		||||
            if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
 | 
			
		||||
                if self.redirect_uri == allowed.url:
 | 
			
		||||
                    match_found = True
 | 
			
		||||
                    break
 | 
			
		||||
            if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
 | 
			
		||||
                try:
 | 
			
		||||
                    if fullmatch(allowed.url, self.redirect_uri):
 | 
			
		||||
                        match_found = True
 | 
			
		||||
                        break
 | 
			
		||||
                except RegexError as exc:
 | 
			
		||||
                    LOGGER.warning(
 | 
			
		||||
                        "Failed to parse regular expression",
 | 
			
		||||
                        exc=exc,
 | 
			
		||||
                        url=allowed.url,
 | 
			
		||||
                        provider=self.provider,
 | 
			
		||||
                    )
 | 
			
		||||
                    Event.new(
 | 
			
		||||
                        EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                        message="Invalid redirect_uri configured",
 | 
			
		||||
                        provider=self.provider,
 | 
			
		||||
                    ).from_http(request)
 | 
			
		||||
        if not match_found:
 | 
			
		||||
            Event.new(
 | 
			
		||||
                EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                message="Invalid redirect URI used by provider",
 | 
			
		||||
                provider=self.provider,
 | 
			
		||||
                redirect_uri=self.redirect_uri,
 | 
			
		||||
                expected=allowed_redirect_urls,
 | 
			
		||||
            ).from_http(request)
 | 
			
		||||
            raise TokenError("invalid_client")
 | 
			
		||||
 | 
			
		||||
        # Check against forbidden schemes
 | 
			
		||||
        if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
 | 
			
		||||
            raise TokenError("invalid_request")
 | 
			
		||||
 | 
			
		||||
    def __post_init_refresh(self, raw_token: str, request: HttpRequest):
 | 
			
		||||
        if not raw_token:
 | 
			
		||||
            LOGGER.warning("Missing refresh token")
 | 
			
		||||
@ -521,7 +497,7 @@ class TokenView(View):
 | 
			
		||||
        response = super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        allowed_origins = []
 | 
			
		||||
        if self.provider:
 | 
			
		||||
            allowed_origins = [x.url for x in self.provider.redirect_uris]
 | 
			
		||||
            allowed_origins = self.provider.redirect_uris.split("\n")
 | 
			
		||||
        cors_allow(self.request, response, *allowed_origins)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
@ -734,7 +710,7 @@ class TokenView(View):
 | 
			
		||||
            "id_token": access_token.id_token.to_jwt(self.provider),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
 | 
			
		||||
        if SCOPE_OFFLINE_ACCESS in self.params.scope:
 | 
			
		||||
            refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
 | 
			
		||||
            refresh_token = RefreshToken(
 | 
			
		||||
                user=self.params.device_code.user,
 | 
			
		||||
 | 
			
		||||
@ -108,7 +108,7 @@ class UserInfoView(View):
 | 
			
		||||
        response = super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        allowed_origins = []
 | 
			
		||||
        if self.token:
 | 
			
		||||
            allowed_origins = [x.url for x in self.token.provider.redirect_uris]
 | 
			
		||||
            allowed_origins = self.token.provider.redirect_uris.split("\n")
 | 
			
		||||
        cors_allow(self.request, response, *allowed_origins)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,6 @@ from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
			
		||||
from authentik.lib.utils.time import timedelta_from_string
 | 
			
		||||
from authentik.providers.oauth2.api.providers import RedirectURISerializer
 | 
			
		||||
from authentik.providers.oauth2.models import ScopeMapping
 | 
			
		||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
 | 
			
		||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
 | 
			
		||||
@ -40,7 +39,7 @@ class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
    """ProxyProvider Serializer"""
 | 
			
		||||
 | 
			
		||||
    client_id = CharField(read_only=True)
 | 
			
		||||
    redirect_uris = RedirectURISerializer(many=True, read_only=True, source="_redirect_uris")
 | 
			
		||||
    redirect_uris = CharField(read_only=True)
 | 
			
		||||
    outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
 | 
			
		||||
 | 
			
		||||
    def validate_basic_auth_enabled(self, value: bool) -> bool:
 | 
			
		||||
@ -122,6 +121,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        "basic_auth_password_attribute": ["iexact"],
 | 
			
		||||
        "basic_auth_user_attribute": ["iexact"],
 | 
			
		||||
        "mode": ["iexact"],
 | 
			
		||||
        "redirect_uris": ["iexact"],
 | 
			
		||||
        "cookie_domain": ["iexact"],
 | 
			
		||||
    }
 | 
			
		||||
    search_fields = ["name"]
 | 
			
		||||
 | 
			
		||||
@ -13,13 +13,7 @@ from rest_framework.serializers import Serializer
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.lib.models import DomainlessURLValidator
 | 
			
		||||
from authentik.outposts.models import OutpostModel
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    ClientTypes,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RedirectURI,
 | 
			
		||||
    RedirectURIMatchingMode,
 | 
			
		||||
    ScopeMapping,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
 | 
			
		||||
 | 
			
		||||
SCOPE_AK_PROXY = "ak_proxy"
 | 
			
		||||
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
 | 
			
		||||
@ -30,14 +24,14 @@ def get_cookie_secret():
 | 
			
		||||
    return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_callback_url(uri: str) -> list[RedirectURI]:
 | 
			
		||||
    return [
 | 
			
		||||
        RedirectURI(
 | 
			
		||||
            RedirectURIMatchingMode.STRICT,
 | 
			
		||||
            urljoin(uri, "outpost.goauthentik.io/callback") + f"?{OUTPOST_CALLBACK_SIGNATURE}=true",
 | 
			
		||||
        ),
 | 
			
		||||
        RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"?{OUTPOST_CALLBACK_SIGNATURE}=true"),
 | 
			
		||||
def _get_callback_url(uri: str) -> str:
 | 
			
		||||
    return "\n".join(
 | 
			
		||||
        [
 | 
			
		||||
            urljoin(uri, "outpost.goauthentik.io/callback")
 | 
			
		||||
            + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
 | 
			
		||||
            uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProxyMode(models.TextChoices):
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
 | 
			
		||||
class User(BaseUser):
 | 
			
		||||
    """Modified User schema with added externalId field"""
 | 
			
		||||
 | 
			
		||||
    id: str | int | None = None
 | 
			
		||||
    schemas: list[str] = [SCIM_USER_SCHEMA]
 | 
			
		||||
    externalId: str | None = None
 | 
			
		||||
    meta: dict | None = None
 | 
			
		||||
@ -28,7 +27,6 @@ class User(BaseUser):
 | 
			
		||||
class Group(BaseGroup):
 | 
			
		||||
    """Modified Group schema with added externalId field"""
 | 
			
		||||
 | 
			
		||||
    id: str | int | None = None
 | 
			
		||||
    schemas: list[str] = [SCIM_GROUP_SCHEMA]
 | 
			
		||||
    externalId: str | None = None
 | 
			
		||||
    meta: dict | None = None
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
 | 
			
		||||
        except LookupError:
 | 
			
		||||
            return None
 | 
			
		||||
        objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
 | 
			
		||||
        obj = objects.filter(pk=instance.object_pk).first()
 | 
			
		||||
        obj = objects.first()
 | 
			
		||||
        if not obj:
 | 
			
		||||
            return None
 | 
			
		||||
        return str(obj)
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
 | 
			
		||||
        except LookupError:
 | 
			
		||||
            return None
 | 
			
		||||
        objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
 | 
			
		||||
        obj = objects.filter(pk=instance.object_pk).first()
 | 
			
		||||
        obj = objects.first()
 | 
			
		||||
        if not obj:
 | 
			
		||||
            return None
 | 
			
		||||
        return str(obj)
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,6 @@
 | 
			
		||||
"""Metrics view"""
 | 
			
		||||
 | 
			
		||||
from hmac import compare_digest
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from tempfile import gettempdir
 | 
			
		||||
from base64 import b64encode
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import connections
 | 
			
		||||
@ -18,21 +16,22 @@ monitoring_set = Signal()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MetricsView(View):
 | 
			
		||||
    """Wrapper around ExportToDjangoView with authentication, accessed by the authentik router"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        _tmp = Path(gettempdir())
 | 
			
		||||
        with open(_tmp / "authentik-core-metrics.key") as _f:
 | 
			
		||||
            self.monitoring_key = _f.read()
 | 
			
		||||
    """Wrapper around ExportToDjangoView, using http-basic auth"""
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        """Check for HTTP-Basic auth"""
 | 
			
		||||
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
 | 
			
		||||
        auth_type, _, given_credentials = auth_header.partition(" ")
 | 
			
		||||
        authed = auth_type == "Bearer" and compare_digest(given_credentials, self.monitoring_key)
 | 
			
		||||
        credentials = f"monitor:{settings.SECRET_KEY}"
 | 
			
		||||
        expected = b64encode(str.encode(credentials)).decode()
 | 
			
		||||
        authed = auth_type == "Basic" and given_credentials == expected
 | 
			
		||||
        if not authed and not settings.DEBUG:
 | 
			
		||||
            return HttpResponse(status=401)
 | 
			
		||||
            response = HttpResponse(status=401)
 | 
			
		||||
            response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"'
 | 
			
		||||
            return response
 | 
			
		||||
 | 
			
		||||
        monitoring_set.send_robust(self)
 | 
			
		||||
 | 
			
		||||
        return ExportToDjangoView(request)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,8 @@
 | 
			
		||||
"""root tests"""
 | 
			
		||||
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from secrets import token_urlsafe
 | 
			
		||||
from tempfile import gettempdir
 | 
			
		||||
from base64 import b64encode
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
@ -11,16 +10,6 @@ from django.urls import reverse
 | 
			
		||||
class TestRoot(TestCase):
 | 
			
		||||
    """Test root application"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        _tmp = Path(gettempdir())
 | 
			
		||||
        self.token = token_urlsafe(32)
 | 
			
		||||
        with open(_tmp / "authentik-core-metrics.key", "w") as _f:
 | 
			
		||||
            _f.write(self.token)
 | 
			
		||||
 | 
			
		||||
    def tearDown(self):
 | 
			
		||||
        _tmp = Path(gettempdir())
 | 
			
		||||
        (_tmp / "authentik-core-metrics.key").unlink()
 | 
			
		||||
 | 
			
		||||
    def test_monitoring_error(self):
 | 
			
		||||
        """Test monitoring without any credentials"""
 | 
			
		||||
        response = self.client.get(reverse("metrics"))
 | 
			
		||||
@ -28,7 +17,8 @@ class TestRoot(TestCase):
 | 
			
		||||
 | 
			
		||||
    def test_monitoring_ok(self):
 | 
			
		||||
        """Test monitoring with credentials"""
 | 
			
		||||
        auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
 | 
			
		||||
        creds = "Basic " + b64encode(f"monitor:{settings.SECRET_KEY}".encode()).decode("utf-8")
 | 
			
		||||
        auth_headers = {"HTTP_AUTHORIZATION": creds}
 | 
			
		||||
        response = self.client.get(reverse("metrics"), **auth_headers)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ from tempfile import gettempdir
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import gssapi
 | 
			
		||||
import kadmin
 | 
			
		||||
import pglock
 | 
			
		||||
from django.db import connection, models
 | 
			
		||||
from django.db.models.fields import b64decode
 | 
			
		||||
@ -13,8 +14,6 @@ from django.http import HttpRequest
 | 
			
		||||
from django.shortcuts import reverse
 | 
			
		||||
from django.templatetags.static import static
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from kadmin import KAdmin
 | 
			
		||||
from kadmin.exceptions import PyKAdminException
 | 
			
		||||
from rest_framework.serializers import Serializer
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -31,8 +30,9 @@ from authentik.flows.challenge import RedirectChallenge
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Creating kadmin connections is expensive. As such, this global is used to reuse
 | 
			
		||||
# existing kadmin connections instead of creating new ones
 | 
			
		||||
# python-kadmin leaks file descriptors. As such, this global is used to reuse
 | 
			
		||||
# existing kadmin connections instead of creating new ones, which results in less to no file
 | 
			
		||||
# descriptors leaks
 | 
			
		||||
_kadmin_connections: dict[str, Any] = {}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -198,13 +198,13 @@ class KerberosSource(Source):
 | 
			
		||||
        conf_path.write_text(self.krb5_conf)
 | 
			
		||||
        return str(conf_path)
 | 
			
		||||
 | 
			
		||||
    def _kadmin_init(self) -> KAdmin | None:
 | 
			
		||||
    def _kadmin_init(self) -> "kadmin.KAdmin | None":
 | 
			
		||||
        # kadmin doesn't use a ccache for its connection
 | 
			
		||||
        # as such, we don't need to create a separate ccache for each source
 | 
			
		||||
        if not self.sync_principal:
 | 
			
		||||
            return None
 | 
			
		||||
        if self.sync_password:
 | 
			
		||||
            return KAdmin.with_password(
 | 
			
		||||
            return kadmin.init_with_password(
 | 
			
		||||
                self.sync_principal,
 | 
			
		||||
                self.sync_password,
 | 
			
		||||
            )
 | 
			
		||||
@ -215,18 +215,18 @@ class KerberosSource(Source):
 | 
			
		||||
                keytab_path.touch(mode=0o600)
 | 
			
		||||
                keytab_path.write_bytes(b64decode(self.sync_keytab))
 | 
			
		||||
                keytab = f"FILE:{keytab_path}"
 | 
			
		||||
            return KAdmin.with_keytab(
 | 
			
		||||
            return kadmin.init_with_keytab(
 | 
			
		||||
                self.sync_principal,
 | 
			
		||||
                keytab,
 | 
			
		||||
            )
 | 
			
		||||
        if self.sync_ccache:
 | 
			
		||||
            return KAdmin.with_ccache(
 | 
			
		||||
            return kadmin.init_with_ccache(
 | 
			
		||||
                self.sync_principal,
 | 
			
		||||
                self.sync_ccache,
 | 
			
		||||
            )
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def connection(self) -> KAdmin | None:
 | 
			
		||||
    def connection(self) -> "kadmin.KAdmin | None":
 | 
			
		||||
        """Get kadmin connection"""
 | 
			
		||||
        if str(self.pk) not in _kadmin_connections:
 | 
			
		||||
            kadm = self._kadmin_init()
 | 
			
		||||
@ -246,7 +246,7 @@ class KerberosSource(Source):
 | 
			
		||||
                    status["status"] = "no connection"
 | 
			
		||||
                    return status
 | 
			
		||||
                status["principal_exists"] = kadm.principal_exists(self.sync_principal)
 | 
			
		||||
            except PyKAdminException as exc:
 | 
			
		||||
            except kadmin.KAdminError as exc:
 | 
			
		||||
                status["status"] = str(exc)
 | 
			
		||||
        return status
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,8 @@
 | 
			
		||||
"""authentik kerberos source signals"""
 | 
			
		||||
 | 
			
		||||
import kadmin
 | 
			
		||||
from django.db.models.signals import post_save
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
from kadmin.exceptions import PyKAdminException
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,7 @@ def kerberos_sync_password(sender, user: User, password: str, **_):
 | 
			
		||||
                source.connection().getprinc(user_source_connection.identifier).change_password(
 | 
			
		||||
                    password
 | 
			
		||||
                )
 | 
			
		||||
            except PyKAdminException as exc:
 | 
			
		||||
            except kadmin.KAdminError as exc:
 | 
			
		||||
                LOGGER.warning("failed to set Kerberos password", exc=exc, source=source)
 | 
			
		||||
                Event.new(
 | 
			
		||||
                    EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
 | 
			
		||||
@ -2,9 +2,9 @@
 | 
			
		||||
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import kadmin
 | 
			
		||||
from django.core.exceptions import FieldError
 | 
			
		||||
from django.db import IntegrityError, transaction
 | 
			
		||||
from kadmin import KAdmin
 | 
			
		||||
from structlog.stdlib import BoundLogger, get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.expression.exceptions import (
 | 
			
		||||
@ -30,7 +30,7 @@ class KerberosSync:
 | 
			
		||||
 | 
			
		||||
    _source: KerberosSource
 | 
			
		||||
    _logger: BoundLogger
 | 
			
		||||
    _connection: KAdmin
 | 
			
		||||
    _connection: "kadmin.KAdmin"
 | 
			
		||||
    mapper: SourceMapper
 | 
			
		||||
    user_manager: PropertyMappingManager
 | 
			
		||||
    group_manager: PropertyMappingManager
 | 
			
		||||
@ -161,7 +161,7 @@ class KerberosSync:
 | 
			
		||||
 | 
			
		||||
        user_count = 0
 | 
			
		||||
        with Krb5ConfContext(self._source):
 | 
			
		||||
            for principal in self._connection.list_principals(None):
 | 
			
		||||
            for principal in self._connection.principals():
 | 
			
		||||
                if self._handle_principal(principal):
 | 
			
		||||
                    user_count += 1
 | 
			
		||||
        return user_count
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ class TestKerberosAuth(KerberosTestCase):
 | 
			
		||||
        )
 | 
			
		||||
        self.user = User.objects.create(username=generate_id())
 | 
			
		||||
        self.user.set_unusable_password()
 | 
			
		||||
        self.user.save()
 | 
			
		||||
        UserKerberosSourceConnection.objects.create(
 | 
			
		||||
            source=self.source, user=self.user, identifier=self.realm.user_princ
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,6 @@
 | 
			
		||||
 | 
			
		||||
from base64 import b64decode, b64encode
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from sys import platform
 | 
			
		||||
from unittest import skipUnless
 | 
			
		||||
 | 
			
		||||
import gssapi
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
@ -38,7 +36,6 @@ class TestSPNEGOSource(KerberosTestCase):
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    @skipUnless(platform.startswith("linux"), "Requires compatible GSSAPI implementation")
 | 
			
		||||
    def test_source_login(self):
 | 
			
		||||
        """test login view"""
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -17,7 +17,6 @@ class CaptchaStageSerializer(StageSerializer):
 | 
			
		||||
            "private_key",
 | 
			
		||||
            "js_url",
 | 
			
		||||
            "api_url",
 | 
			
		||||
            "interactive",
 | 
			
		||||
            "score_min_threshold",
 | 
			
		||||
            "score_max_threshold",
 | 
			
		||||
            "error_on_invalid_score",
 | 
			
		||||
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
# Generated by Django 5.0.9 on 2024-10-30 14:28
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="captchastage",
 | 
			
		||||
            name="interactive",
 | 
			
		||||
            field=models.BooleanField(default=False),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -9,13 +9,11 @@ from authentik.flows.models import Stage
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CaptchaStage(Stage):
 | 
			
		||||
    """Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""
 | 
			
		||||
    """Verify the user is human using Google's reCaptcha."""
 | 
			
		||||
 | 
			
		||||
    public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider."))
 | 
			
		||||
    private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider."))
 | 
			
		||||
 | 
			
		||||
    interactive = models.BooleanField(default=False)
 | 
			
		||||
 | 
			
		||||
    score_min_threshold = models.FloatField(default=0.5)  # Default values for reCaptcha
 | 
			
		||||
    score_max_threshold = models.FloatField(default=1.0)  # Default values for reCaptcha
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
from django.http.response import HttpResponse
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from requests import RequestException
 | 
			
		||||
from rest_framework.fields import BooleanField, CharField
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -24,12 +24,10 @@ PLAN_CONTEXT_CAPTCHA = "captcha"
 | 
			
		||||
class CaptchaChallenge(WithUserInfoChallenge):
 | 
			
		||||
    """Site public key"""
 | 
			
		||||
 | 
			
		||||
    site_key = CharField()
 | 
			
		||||
    js_url = CharField()
 | 
			
		||||
    component = CharField(default="ak-stage-captcha")
 | 
			
		||||
 | 
			
		||||
    site_key = CharField(required=True)
 | 
			
		||||
    js_url = CharField(required=True)
 | 
			
		||||
    interactive = BooleanField(required=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str):
 | 
			
		||||
    """Validate captcha token"""
 | 
			
		||||
@ -105,7 +103,6 @@ class CaptchaStageView(ChallengeStageView):
 | 
			
		||||
            data={
 | 
			
		||||
                "js_url": self.executor.current_stage.js_url,
 | 
			
		||||
                "site_key": self.executor.current_stage.public_key,
 | 
			
		||||
                "interactive": self.executor.current_stage.interactive,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -223,7 +223,6 @@ class IdentificationStageView(ChallengeStageView):
 | 
			
		||||
                    {
 | 
			
		||||
                        "js_url": current_stage.captcha_stage.js_url,
 | 
			
		||||
                        "site_key": current_stage.captcha_stage.public_key,
 | 
			
		||||
                        "interactive": current_stage.captcha_stage.interactive,
 | 
			
		||||
                    }
 | 
			
		||||
                    if current_stage.captcha_stage
 | 
			
		||||
                    else None
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,7 @@ from authentik.flows.challenge import (
 | 
			
		||||
    WithUserInfoChallenge,
 | 
			
		||||
)
 | 
			
		||||
from authentik.flows.exceptions import StageInvalidException
 | 
			
		||||
from authentik.flows.models import Flow, Stage
 | 
			
		||||
from authentik.flows.models import Flow, FlowDesignation, Stage
 | 
			
		||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
 | 
			
		||||
from authentik.flows.stage import ChallengeStageView
 | 
			
		||||
from authentik.lib.utils.reflection import path_to_class
 | 
			
		||||
@ -141,11 +141,11 @@ class PasswordStageView(ChallengeStageView):
 | 
			
		||||
                "allow_show_password": self.executor.current_stage.allow_show_password,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        recovery_flow: Flow | None = self.request.brand.flow_recovery
 | 
			
		||||
        if recovery_flow:
 | 
			
		||||
        recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
 | 
			
		||||
        if recovery_flow.exists():
 | 
			
		||||
            recover_url = reverse(
 | 
			
		||||
                "authentik_core:if-flow",
 | 
			
		||||
                kwargs={"flow_slug": recovery_flow.slug},
 | 
			
		||||
                kwargs={"flow_slug": recovery_flow.first().slug},
 | 
			
		||||
            )
 | 
			
		||||
            challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
 | 
			
		||||
        return challenge
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.flows.markers import StageMarker
 | 
			
		||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
 | 
			
		||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
 | 
			
		||||
@ -57,9 +57,6 @@ class TestPasswordStage(FlowTestCase):
 | 
			
		||||
    def test_recovery_flow_link(self):
 | 
			
		||||
        """Test link to the default recovery flow"""
 | 
			
		||||
        flow = create_test_flow(designation=FlowDesignation.RECOVERY)
 | 
			
		||||
        brand = create_test_brand()
 | 
			
		||||
        brand.flow_recovery = flow
 | 
			
		||||
        brand.save()
 | 
			
		||||
 | 
			
		||||
        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 | 
			
		||||
        session = self.client.session
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ class SettingsSerializer(ModelSerializer):
 | 
			
		||||
            "footer_links",
 | 
			
		||||
            "gdpr_compliance",
 | 
			
		||||
            "impersonation",
 | 
			
		||||
            "impersonation_require_reason",
 | 
			
		||||
            "default_token_duration",
 | 
			
		||||
            "default_token_length",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
# Generated by Django 5.0.9 on 2024-11-07 15:08
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_tenants", "0003_alter_tenant_default_token_duration"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="tenant",
 | 
			
		||||
            name="impersonation_require_reason",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=True,
 | 
			
		||||
                help_text="Require administrators to provide a reason for impersonating a user.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -85,10 +85,6 @@ class Tenant(TenantMixin, SerializerModel):
 | 
			
		||||
    impersonation = models.BooleanField(
 | 
			
		||||
        help_text=_("Globally enable/disable impersonation."), default=True
 | 
			
		||||
    )
 | 
			
		||||
    impersonation_require_reason = models.BooleanField(
 | 
			
		||||
        help_text=_("Require administrators to provide a reason for impersonating a user."),
 | 
			
		||||
        default=True,
 | 
			
		||||
    )
 | 
			
		||||
    default_token_duration = models.TextField(
 | 
			
		||||
        help_text=_("Default token duration"),
 | 
			
		||||
        default=DEFAULT_TOKEN_DURATION,
 | 
			
		||||
 | 
			
		||||
@ -8,11 +8,9 @@ from authentik.root.install_id import get_install_id
 | 
			
		||||
from authentik.tenants.models import Tenant
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_current_tenant(only: list[str] | None = None) -> Tenant:
 | 
			
		||||
def get_current_tenant() -> Tenant:
 | 
			
		||||
    """Get tenant for current request"""
 | 
			
		||||
    if only is None:
 | 
			
		||||
        only = []
 | 
			
		||||
    return Tenant.objects.only(*only).get(schema_name=connection.schema_name)
 | 
			
		||||
    return Tenant.objects.get(schema_name=connection.schema_name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_unique_identifier() -> str:
 | 
			
		||||
 | 
			
		||||
@ -101,6 +101,7 @@ entries:
 | 
			
		||||
    - !KeyOf prompt-field-email
 | 
			
		||||
    - !KeyOf prompt-field-password
 | 
			
		||||
    - !KeyOf prompt-field-password-repeat
 | 
			
		||||
    validation_policies: []
 | 
			
		||||
  id: stage-default-oobe-password
 | 
			
		||||
  identifiers:
 | 
			
		||||
    name: stage-default-oobe-password
 | 
			
		||||
 | 
			
		||||
@ -2,17 +2,6 @@ version: 1
 | 
			
		||||
metadata:
 | 
			
		||||
  name: Default - Password change flow
 | 
			
		||||
entries:
 | 
			
		||||
- attrs:
 | 
			
		||||
    check_static_rules: true
 | 
			
		||||
    check_zxcvbn: true
 | 
			
		||||
    length_min: 8
 | 
			
		||||
    password_field: password
 | 
			
		||||
    zxcvbn_score_threshold: 2
 | 
			
		||||
    error_message: Password needs to be 8 characters or longer.
 | 
			
		||||
  identifiers:
 | 
			
		||||
    name: default-password-change-password-policy
 | 
			
		||||
  model: authentik_policies_password.passwordpolicy
 | 
			
		||||
  id: default-password-change-password-policy
 | 
			
		||||
- attrs:
 | 
			
		||||
    designation: stage_configuration
 | 
			
		||||
    name: Change Password
 | 
			
		||||
@ -50,8 +39,6 @@ entries:
 | 
			
		||||
    fields:
 | 
			
		||||
    - !KeyOf prompt-field-password
 | 
			
		||||
    - !KeyOf prompt-field-password-repeat
 | 
			
		||||
    validation_policies:
 | 
			
		||||
    - !KeyOf default-password-change-password-policy
 | 
			
		||||
  identifiers:
 | 
			
		||||
    name: default-password-change-prompt
 | 
			
		||||
  id: default-password-change-prompt
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
    "$schema": "http://json-schema.org/draft-07/schema",
 | 
			
		||||
    "$id": "https://goauthentik.io/blueprints/schema.json",
 | 
			
		||||
    "type": "object",
 | 
			
		||||
    "title": "authentik 2024.10.4 Blueprint schema",
 | 
			
		||||
    "title": "authentik 2024.10.0 Blueprint schema",
 | 
			
		||||
    "required": [
 | 
			
		||||
        "version",
 | 
			
		||||
        "entries"
 | 
			
		||||
@ -5570,30 +5570,9 @@
 | 
			
		||||
                    "description": "Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs."
 | 
			
		||||
                },
 | 
			
		||||
                "redirect_uris": {
 | 
			
		||||
                    "type": "array",
 | 
			
		||||
                    "items": {
 | 
			
		||||
                        "type": "object",
 | 
			
		||||
                        "properties": {
 | 
			
		||||
                            "matching_mode": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
                                "enum": [
 | 
			
		||||
                                    "strict",
 | 
			
		||||
                                    "regex"
 | 
			
		||||
                                ],
 | 
			
		||||
                                "title": "Matching mode"
 | 
			
		||||
                            },
 | 
			
		||||
                            "url": {
 | 
			
		||||
                                "type": "string",
 | 
			
		||||
                                "minLength": 1,
 | 
			
		||||
                                "title": "Url"
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        "required": [
 | 
			
		||||
                            "matching_mode",
 | 
			
		||||
                            "url"
 | 
			
		||||
                        ]
 | 
			
		||||
                    },
 | 
			
		||||
                    "title": "Redirect uris"
 | 
			
		||||
                    "title": "Redirect URIs",
 | 
			
		||||
                    "description": "Enter each URI on a new line."
 | 
			
		||||
                },
 | 
			
		||||
                "sub_mode": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
@ -6995,7 +6974,7 @@
 | 
			
		||||
                "spnego_server_name": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
                    "title": "Spnego server name",
 | 
			
		||||
                    "description": "Force the use of a specific server name for SPNEGO. Must be in the form HTTP@hostname"
 | 
			
		||||
                    "description": "Force the use of a specific server name for SPNEGO"
 | 
			
		||||
                },
 | 
			
		||||
                "spnego_keytab": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
@ -9802,10 +9781,6 @@
 | 
			
		||||
                    "minLength": 1,
 | 
			
		||||
                    "title": "Api url"
 | 
			
		||||
                },
 | 
			
		||||
                "interactive": {
 | 
			
		||||
                    "type": "boolean",
 | 
			
		||||
                    "title": "Interactive"
 | 
			
		||||
                },
 | 
			
		||||
                "score_min_threshold": {
 | 
			
		||||
                    "type": "number",
 | 
			
		||||
                    "title": "Score min threshold"
 | 
			
		||||
@ -13408,6 +13383,12 @@
 | 
			
		||||
                    "title": "Authorization flow",
 | 
			
		||||
                    "description": "Flow used when authorizing this provider."
 | 
			
		||||
                },
 | 
			
		||||
                "invalidation_flow": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
                    "format": "uuid",
 | 
			
		||||
                    "title": "Invalidation flow",
 | 
			
		||||
                    "description": "Flow used ending the session from a provider."
 | 
			
		||||
                },
 | 
			
		||||
                "property_mappings": {
 | 
			
		||||
                    "type": "array",
 | 
			
		||||
                    "items": {
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@ services:
 | 
			
		||||
    volumes:
 | 
			
		||||
      - redis:/data
 | 
			
		||||
  server:
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4}
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.0}
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    command: server
 | 
			
		||||
    environment:
 | 
			
		||||
@ -52,7 +52,7 @@ services:
 | 
			
		||||
      - postgresql
 | 
			
		||||
      - redis
 | 
			
		||||
  worker:
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4}
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.0}
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    command: worker
 | 
			
		||||
    environment:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.mod
									
									
									
									
									
								
							@ -29,10 +29,10 @@ require (
 | 
			
		||||
	github.com/spf13/cobra v1.8.1
 | 
			
		||||
	github.com/stretchr/testify v1.9.0
 | 
			
		||||
	github.com/wwt/guac v1.3.2
 | 
			
		||||
	goauthentik.io/api/v3 v3.2024104.1
 | 
			
		||||
	goauthentik.io/api/v3 v3.2024100.1
 | 
			
		||||
	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
 | 
			
		||||
	golang.org/x/oauth2 v0.24.0
 | 
			
		||||
	golang.org/x/sync v0.9.0
 | 
			
		||||
	golang.org/x/oauth2 v0.23.0
 | 
			
		||||
	golang.org/x/sync v0.8.0
 | 
			
		||||
	gopkg.in/yaml.v2 v2.4.0
 | 
			
		||||
	layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.sum
									
									
									
									
									
								
							@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
 | 
			
		||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 | 
			
		||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 | 
			
		||||
goauthentik.io/api/v3 v3.2024104.1 h1:N09HAJ66W965QEYpx6sJzcaQxPsnFykVwkzVjVK/zH0=
 | 
			
		||||
goauthentik.io/api/v3 v3.2024104.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
 | 
			
		||||
goauthentik.io/api/v3 v3.2024100.1 h1:ve8xiaKOyUD5oCkNAsu1o3nc7aolt9bKTTR2qMI1iU4=
 | 
			
		||||
goauthentik.io/api/v3 v3.2024100.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 | 
			
		||||
@ -388,8 +388,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 | 
			
		||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
 | 
			
		||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 | 
			
		||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
 | 
			
		||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 | 
			
		||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
@ -400,8 +400,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
 | 
			
		||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
 | 
			
		||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 | 
			
		||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
 | 
			
		||||
@ -29,4 +29,4 @@ func UserAgent() string {
 | 
			
		||||
	return fmt.Sprintf("authentik@%s", FullVersion())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const VERSION = "2024.10.4"
 | 
			
		||||
const VERSION = "2024.10.0"
 | 
			
		||||
 | 
			
		||||
@ -65,7 +65,7 @@ func (ls *LDAPServer) StartLDAPServer() error {
 | 
			
		||||
		ls.log.WithField("listen", listen).WithError(err).Warning("Failed to listen (SSL)")
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	ls.log.WithField("listen", listen).Info("Starting LDAP server")
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,7 @@ func (ls *LDAPServer) StartLDAPTLSServer() error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	tln := tls.NewListener(proxyListener, tlsConfig)
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ import (
 | 
			
		||||
	"github.com/prometheus/client_golang/prometheus"
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
	"goauthentik.io/api/v3"
 | 
			
		||||
	"goauthentik.io/internal/config"
 | 
			
		||||
	"goauthentik.io/internal/outpost/ak"
 | 
			
		||||
	"goauthentik.io/internal/outpost/proxyv2/constants"
 | 
			
		||||
	"goauthentik.io/internal/outpost/proxyv2/hs256"
 | 
			
		||||
@ -122,14 +121,6 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
 | 
			
		||||
	bs := string(h.Sum([]byte(*p.ClientId)))
 | 
			
		||||
	sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8])
 | 
			
		||||
 | 
			
		||||
	// When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match
 | 
			
		||||
	// otherwise we use the internally configured authentik_host
 | 
			
		||||
	tokenEndpointHost := server.API().Outpost.Config["authentik_host"].(string)
 | 
			
		||||
	if config.Get().AuthentikHostBrowser != "" {
 | 
			
		||||
		tokenEndpointHost = config.Get().AuthentikHostBrowser
 | 
			
		||||
	}
 | 
			
		||||
	publicHTTPClient := web.NewHostInterceptor(c, tokenEndpointHost)
 | 
			
		||||
 | 
			
		||||
	a := &Application{
 | 
			
		||||
		Host:                 externalHost.Host,
 | 
			
		||||
		log:                  muxLogger,
 | 
			
		||||
@ -140,7 +131,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
 | 
			
		||||
		tokenVerifier:        verifier,
 | 
			
		||||
		proxyConfig:          p,
 | 
			
		||||
		httpClient:           c,
 | 
			
		||||
		publicHostHTTPClient: publicHTTPClient,
 | 
			
		||||
		publicHostHTTPClient: web.NewHostInterceptor(c, server.API().Outpost.Config["authentik_host"].(string)),
 | 
			
		||||
		mux:                  mux,
 | 
			
		||||
		errorTemplates:       templates.GetTemplates(),
 | 
			
		||||
		ak:                   server.API(),
 | 
			
		||||
 | 
			
		||||
@ -129,7 +129,7 @@ func (ps *ProxyServer) ServeHTTP() {
 | 
			
		||||
		ps.log.WithField("listen", listenAddress).WithError(err).Warning("Failed to listen")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: listener, ConnPolicy: utils.GetProxyConnectionPolicy()}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: listener}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	ps.log.WithField("listen", listenAddress).Info("Starting HTTP server")
 | 
			
		||||
@ -148,7 +148,7 @@ func (ps *ProxyServer) ServeHTTPS() {
 | 
			
		||||
		ps.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)}}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	tlsListener := tls.NewListener(proxyListener, tlsConfig)
 | 
			
		||||
 | 
			
		||||
@ -1,34 +0,0 @@
 | 
			
		||||
package utils
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net"
 | 
			
		||||
 | 
			
		||||
	"github.com/pires/go-proxyproto"
 | 
			
		||||
	log "github.com/sirupsen/logrus"
 | 
			
		||||
	"goauthentik.io/internal/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func GetProxyConnectionPolicy() proxyproto.ConnPolicyFunc {
 | 
			
		||||
	nets := []*net.IPNet{}
 | 
			
		||||
	for _, rn := range config.Get().Listen.TrustedProxyCIDRs {
 | 
			
		||||
		_, cidr, err := net.ParseCIDR(rn)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		nets = append(nets, cidr)
 | 
			
		||||
	}
 | 
			
		||||
	return func(connPolicyOptions proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
 | 
			
		||||
		host, _, err := net.SplitHostPort(connPolicyOptions.Upstream.String())
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			// remoteAddr will be nil if the IP cannot be parsed
 | 
			
		||||
			remoteAddr := net.ParseIP(host)
 | 
			
		||||
			for _, allowedCidr := range nets {
 | 
			
		||||
				if remoteAddr != nil && allowedCidr.Contains(remoteAddr) {
 | 
			
		||||
					log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Using remote IP from proxy protocol")
 | 
			
		||||
					return proxyproto.USE, nil
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return proxyproto.SKIP, nil
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -14,10 +14,8 @@ type hostInterceptor struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t hostInterceptor) RoundTrip(r *http.Request) (*http.Response, error) {
 | 
			
		||||
	if r.Host != t.host {
 | 
			
		||||
	r.Host = t.host
 | 
			
		||||
	r.Header.Set("X-Forwarded-Proto", t.scheme)
 | 
			
		||||
	}
 | 
			
		||||
	return t.inner.RoundTrip(r)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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,25 +14,14 @@ 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",
 | 
			
		||||
}, []string{"dest"})
 | 
			
		||||
 | 
			
		||||
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()
 | 
			
		||||
	l := log.WithField("logger", "authentik.router.metrics")
 | 
			
		||||
	m.Use(sentry.SentryNoSampleMiddleware)
 | 
			
		||||
	m.Path("/metrics").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		promhttp.InstrumentMetricHandler(
 | 
			
		||||
@ -51,7 +36,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.SetBasicAuth("monitor", config.Get().SecretKey)
 | 
			
		||||
		res, err := ws.upstreamHttpClient().Do(re)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			l.WithError(err).Warning("failed to get upstream metrics")
 | 
			
		||||
@ -64,13 +49,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")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -42,11 +42,8 @@ func (ws *WebServer) configureStatic() {
 | 
			
		||||
 | 
			
		||||
	// Media files, if backend is file
 | 
			
		||||
	if config.Get().Storage.Media.Backend == "file" {
 | 
			
		||||
		fsMedia := http.StripPrefix("/media", http.FileServer(http.Dir(config.Get().Storage.Media.File.Path)))
 | 
			
		||||
		staticRouter.PathPrefix("/media/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		    w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
 | 
			
		||||
		    fsMedia.ServeHTTP(w, r)
 | 
			
		||||
		})
 | 
			
		||||
		fsMedia := http.FileServer(http.Dir(config.Get().Storage.Media.File.Path))
 | 
			
		||||
		staticRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fsMedia))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	staticRouter.PathPrefix("/if/help/").Handler(http.StripPrefix("/if/help/", http.FileServer(http.Dir("./website/help/"))))
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,7 @@ func (ws *WebServer) listenTLS() {
 | 
			
		||||
		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)}}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	tlsListener := tls.NewListener(proxyListener, tlsConfig)
 | 
			
		||||
@ -19,7 +19,6 @@ import (
 | 
			
		||||
	"goauthentik.io/internal/config"
 | 
			
		||||
	"goauthentik.io/internal/gounicorn"
 | 
			
		||||
	"goauthentik.io/internal/outpost/proxyv2"
 | 
			
		||||
	"goauthentik.io/internal/utils"
 | 
			
		||||
	"goauthentik.io/internal/utils/web"
 | 
			
		||||
	"goauthentik.io/internal/web/brand_tls"
 | 
			
		||||
)
 | 
			
		||||
@ -53,7 +52,7 @@ func NewWebServer() *WebServer {
 | 
			
		||||
	loggingHandler.Use(web.NewLoggingHandler(l, nil))
 | 
			
		||||
 | 
			
		||||
	tmp := os.TempDir()
 | 
			
		||||
	socketPath := path.Join(tmp, UnixSocketName)
 | 
			
		||||
	socketPath := path.Join(tmp, "authentik-core.sock")
 | 
			
		||||
 | 
			
		||||
	// create http client to talk to backend, normal client if we're in debug more
 | 
			
		||||
	// and a client that connects to our socket when in non debug mode
 | 
			
		||||
@ -150,7 +149,7 @@ func (ws *WebServer) listenPlain() {
 | 
			
		||||
		ws.log.WithError(err).Warning("failed to listen")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()}
 | 
			
		||||
	proxyListener := &proxyproto.Listener{Listener: ln}
 | 
			
		||||
	defer proxyListener.Close()
 | 
			
		||||
 | 
			
		||||
	ws.log.WithField("listen", config.Get().Listen.HTTP).Info("Starting HTTP server")
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,6 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
 | 
			
		||||
# Stage 2: Run
 | 
			
		||||
FROM ghcr.io/goauthentik/fips-debian:bookworm-slim-fips
 | 
			
		||||
 | 
			
		||||
ARG VERSION
 | 
			
		||||
ARG GIT_BUILD_HASH
 | 
			
		||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user