Compare commits
	
		
			56 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e8b5e4c127 | |||
| 81ec98b198 | |||
| c46ab19e79 | |||
| de9fc5de6b | |||
| eab3d9b411 | |||
| 7cb40d786f | |||
| b4fce08bbc | |||
| 8a2ba1c518 | |||
| 25b4306693 | |||
| 1e279950f1 | |||
| 960429355f | |||
| b4f3748353 | |||
| 91d2445c61 | |||
| dd8f809161 | |||
| 57a31b5dd1 | |||
| 09125b6236 | |||
| 832126c6fe | |||
| 25fe489b34 | |||
| 18078fd68f | |||
| 4fa71d995d | |||
| 22cec64234 | |||
| a87cc27366 | |||
| ad7ad1fa78 | |||
| c70e609e50 | |||
| 5f08485fff | |||
| 3a2ed11821 | |||
| ee04f39e28 | |||
| 2c6aa72f3c | |||
| bd0afef790 | |||
| fc11cc0a1a | |||
| fb78303e8f | |||
| 2ea04440db | |||
| 96e1636be3 | |||
| c546451a73 | |||
| 61778053b4 | |||
| f5580d311d | |||
| 99d292bce0 | |||
| b2801641bc | |||
| bfaa1046b2 | |||
| 95c30400cc | |||
| e77480ee1d | |||
| 905800e535 | |||
| fadeaef4c6 | |||
| 437efda649 | |||
| dd75d5f54b | |||
| 392a2e582e | |||
| a1da183721 | |||
| feea2df0b1 | |||
| b47acd8c76 | |||
| 6fd87d9ced | |||
| acbb065808 | |||
| 2fb097061d | |||
| 8962d17e03 | |||
| 8326e1490c | |||
| 091e4d3e4c | |||
| 6ee77edcbb | 
@ -1,5 +1,5 @@
 | 
				
			|||||||
[bumpversion]
 | 
					[bumpversion]
 | 
				
			||||||
current_version = 2024.8.0-rc2
 | 
					current_version = 2024.8.4
 | 
				
			||||||
tag = True
 | 
					tag = True
 | 
				
			||||||
commit = 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*))?
 | 
					parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
 | 
				
			||||||
 | 
				
			|||||||
@ -29,9 +29,9 @@ outputs:
 | 
				
			|||||||
  imageTags:
 | 
					  imageTags:
 | 
				
			||||||
    description: "Docker image tags"
 | 
					    description: "Docker image tags"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageTags }}
 | 
					    value: ${{ steps.ev.outputs.imageTags }}
 | 
				
			||||||
  imageNames:
 | 
					  attestImageNames:
 | 
				
			||||||
    description: "Docker image names"
 | 
					    description: "Docker image names used for attestation"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageNames }}
 | 
					    value: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
  imageMainTag:
 | 
					  imageMainTag:
 | 
				
			||||||
    description: "Docker image main tag"
 | 
					    description: "Docker image main tag"
 | 
				
			||||||
    value: ${{ steps.ev.outputs.imageMainTag }}
 | 
					    value: ${{ steps.ev.outputs.imageMainTag }}
 | 
				
			||||||
 | 
				
			|||||||
@ -51,15 +51,24 @@ else:
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
image_main_tag = image_tags[0].split(":")[-1]
 | 
					image_main_tag = image_tags[0].split(":")[-1]
 | 
				
			||||||
image_tags_rendered = ",".join(image_tags)
 | 
					
 | 
				
			||||||
image_names_rendered = ",".join(set(name.split(":")[0] for name in image_tags))
 | 
					
 | 
				
			||||||
 | 
					def get_attest_image_names(image_with_tags: list[str]):
 | 
				
			||||||
 | 
					    """Attestation only for GHCR"""
 | 
				
			||||||
 | 
					    image_tags = []
 | 
				
			||||||
 | 
					    for image_name in set(name.split(":")[0] for name in image_with_tags):
 | 
				
			||||||
 | 
					        if not image_name.startswith("ghcr.io"):
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        image_tags.append(image_name)
 | 
				
			||||||
 | 
					    return ",".join(set(image_tags))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
 | 
					with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
 | 
				
			||||||
    print(f"shouldBuild={should_build}", file=_output)
 | 
					    print(f"shouldBuild={should_build}", file=_output)
 | 
				
			||||||
    print(f"sha={sha}", file=_output)
 | 
					    print(f"sha={sha}", file=_output)
 | 
				
			||||||
    print(f"version={version}", file=_output)
 | 
					    print(f"version={version}", file=_output)
 | 
				
			||||||
    print(f"prerelease={prerelease}", file=_output)
 | 
					    print(f"prerelease={prerelease}", file=_output)
 | 
				
			||||||
    print(f"imageTags={image_tags_rendered}", file=_output)
 | 
					    print(f"imageTags={','.join(image_tags)}", file=_output)
 | 
				
			||||||
    print(f"imageNames={image_names_rendered}", file=_output)
 | 
					    print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
 | 
				
			||||||
    print(f"imageMainTag={image_main_tag}", file=_output)
 | 
					    print(f"imageMainTag={image_main_tag}", file=_output)
 | 
				
			||||||
    print(f"imageMainName={image_tags[0]}", file=_output)
 | 
					    print(f"imageMainName={image_tags[0]}", file=_output)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							@ -261,7 +261,7 @@ jobs:
 | 
				
			|||||||
        id: attest
 | 
					        id: attest
 | 
				
			||||||
        if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
					        if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.imageNames }}
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
          subject-digest: ${{ steps.push.outputs.digest }}
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
          push-to-registry: true
 | 
					          push-to-registry: true
 | 
				
			||||||
  pr-comment:
 | 
					  pr-comment:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							@ -115,7 +115,7 @@ jobs:
 | 
				
			|||||||
        id: attest
 | 
					        id: attest
 | 
				
			||||||
        if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
					        if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.imageNames }}
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
          subject-digest: ${{ steps.push.outputs.digest }}
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
          push-to-registry: true
 | 
					          push-to-registry: true
 | 
				
			||||||
  build-binary:
 | 
					  build-binary:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -58,7 +58,7 @@ jobs:
 | 
				
			|||||||
      - uses: actions/attest-build-provenance@v1
 | 
					      - uses: actions/attest-build-provenance@v1
 | 
				
			||||||
        id: attest
 | 
					        id: attest
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.imageNames }}
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
          subject-digest: ${{ steps.push.outputs.digest }}
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
          push-to-registry: true
 | 
					          push-to-registry: true
 | 
				
			||||||
  build-outpost:
 | 
					  build-outpost:
 | 
				
			||||||
@ -122,7 +122,7 @@ jobs:
 | 
				
			|||||||
      - uses: actions/attest-build-provenance@v1
 | 
					      - uses: actions/attest-build-provenance@v1
 | 
				
			||||||
        id: attest
 | 
					        id: attest
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          subject-name: ${{ steps.ev.outputs.imageNames }}
 | 
					          subject-name: ${{ steps.ev.outputs.attestImageNames }}
 | 
				
			||||||
          subject-digest: ${{ steps.push.outputs.digest }}
 | 
					          subject-digest: ${{ steps.push.outputs.digest }}
 | 
				
			||||||
          push-to-registry: true
 | 
					          push-to-registry: true
 | 
				
			||||||
  build-outpost-binary:
 | 
					  build-outpost-binary:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							@ -205,7 +205,7 @@ gen: gen-build gen-client-ts
 | 
				
			|||||||
web-build: web-install  ## Build the Authentik UI
 | 
					web-build: web-install  ## Build the Authentik UI
 | 
				
			||||||
	cd web && npm run build
 | 
						cd web && npm run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
web: web-lint-fix web-lint web-check-compile web-test  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
 | 
					web: web-lint-fix web-lint web-check-compile  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
 | 
				
			||||||
 | 
					
 | 
				
			||||||
web-install:  ## Install the necessary libraries to build the Authentik UI
 | 
					web-install:  ## Install the necessary libraries to build the Authentik UI
 | 
				
			||||||
	cd web && npm ci
 | 
						cd web && npm ci
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from os import environ
 | 
					from os import environ
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__version__ = "2024.8.0"
 | 
					__version__ = "2024.8.4"
 | 
				
			||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
					ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -51,9 +51,11 @@ class BlueprintInstanceSerializer(ModelSerializer):
 | 
				
			|||||||
        context = self.instance.context if self.instance else {}
 | 
					        context = self.instance.context if self.instance else {}
 | 
				
			||||||
        valid, logs = Importer.from_string(content, context).validate()
 | 
					        valid, logs = Importer.from_string(content, context).validate()
 | 
				
			||||||
        if not valid:
 | 
					        if not valid:
 | 
				
			||||||
            text_logs = "\n".join([x["event"] for x in logs])
 | 
					 | 
				
			||||||
            raise ValidationError(
 | 
					            raise ValidationError(
 | 
				
			||||||
                _("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
 | 
					                [
 | 
				
			||||||
 | 
					                    _("Failed to validate blueprint"),
 | 
				
			||||||
 | 
					                    *[f"- {x.event}" for x in logs],
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        return content
 | 
					        return content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -78,5 +78,5 @@ class TestBlueprintsV1API(APITestCase):
 | 
				
			|||||||
        self.assertEqual(res.status_code, 400)
 | 
					        self.assertEqual(res.status_code, 400)
 | 
				
			||||||
        self.assertJSONEqual(
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
            res.content.decode(),
 | 
					            res.content.decode(),
 | 
				
			||||||
            {"content": ["Failed to validate blueprint: Invalid blueprint version"]},
 | 
					            {"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -429,7 +429,7 @@ class Importer:
 | 
				
			|||||||
        orig_import = deepcopy(self._import)
 | 
					        orig_import = deepcopy(self._import)
 | 
				
			||||||
        if self._import.version != 1:
 | 
					        if self._import.version != 1:
 | 
				
			||||||
            self.logger.warning("Invalid blueprint version")
 | 
					            self.logger.warning("Invalid blueprint version")
 | 
				
			||||||
            return False, [{"event": "Invalid blueprint version"}]
 | 
					            return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)]
 | 
				
			||||||
        with (
 | 
					        with (
 | 
				
			||||||
            transaction_rollback(),
 | 
					            transaction_rollback(),
 | 
				
			||||||
            capture_logs() as logs,
 | 
					            capture_logs() as logs,
 | 
				
			||||||
 | 
				
			|||||||
@ -30,8 +30,10 @@ from authentik.core.api.utils import (
 | 
				
			|||||||
    PassiveSerializer,
 | 
					    PassiveSerializer,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.core.expression.evaluator import PropertyMappingEvaluator
 | 
					from authentik.core.expression.evaluator import PropertyMappingEvaluator
 | 
				
			||||||
 | 
					from authentik.core.expression.exceptions import PropertyMappingExpressionException
 | 
				
			||||||
from authentik.core.models import Group, PropertyMapping, User
 | 
					from authentik.core.models import Group, PropertyMapping, User
 | 
				
			||||||
from authentik.events.utils import sanitize_item
 | 
					from authentik.events.utils import sanitize_item
 | 
				
			||||||
 | 
					from authentik.lib.utils.errors import exception_to_string
 | 
				
			||||||
from authentik.policies.api.exec import PolicyTestSerializer
 | 
					from authentik.policies.api.exec import PolicyTestSerializer
 | 
				
			||||||
from authentik.rbac.decorators import permission_required
 | 
					from authentik.rbac.decorators import permission_required
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -162,12 +164,15 @@ class PropertyMappingViewSet(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        response_data = {"successful": True, "result": ""}
 | 
					        response_data = {"successful": True, "result": ""}
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            result = mapping.evaluate(**context)
 | 
					            result = mapping.evaluate(dry_run=True, **context)
 | 
				
			||||||
            response_data["result"] = dumps(
 | 
					            response_data["result"] = dumps(
 | 
				
			||||||
                sanitize_item(result), indent=(4 if format_result else None)
 | 
					                sanitize_item(result), indent=(4 if format_result else None)
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					        except PropertyMappingExpressionException as exc:
 | 
				
			||||||
 | 
					            response_data["result"] = exception_to_string(exc.exc)
 | 
				
			||||||
 | 
					            response_data["successful"] = False
 | 
				
			||||||
        except Exception as exc:
 | 
					        except Exception as exc:
 | 
				
			||||||
            response_data["result"] = str(exc)
 | 
					            response_data["result"] = exception_to_string(exc)
 | 
				
			||||||
            response_data["successful"] = False
 | 
					            response_data["successful"] = False
 | 
				
			||||||
        response = PropertyMappingTestResultSerializer(response_data)
 | 
					        response = PropertyMappingTestResultSerializer(response_data)
 | 
				
			||||||
        return Response(response.data)
 | 
					        return Response(response.data)
 | 
				
			||||||
 | 
				
			|||||||
@ -678,10 +678,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
        if not request.tenant.impersonation:
 | 
					        if not request.tenant.impersonation:
 | 
				
			||||||
            LOGGER.debug("User attempted to impersonate", user=request.user)
 | 
					            LOGGER.debug("User attempted to impersonate", user=request.user)
 | 
				
			||||||
            return Response(status=401)
 | 
					            return Response(status=401)
 | 
				
			||||||
        if not request.user.has_perm("impersonate"):
 | 
					        user_to_be = self.get_object()
 | 
				
			||||||
 | 
					        # Check both object-level perms and global perms
 | 
				
			||||||
 | 
					        if not request.user.has_perm(
 | 
				
			||||||
 | 
					            "authentik_core.impersonate", user_to_be
 | 
				
			||||||
 | 
					        ) and not request.user.has_perm("authentik_core.impersonate"):
 | 
				
			||||||
            LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
 | 
					            LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
 | 
				
			||||||
            return Response(status=401)
 | 
					            return Response(status=401)
 | 
				
			||||||
        user_to_be = self.get_object()
 | 
					 | 
				
			||||||
        if user_to_be.pk == self.request.user.pk:
 | 
					        if user_to_be.pk == self.request.user.pk:
 | 
				
			||||||
            LOGGER.debug("User attempted to impersonate themselves", user=request.user)
 | 
					            LOGGER.debug("User attempted to impersonate themselves", user=request.user)
 | 
				
			||||||
            return Response(status=401)
 | 
					            return Response(status=401)
 | 
				
			||||||
 | 
				
			|||||||
@ -9,10 +9,11 @@ class Command(TenantCommand):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def add_arguments(self, parser):
 | 
					    def add_arguments(self, parser):
 | 
				
			||||||
        parser.add_argument("--type", type=str, required=True)
 | 
					        parser.add_argument("--type", type=str, required=True)
 | 
				
			||||||
        parser.add_argument("--all", action="store_true")
 | 
					        parser.add_argument("--all", action="store_true", default=False)
 | 
				
			||||||
        parser.add_argument("usernames", nargs="+", type=str)
 | 
					        parser.add_argument("usernames", nargs="*", type=str)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def handle_per_tenant(self, **options):
 | 
					    def handle_per_tenant(self, **options):
 | 
				
			||||||
 | 
					        print(options)
 | 
				
			||||||
        new_type = UserTypes(options["type"])
 | 
					        new_type = UserTypes(options["type"])
 | 
				
			||||||
        qs = (
 | 
					        qs = (
 | 
				
			||||||
            User.objects.exclude_anonymous()
 | 
					            User.objects.exclude_anonymous()
 | 
				
			||||||
@ -22,6 +23,9 @@ class Command(TenantCommand):
 | 
				
			|||||||
        if options["usernames"] and options["all"]:
 | 
					        if options["usernames"] and options["all"]:
 | 
				
			||||||
            self.stderr.write("--all and usernames specified, only one can be specified")
 | 
					            self.stderr.write("--all and usernames specified, only one can be specified")
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					        if not options["usernames"] and not options["all"]:
 | 
				
			||||||
 | 
					            self.stderr.write("--all or usernames must be specified")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        if options["usernames"] and not options["all"]:
 | 
					        if options["usernames"] and not options["all"]:
 | 
				
			||||||
            qs = qs.filter(username__in=options["usernames"])
 | 
					            qs = qs.filter(username__in=options["usernames"])
 | 
				
			||||||
        updated = qs.update(type=new_type)
 | 
					        updated = qs.update(type=new_type)
 | 
				
			||||||
 | 
				
			|||||||
@ -466,8 +466,6 @@ class ApplicationQuerySet(QuerySet):
 | 
				
			|||||||
    def with_provider(self) -> "QuerySet[Application]":
 | 
					    def with_provider(self) -> "QuerySet[Application]":
 | 
				
			||||||
        qs = self.select_related("provider")
 | 
					        qs = self.select_related("provider")
 | 
				
			||||||
        for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
 | 
					        for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
 | 
				
			||||||
            if LOOKUP_SEP in subclass:
 | 
					 | 
				
			||||||
                continue
 | 
					 | 
				
			||||||
            qs = qs.select_related(f"provider__{subclass}")
 | 
					            qs = qs.select_related(f"provider__{subclass}")
 | 
				
			||||||
        return qs
 | 
					        return qs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -545,15 +543,24 @@ class Application(SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        if not self.provider:
 | 
					        if not self.provider:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
 | 
					        candidates = []
 | 
				
			||||||
            # We don't care about recursion, skip nested models
 | 
					        base_class = Provider
 | 
				
			||||||
            if LOOKUP_SEP in subclass:
 | 
					        for subclass in base_class.objects.get_queryset()._get_subclasses_recurse(base_class):
 | 
				
			||||||
 | 
					            parent = self.provider
 | 
				
			||||||
 | 
					            for level in subclass.split(LOOKUP_SEP):
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    parent = getattr(parent, level)
 | 
				
			||||||
 | 
					                except AttributeError:
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					            if parent in candidates:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            try:
 | 
					            idx = subclass.count(LOOKUP_SEP)
 | 
				
			||||||
                return getattr(self.provider, subclass)
 | 
					            if type(parent) is not base_class:
 | 
				
			||||||
            except AttributeError:
 | 
					                idx += 1
 | 
				
			||||||
                pass
 | 
					            candidates.insert(idx, parent)
 | 
				
			||||||
        return None
 | 
					        if not candidates:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        return candidates[-1]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return str(self.name)
 | 
					        return str(self.name)
 | 
				
			||||||
@ -901,7 +908,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
 | 
				
			|||||||
        except ControlFlowException as exc:
 | 
					        except ControlFlowException as exc:
 | 
				
			||||||
            raise exc
 | 
					            raise exc
 | 
				
			||||||
        except Exception as exc:
 | 
					        except Exception as exc:
 | 
				
			||||||
            raise PropertyMappingExpressionException(self, exc) from exc
 | 
					            raise PropertyMappingExpressionException(exc, self) from exc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return f"Property Mapping {self.name}"
 | 
					        return f"Property Mapping {self.name}"
 | 
				
			||||||
 | 
				
			|||||||
@ -9,9 +9,12 @@ from rest_framework.test import APITestCase
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application
 | 
					from authentik.core.models import Application
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
					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.dummy.models import DummyPolicy
 | 
				
			||||||
from authentik.policies.models import PolicyBinding
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
					from authentik.providers.oauth2.models import OAuth2Provider
 | 
				
			||||||
 | 
					from authentik.providers.proxy.models import ProxyProvider
 | 
				
			||||||
 | 
					from authentik.providers.saml.models import SAMLProvider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestApplicationsAPI(APITestCase):
 | 
					class TestApplicationsAPI(APITestCase):
 | 
				
			||||||
@ -222,3 +225,31 @@ class TestApplicationsAPI(APITestCase):
 | 
				
			|||||||
                ],
 | 
					                ],
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_provider(self):
 | 
				
			||||||
 | 
					        """Ensure that proxy providers (at the time of writing that is the only provider
 | 
				
			||||||
 | 
					        that inherits from another proxy type (OAuth) instead of inheriting from the root
 | 
				
			||||||
 | 
					        provider class) is correctly looked up and selected from the database"""
 | 
				
			||||||
 | 
					        slug = generate_id()
 | 
				
			||||||
 | 
					        provider = ProxyProvider.objects.create(name=generate_id())
 | 
				
			||||||
 | 
					        Application.objects.create(
 | 
				
			||||||
 | 
					            name=generate_id(),
 | 
				
			||||||
 | 
					            slug=slug,
 | 
				
			||||||
 | 
					            provider=provider,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            Application.objects.with_provider().get(slug=slug).get_provider(), provider
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        slug = generate_id()
 | 
				
			||||||
 | 
					        provider = SAMLProvider.objects.create(name=generate_id())
 | 
				
			||||||
 | 
					        Application.objects.create(
 | 
				
			||||||
 | 
					            name=generate_id(),
 | 
				
			||||||
 | 
					            slug=slug,
 | 
				
			||||||
 | 
					            provider=provider,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            Application.objects.with_provider().get(slug=slug).get_provider(), provider
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -3,10 +3,10 @@
 | 
				
			|||||||
from json import loads
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from guardian.shortcuts import assign_perm
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_user
 | 
				
			||||||
from authentik.core.tests.utils import create_test_admin_user
 | 
					 | 
				
			||||||
from authentik.tenants.utils import get_current_tenant
 | 
					from authentik.tenants.utils import get_current_tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -15,7 +15,7 @@ class TestImpersonation(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.other_user = User.objects.create(username="to-impersonate")
 | 
					        self.other_user = create_test_user()
 | 
				
			||||||
        self.user = create_test_admin_user()
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_impersonate_simple(self):
 | 
					    def test_impersonate_simple(self):
 | 
				
			||||||
@ -44,6 +44,46 @@ class TestImpersonation(APITestCase):
 | 
				
			|||||||
        self.assertEqual(response_body["user"]["username"], self.user.username)
 | 
					        self.assertEqual(response_body["user"]["username"], self.user.username)
 | 
				
			||||||
        self.assertNotIn("original", response_body)
 | 
					        self.assertNotIn("original", response_body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_impersonate_global(self):
 | 
				
			||||||
 | 
					        """Test impersonation with global permissions"""
 | 
				
			||||||
 | 
					        new_user = create_test_user()
 | 
				
			||||||
 | 
					        assign_perm("authentik_core.impersonate", new_user)
 | 
				
			||||||
 | 
					        assign_perm("authentik_core.view_user", new_user)
 | 
				
			||||||
 | 
					        self.client.force_login(new_user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:user-impersonate",
 | 
				
			||||||
 | 
					                kwargs={"pk": self.other_user.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 201)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:user-me"))
 | 
				
			||||||
 | 
					        response_body = loads(response.content.decode())
 | 
				
			||||||
 | 
					        self.assertEqual(response_body["user"]["username"], self.other_user.username)
 | 
				
			||||||
 | 
					        self.assertEqual(response_body["original"]["username"], new_user.username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_impersonate_scoped(self):
 | 
				
			||||||
 | 
					        """Test impersonation with scoped permissions"""
 | 
				
			||||||
 | 
					        new_user = create_test_user()
 | 
				
			||||||
 | 
					        assign_perm("authentik_core.impersonate", new_user, self.other_user)
 | 
				
			||||||
 | 
					        assign_perm("authentik_core.view_user", new_user, self.other_user)
 | 
				
			||||||
 | 
					        self.client.force_login(new_user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:user-impersonate",
 | 
				
			||||||
 | 
					                kwargs={"pk": self.other_user.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 201)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:user-me"))
 | 
				
			||||||
 | 
					        response_body = loads(response.content.decode())
 | 
				
			||||||
 | 
					        self.assertEqual(response_body["user"]["username"], self.other_user.username)
 | 
				
			||||||
 | 
					        self.assertEqual(response_body["original"]["username"], new_user.username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_impersonate_denied(self):
 | 
					    def test_impersonate_denied(self):
 | 
				
			||||||
        """test impersonation without permissions"""
 | 
					        """test impersonation without permissions"""
 | 
				
			||||||
        self.client.force_login(self.other_user)
 | 
					        self.client.force_login(self.other_user)
 | 
				
			||||||
 | 
				
			|||||||
@ -18,7 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
 | 
				
			|||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
					from authentik.core.api.utils import ModelSerializer, PassiveSerializer
 | 
				
			||||||
from authentik.core.models import User, UserTypes
 | 
					from authentik.core.models import User, UserTypes
 | 
				
			||||||
from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
 | 
					from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
 | 
				
			||||||
from authentik.enterprise.models import License, LicenseUsageStatus
 | 
					from authentik.enterprise.models import License
 | 
				
			||||||
from authentik.rbac.decorators import permission_required
 | 
					from authentik.rbac.decorators import permission_required
 | 
				
			||||||
from authentik.tenants.utils import get_unique_identifier
 | 
					from authentik.tenants.utils import get_unique_identifier
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -29,7 +29,7 @@ class EnterpriseRequiredMixin:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def validate(self, attrs: dict) -> dict:
 | 
					    def validate(self, attrs: dict) -> dict:
 | 
				
			||||||
        """Check that a valid license exists"""
 | 
					        """Check that a valid license exists"""
 | 
				
			||||||
        if LicenseKey.cached_summary().status != LicenseUsageStatus.UNLICENSED:
 | 
					        if not LicenseKey.cached_summary().status.is_valid:
 | 
				
			||||||
            raise ValidationError(_("Enterprise is required to create/update this object."))
 | 
					            raise ValidationError(_("Enterprise is required to create/update this object."))
 | 
				
			||||||
        return super().validate(attrs)
 | 
					        return super().validate(attrs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -117,10 +117,13 @@ class LicenseKey:
 | 
				
			|||||||
                    our_cert.public_key(),
 | 
					                    our_cert.public_key(),
 | 
				
			||||||
                    algorithms=["ES512"],
 | 
					                    algorithms=["ES512"],
 | 
				
			||||||
                    audience=get_license_aud(),
 | 
					                    audience=get_license_aud(),
 | 
				
			||||||
                    options={"verify_exp": check_expiry},
 | 
					                    options={"verify_exp": check_expiry, "verify_signature": check_expiry},
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        except PyJWTError:
 | 
					        except PyJWTError:
 | 
				
			||||||
 | 
					            unverified = decode(jwt, options={"verify_signature": False})
 | 
				
			||||||
 | 
					            if unverified["aud"] != get_license_aud():
 | 
				
			||||||
 | 
					                raise ValidationError("Invalid Install ID in license") from None
 | 
				
			||||||
            raise ValidationError("Unable to verify license") from None
 | 
					            raise ValidationError("Unable to verify license") from None
 | 
				
			||||||
        return body
 | 
					        return body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -134,7 +137,7 @@ class LicenseKey:
 | 
				
			|||||||
            exp_ts = int(mktime(lic.expiry.timetuple()))
 | 
					            exp_ts = int(mktime(lic.expiry.timetuple()))
 | 
				
			||||||
            if total.exp == 0:
 | 
					            if total.exp == 0:
 | 
				
			||||||
                total.exp = exp_ts
 | 
					                total.exp = exp_ts
 | 
				
			||||||
            total.exp = min(total.exp, exp_ts)
 | 
					            total.exp = max(total.exp, exp_ts)
 | 
				
			||||||
            total.license_flags.extend(lic.status.license_flags)
 | 
					            total.license_flags.extend(lic.status.license_flags)
 | 
				
			||||||
        return total
 | 
					        return total
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
from datetime import datetime
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.db.models.signals import post_save, pre_save
 | 
					from django.db.models.signals import post_delete, post_save, pre_save
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from django.utils.timezone import get_current_timezone
 | 
					from django.utils.timezone import get_current_timezone
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,3 +27,9 @@ def post_save_license(sender: type[License], instance: License, **_):
 | 
				
			|||||||
    """Trigger license usage calculation when license is saved"""
 | 
					    """Trigger license usage calculation when license is saved"""
 | 
				
			||||||
    cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
 | 
					    cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
 | 
				
			||||||
    enterprise_update_usage.delay()
 | 
					    enterprise_update_usage.delay()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(post_delete, sender=License)
 | 
				
			||||||
 | 
					def post_delete_license(sender: type[License], instance: License, **_):
 | 
				
			||||||
 | 
					    """Clear license cache when license is deleted"""
 | 
				
			||||||
 | 
					    cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
 | 
				
			||||||
 | 
				
			|||||||
@ -69,8 +69,5 @@ class NotificationViewSet(
 | 
				
			|||||||
    @action(detail=False, methods=["post"])
 | 
					    @action(detail=False, methods=["post"])
 | 
				
			||||||
    def mark_all_seen(self, request: Request) -> Response:
 | 
					    def mark_all_seen(self, request: Request) -> Response:
 | 
				
			||||||
        """Mark all the user's notifications as seen"""
 | 
					        """Mark all the user's notifications as seen"""
 | 
				
			||||||
        notifications = Notification.objects.filter(user=request.user)
 | 
					        Notification.objects.filter(user=request.user, seen=False).update(seen=True)
 | 
				
			||||||
        for notification in notifications:
 | 
					 | 
				
			||||||
            notification.seen = True
 | 
					 | 
				
			||||||
        Notification.objects.bulk_update(notifications, ["seen"])
 | 
					 | 
				
			||||||
        return Response({}, status=204)
 | 
					        return Response({}, status=204)
 | 
				
			||||||
 | 
				
			|||||||
@ -49,6 +49,7 @@ from authentik.policies.models import PolicyBindingModel
 | 
				
			|||||||
from authentik.root.middleware import ClientIPMiddleware
 | 
					from authentik.root.middleware import ClientIPMiddleware
 | 
				
			||||||
from authentik.stages.email.utils import TemplateEmailMessage
 | 
					from authentik.stages.email.utils import TemplateEmailMessage
 | 
				
			||||||
from authentik.tenants.models import Tenant
 | 
					from authentik.tenants.models import Tenant
 | 
				
			||||||
 | 
					from authentik.tenants.utils import get_current_tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
DISCORD_FIELD_LIMIT = 25
 | 
					DISCORD_FIELD_LIMIT = 25
 | 
				
			||||||
@ -58,7 +59,11 @@ NOTIFICATION_SUMMARY_LENGTH = 75
 | 
				
			|||||||
def default_event_duration():
 | 
					def default_event_duration():
 | 
				
			||||||
    """Default duration an Event is saved.
 | 
					    """Default duration an Event is saved.
 | 
				
			||||||
    This is used as a fallback when no brand is available"""
 | 
					    This is used as a fallback when no brand is available"""
 | 
				
			||||||
    return now() + timedelta(days=365)
 | 
					    try:
 | 
				
			||||||
 | 
					        tenant = get_current_tenant()
 | 
				
			||||||
 | 
					        return now() + timedelta_from_string(tenant.event_retention)
 | 
				
			||||||
 | 
					    except Tenant.DoesNotExist:
 | 
				
			||||||
 | 
					        return now() + timedelta(days=365)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def default_brand():
 | 
					def default_brand():
 | 
				
			||||||
@ -245,12 +250,6 @@ class Event(SerializerModel, ExpiringModel):
 | 
				
			|||||||
            if QS_QUERY in self.context["http_request"]["args"]:
 | 
					            if QS_QUERY in self.context["http_request"]["args"]:
 | 
				
			||||||
                wrapped = self.context["http_request"]["args"][QS_QUERY]
 | 
					                wrapped = self.context["http_request"]["args"][QS_QUERY]
 | 
				
			||||||
                self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped))
 | 
					                self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped))
 | 
				
			||||||
        if hasattr(request, "tenant"):
 | 
					 | 
				
			||||||
            tenant: Tenant = request.tenant
 | 
					 | 
				
			||||||
            # Because self.created only gets set on save, we can't use it's value here
 | 
					 | 
				
			||||||
            # hence we set self.created to now and then use it
 | 
					 | 
				
			||||||
            self.created = now()
 | 
					 | 
				
			||||||
            self.expires = self.created + timedelta_from_string(tenant.event_retention)
 | 
					 | 
				
			||||||
        if hasattr(request, "brand"):
 | 
					        if hasattr(request, "brand"):
 | 
				
			||||||
            brand: Brand = request.brand
 | 
					            brand: Brand = request.brand
 | 
				
			||||||
            self.brand = sanitize_dict(model_to_dict(brand))
 | 
					            self.brand = sanitize_dict(model_to_dict(brand))
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ from django.db.models import Model
 | 
				
			|||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import default_token_key
 | 
					from authentik.core.models import default_token_key
 | 
				
			||||||
 | 
					from authentik.events.models import default_event_duration
 | 
				
			||||||
from authentik.lib.utils.reflection import get_apps
 | 
					from authentik.lib.utils.reflection import get_apps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,7 +21,7 @@ def model_tester_factory(test_model: type[Model]) -> Callable:
 | 
				
			|||||||
        allowed = 0
 | 
					        allowed = 0
 | 
				
			||||||
        # Token-like objects need to lookup the current tenant to get the default token length
 | 
					        # Token-like objects need to lookup the current tenant to get the default token length
 | 
				
			||||||
        for field in test_model._meta.fields:
 | 
					        for field in test_model._meta.fields:
 | 
				
			||||||
            if field.default == default_token_key:
 | 
					            if field.default in [default_token_key, default_event_duration]:
 | 
				
			||||||
                allowed += 1
 | 
					                allowed += 1
 | 
				
			||||||
        with self.assertNumQueries(allowed):
 | 
					        with self.assertNumQueries(allowed):
 | 
				
			||||||
            str(test_model())
 | 
					            str(test_model())
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from unittest.mock import MagicMock, patch
 | 
					from unittest.mock import MagicMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Group, User
 | 
					from authentik.core.models import Group, User
 | 
				
			||||||
from authentik.events.models import (
 | 
					from authentik.events.models import (
 | 
				
			||||||
@ -10,6 +11,7 @@ from authentik.events.models import (
 | 
				
			|||||||
    EventAction,
 | 
					    EventAction,
 | 
				
			||||||
    Notification,
 | 
					    Notification,
 | 
				
			||||||
    NotificationRule,
 | 
					    NotificationRule,
 | 
				
			||||||
 | 
					    NotificationSeverity,
 | 
				
			||||||
    NotificationTransport,
 | 
					    NotificationTransport,
 | 
				
			||||||
    NotificationWebhookMapping,
 | 
					    NotificationWebhookMapping,
 | 
				
			||||||
    TransportMode,
 | 
					    TransportMode,
 | 
				
			||||||
@ -20,7 +22,7 @@ from authentik.policies.exceptions import PolicyException
 | 
				
			|||||||
from authentik.policies.models import PolicyBinding
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestEventsNotifications(TestCase):
 | 
					class TestEventsNotifications(APITestCase):
 | 
				
			||||||
    """Test Event Notifications"""
 | 
					    """Test Event Notifications"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
@ -131,3 +133,15 @@ class TestEventsNotifications(TestCase):
 | 
				
			|||||||
        Notification.objects.all().delete()
 | 
					        Notification.objects.all().delete()
 | 
				
			||||||
        Event.new(EventAction.CUSTOM_PREFIX).save()
 | 
					        Event.new(EventAction.CUSTOM_PREFIX).save()
 | 
				
			||||||
        self.assertEqual(Notification.objects.first().body, "foo")
 | 
					        self.assertEqual(Notification.objects.first().body, "foo")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_api_mark_all_seen(self):
 | 
				
			||||||
 | 
					        """Test mark_all_seen"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Notification.objects.create(
 | 
				
			||||||
 | 
					            severity=NotificationSeverity.NOTICE, body="foo", user=self.user, seen=False
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(reverse("authentik_api:notification-mark-all-seen"))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 204)
 | 
				
			||||||
 | 
					        self.assertFalse(Notification.objects.filter(body="foo", seen=False).exists())
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import socket
 | 
					import socket
 | 
				
			||||||
from collections.abc import Iterable
 | 
					 | 
				
			||||||
from ipaddress import ip_address, ip_network
 | 
					from ipaddress import ip_address, ip_network
 | 
				
			||||||
from textwrap import indent
 | 
					from textwrap import indent
 | 
				
			||||||
from types import CodeType
 | 
					from types import CodeType
 | 
				
			||||||
@ -28,6 +27,12 @@ from authentik.stages.authenticator import devices_for_user
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ARG_SANITIZE = re.compile(r"[:.-]")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def sanitize_arg(arg_name: str) -> str:
 | 
				
			||||||
 | 
					    return re.sub(ARG_SANITIZE, "_", arg_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BaseEvaluator:
 | 
					class BaseEvaluator:
 | 
				
			||||||
    """Validate and evaluate python-based expressions"""
 | 
					    """Validate and evaluate python-based expressions"""
 | 
				
			||||||
@ -177,9 +182,9 @@ class BaseEvaluator:
 | 
				
			|||||||
        proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
 | 
					        proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
 | 
				
			||||||
        return proc.profiling_wrapper()
 | 
					        return proc.profiling_wrapper()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
 | 
					    def wrap_expression(self, expression: str) -> str:
 | 
				
			||||||
        """Wrap expression in a function, call it, and save the result as `result`"""
 | 
					        """Wrap expression in a function, call it, and save the result as `result`"""
 | 
				
			||||||
        handler_signature = ",".join(params)
 | 
					        handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
 | 
				
			||||||
        full_expression = ""
 | 
					        full_expression = ""
 | 
				
			||||||
        full_expression += f"def handler({handler_signature}):\n"
 | 
					        full_expression += f"def handler({handler_signature}):\n"
 | 
				
			||||||
        full_expression += indent(expression, "    ")
 | 
					        full_expression += indent(expression, "    ")
 | 
				
			||||||
@ -188,8 +193,8 @@ class BaseEvaluator:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def compile(self, expression: str) -> CodeType:
 | 
					    def compile(self, expression: str) -> CodeType:
 | 
				
			||||||
        """Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect."""
 | 
					        """Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect."""
 | 
				
			||||||
        param_keys = self._context.keys()
 | 
					        expression = self.wrap_expression(expression)
 | 
				
			||||||
        return compile(self.wrap_expression(expression, param_keys), self._filename, "exec")
 | 
					        return compile(expression, self._filename, "exec")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def evaluate(self, expression_source: str) -> Any:
 | 
					    def evaluate(self, expression_source: str) -> Any:
 | 
				
			||||||
        """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
 | 
					        """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
 | 
				
			||||||
@ -205,7 +210,7 @@ class BaseEvaluator:
 | 
				
			|||||||
                self.handle_error(exc, expression_source)
 | 
					                self.handle_error(exc, expression_source)
 | 
				
			||||||
                raise exc
 | 
					                raise exc
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                _locals = self._context
 | 
					                _locals = {sanitize_arg(x): y for x, y in self._context.items()}
 | 
				
			||||||
                # Yes this is an exec, yes it is potentially bad. Since we limit what variables are
 | 
					                # Yes this is an exec, yes it is potentially bad. Since we limit what variables are
 | 
				
			||||||
                # available here, and these policies can only be edited by admins, this is a risk
 | 
					                # available here, and these policies can only be edited by admins, this is a risk
 | 
				
			||||||
                # we're willing to take.
 | 
					                # we're willing to take.
 | 
				
			||||||
 | 
				
			|||||||
@ -30,6 +30,11 @@ class TestHTTP(TestCase):
 | 
				
			|||||||
        request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2")
 | 
					        request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2")
 | 
				
			||||||
        self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2")
 | 
					        self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_forward_for_invalid(self):
 | 
				
			||||||
 | 
					        """Test invalid forward for"""
 | 
				
			||||||
 | 
					        request = self.factory.get("/", HTTP_X_FORWARDED_FOR="foobar")
 | 
				
			||||||
 | 
					        self.assertEqual(ClientIPMiddleware.get_client_ip(request), ClientIPMiddleware.default_ip)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_fake_outpost(self):
 | 
					    def test_fake_outpost(self):
 | 
				
			||||||
        """Test faked IP which is overridden by an outpost"""
 | 
					        """Test faked IP which is overridden by an outpost"""
 | 
				
			||||||
        token = Token.objects.create(
 | 
					        token = Token.objects.create(
 | 
				
			||||||
@ -53,6 +58,17 @@ class TestHTTP(TestCase):
 | 
				
			|||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
 | 
					        self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
 | 
				
			||||||
 | 
					        # Invalid, not a real IP
 | 
				
			||||||
 | 
					        self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
 | 
				
			||||||
 | 
					        self.user.save()
 | 
				
			||||||
 | 
					        request = self.factory.get(
 | 
				
			||||||
 | 
					            "/",
 | 
				
			||||||
 | 
					            **{
 | 
				
			||||||
 | 
					                ClientIPMiddleware.outpost_remote_ip_header: "foobar",
 | 
				
			||||||
 | 
					                ClientIPMiddleware.outpost_token_header: token.key,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
 | 
				
			||||||
        # Valid
 | 
					        # Valid
 | 
				
			||||||
        self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
 | 
					        self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
 | 
				
			||||||
        self.user.save()
 | 
					        self.user.save()
 | 
				
			||||||
 | 
				
			|||||||
@ -21,7 +21,14 @@ class DebugSession(Session):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def send(self, req: PreparedRequest, *args, **kwargs):
 | 
					    def send(self, req: PreparedRequest, *args, **kwargs):
 | 
				
			||||||
        request_id = str(uuid4())
 | 
					        request_id = str(uuid4())
 | 
				
			||||||
        LOGGER.debug("HTTP request sent", uid=request_id, path=req.path_url, headers=req.headers)
 | 
					        LOGGER.debug(
 | 
				
			||||||
 | 
					            "HTTP request sent",
 | 
				
			||||||
 | 
					            uid=request_id,
 | 
				
			||||||
 | 
					            url=req.url,
 | 
				
			||||||
 | 
					            method=req.method,
 | 
				
			||||||
 | 
					            headers=req.headers,
 | 
				
			||||||
 | 
					            body=req.body,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        resp = super().send(req, *args, **kwargs)
 | 
					        resp = super().send(req, *args, **kwargs)
 | 
				
			||||||
        LOGGER.debug(
 | 
					        LOGGER.debug(
 | 
				
			||||||
            "HTTP response received",
 | 
					            "HTTP response received",
 | 
				
			||||||
 | 
				
			|||||||
@ -108,7 +108,7 @@ class EventMatcherPolicy(Policy):
 | 
				
			|||||||
                result=result,
 | 
					                result=result,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            matches.append(result)
 | 
					            matches.append(result)
 | 
				
			||||||
        passing = any(x.passing for x in matches)
 | 
					        passing = all(x.passing for x in matches)
 | 
				
			||||||
        messages = chain(*[x.messages for x in matches])
 | 
					        messages = chain(*[x.messages for x in matches])
 | 
				
			||||||
        result = PolicyResult(passing, *messages)
 | 
					        result = PolicyResult(passing, *messages)
 | 
				
			||||||
        result.source_results = matches
 | 
					        result.source_results = matches
 | 
				
			||||||
 | 
				
			|||||||
@ -77,11 +77,24 @@ class TestEventMatcherPolicy(TestCase):
 | 
				
			|||||||
        request = PolicyRequest(get_anonymous_user())
 | 
					        request = PolicyRequest(get_anonymous_user())
 | 
				
			||||||
        request.context["event"] = event
 | 
					        request.context["event"] = event
 | 
				
			||||||
        policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
 | 
					        policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
 | 
				
			||||||
            client_ip="1.2.3.5", app="bar"
 | 
					            client_ip="1.2.3.5", app="foo"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        response = policy.passes(request)
 | 
					        response = policy.passes(request)
 | 
				
			||||||
        self.assertFalse(response.passing)
 | 
					        self.assertFalse(response.passing)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_multiple(self):
 | 
				
			||||||
 | 
					        """Test multiple"""
 | 
				
			||||||
 | 
					        event = Event.new(EventAction.LOGIN)
 | 
				
			||||||
 | 
					        event.app = "foo"
 | 
				
			||||||
 | 
					        event.client_ip = "1.2.3.4"
 | 
				
			||||||
 | 
					        request = PolicyRequest(get_anonymous_user())
 | 
				
			||||||
 | 
					        request.context["event"] = event
 | 
				
			||||||
 | 
					        policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
 | 
				
			||||||
 | 
					            client_ip="1.2.3.4", app="foo"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        response = policy.passes(request)
 | 
				
			||||||
 | 
					        self.assertTrue(response.passing)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_invalid(self):
 | 
					    def test_invalid(self):
 | 
				
			||||||
        """Test passing event"""
 | 
					        """Test passing event"""
 | 
				
			||||||
        request = PolicyRequest(get_anonymous_user())
 | 
					        request = PolicyRequest(get_anonymous_user())
 | 
				
			||||||
 | 
				
			|||||||
@ -4,13 +4,13 @@ from django.apps.registry import Apps
 | 
				
			|||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.db import migrations
 | 
					from django.db import migrations
 | 
				
			||||||
from django.contrib.auth.management import create_permissions
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
					def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
    from guardian.shortcuts import assign_perm
 | 
					 | 
				
			||||||
    from authentik.core.models import User
 | 
					    from authentik.core.models import User
 | 
				
			||||||
    from django.apps import apps as real_apps
 | 
					    from django.apps import apps as real_apps
 | 
				
			||||||
 | 
					    from django.contrib.auth.management import create_permissions
 | 
				
			||||||
 | 
					    from guardian.shortcuts import UserObjectPermission
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    db_alias = schema_editor.connection.alias
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,14 +20,25 @@ def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			|||||||
    create_permissions(real_apps.get_app_config("authentik_providers_ldap"), using=db_alias)
 | 
					    create_permissions(real_apps.get_app_config("authentik_providers_ldap"), using=db_alias)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    LDAPProvider = apps.get_model("authentik_providers_ldap", "ldapprovider")
 | 
					    LDAPProvider = apps.get_model("authentik_providers_ldap", "ldapprovider")
 | 
				
			||||||
 | 
					    Permission = apps.get_model("auth", "Permission")
 | 
				
			||||||
 | 
					    UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
 | 
				
			||||||
 | 
					    ContentType = apps.get_model("contenttypes", "ContentType")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    new_prem = Permission.objects.using(db_alias).get(codename="search_full_directory")
 | 
				
			||||||
 | 
					    ct = ContentType.objects.using(db_alias).get(
 | 
				
			||||||
 | 
					        app_label="authentik_providers_ldap",
 | 
				
			||||||
 | 
					        model="ldapprovider",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for provider in LDAPProvider.objects.using(db_alias).all():
 | 
					    for provider in LDAPProvider.objects.using(db_alias).all():
 | 
				
			||||||
        for user_pk in (
 | 
					        if not provider.search_group:
 | 
				
			||||||
            provider.search_group.users.using(db_alias).all().values_list("pk", flat=True)
 | 
					            continue
 | 
				
			||||||
        ):
 | 
					        for user in provider.search_group.users.using(db_alias).all():
 | 
				
			||||||
            # We need the correct user model instance to assign the permission
 | 
					            UserObjectPermission.objects.using(db_alias).create(
 | 
				
			||||||
            assign_perm(
 | 
					                user=user,
 | 
				
			||||||
                "search_full_directory", User.objects.using(db_alias).get(pk=user_pk), provider
 | 
					                permission=new_prem,
 | 
				
			||||||
 | 
					                object_pk=provider.pk,
 | 
				
			||||||
 | 
					                content_type=ct,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -35,6 +46,7 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    dependencies = [
 | 
					    dependencies = [
 | 
				
			||||||
        ("authentik_providers_ldap", "0003_ldapprovider_mfa_support_and_more"),
 | 
					        ("authentik_providers_ldap", "0003_ldapprovider_mfa_support_and_more"),
 | 
				
			||||||
 | 
					        ("guardian", "0002_generic_permissions_index"),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    operations = [
 | 
					    operations = [
 | 
				
			||||||
 | 
				
			|||||||
@ -29,7 +29,6 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
				
			|||||||
        self.app = Application.objects.create(
 | 
					        self.app = Application.objects.create(
 | 
				
			||||||
            name=generate_id(), slug=generate_id(), provider=self.provider
 | 
					            name=generate_id(), slug=generate_id(), provider=self.provider
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.app.save()
 | 
					 | 
				
			||||||
        self.user = create_test_admin_user()
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.auth = b64encode(
 | 
					        self.auth = b64encode(
 | 
				
			||||||
            f"{self.provider.client_id}:{self.provider.client_secret}".encode()
 | 
					            f"{self.provider.client_id}:{self.provider.client_secret}".encode()
 | 
				
			||||||
@ -114,6 +113,41 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
				
			|||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_introspect_invalid_provider(self):
 | 
				
			||||||
 | 
					        """Test introspection (mismatched provider and token)"""
 | 
				
			||||||
 | 
					        provider: OAuth2Provider = OAuth2Provider.objects.create(
 | 
				
			||||||
 | 
					            name=generate_id(),
 | 
				
			||||||
 | 
					            authorization_flow=create_test_flow(),
 | 
				
			||||||
 | 
					            redirect_uris="",
 | 
				
			||||||
 | 
					            signing_key=create_test_cert(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        token: AccessToken = AccessToken.objects.create(
 | 
				
			||||||
 | 
					            provider=self.provider,
 | 
				
			||||||
 | 
					            user=self.user,
 | 
				
			||||||
 | 
					            token=generate_id(),
 | 
				
			||||||
 | 
					            auth_time=timezone.now(),
 | 
				
			||||||
 | 
					            _scope="openid user profile",
 | 
				
			||||||
 | 
					            _id_token=json.dumps(
 | 
				
			||||||
 | 
					                asdict(
 | 
				
			||||||
 | 
					                    IDToken("foo", "bar"),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        res = self.client.post(
 | 
				
			||||||
 | 
					            reverse("authentik_providers_oauth2:token-introspection"),
 | 
				
			||||||
 | 
					            HTTP_AUTHORIZATION=f"Basic {auth}",
 | 
				
			||||||
 | 
					            data={"token": token.token},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(res.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            res.content.decode(),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "active": False,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_introspect_invalid_auth(self):
 | 
					    def test_introspect_invalid_auth(self):
 | 
				
			||||||
        """Test introspect (invalid auth)"""
 | 
					        """Test introspect (invalid auth)"""
 | 
				
			||||||
        res = self.client.post(
 | 
					        res = self.client.post(
 | 
				
			||||||
 | 
				
			|||||||
@ -46,10 +46,10 @@ class TokenIntrospectionParams:
 | 
				
			|||||||
        if not provider:
 | 
					        if not provider:
 | 
				
			||||||
            raise TokenIntrospectionError
 | 
					            raise TokenIntrospectionError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        access_token = AccessToken.objects.filter(token=raw_token).first()
 | 
					        access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first()
 | 
				
			||||||
        if access_token:
 | 
					        if access_token:
 | 
				
			||||||
            return TokenIntrospectionParams(access_token, provider)
 | 
					            return TokenIntrospectionParams(access_token, provider)
 | 
				
			||||||
        refresh_token = RefreshToken.objects.filter(token=raw_token).first()
 | 
					        refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first()
 | 
				
			||||||
        if refresh_token:
 | 
					        if refresh_token:
 | 
				
			||||||
            return TokenIntrospectionParams(refresh_token, provider)
 | 
					            return TokenIntrospectionParams(refresh_token, provider)
 | 
				
			||||||
        LOGGER.debug("Token does not exist", token=raw_token)
 | 
					        LOGGER.debug("Token does not exist", token=raw_token)
 | 
				
			||||||
 | 
				
			|||||||
@ -439,15 +439,14 @@ class TokenParams:
 | 
				
			|||||||
                # (22 chars being the length of the "template")
 | 
					                # (22 chars being the length of the "template")
 | 
				
			||||||
                username=f"ak-{self.provider.name[:150-22]}-client_credentials",
 | 
					                username=f"ak-{self.provider.name[:150-22]}-client_credentials",
 | 
				
			||||||
                defaults={
 | 
					                defaults={
 | 
				
			||||||
                    "attributes": {
 | 
					 | 
				
			||||||
                        USER_ATTRIBUTE_GENERATED: True,
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    "last_login": timezone.now(),
 | 
					                    "last_login": timezone.now(),
 | 
				
			||||||
                    "name": f"Autogenerated user from application {app.name} (client credentials)",
 | 
					                    "name": f"Autogenerated user from application {app.name} (client credentials)",
 | 
				
			||||||
                    "path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
 | 
					                    "path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
 | 
				
			||||||
                    "type": UserTypes.SERVICE_ACCOUNT,
 | 
					                    "type": UserTypes.SERVICE_ACCOUNT,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
 | 
				
			||||||
 | 
					            self.user.save()
 | 
				
			||||||
        self.__check_policy_access(app, request)
 | 
					        self.__check_policy_access(app, request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Event.new(
 | 
					        Event.new(
 | 
				
			||||||
@ -471,9 +470,6 @@ class TokenParams:
 | 
				
			|||||||
            self.user, created = User.objects.update_or_create(
 | 
					            self.user, created = User.objects.update_or_create(
 | 
				
			||||||
                username=f"{self.provider.name}-{token.get('sub')}",
 | 
					                username=f"{self.provider.name}-{token.get('sub')}",
 | 
				
			||||||
                defaults={
 | 
					                defaults={
 | 
				
			||||||
                    "attributes": {
 | 
					 | 
				
			||||||
                        USER_ATTRIBUTE_GENERATED: True,
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    "last_login": timezone.now(),
 | 
					                    "last_login": timezone.now(),
 | 
				
			||||||
                    "name": (
 | 
					                    "name": (
 | 
				
			||||||
                        f"Autogenerated user from application {app.name} (client credentials JWT)"
 | 
					                        f"Autogenerated user from application {app.name} (client credentials JWT)"
 | 
				
			||||||
@ -482,6 +478,8 @@ class TokenParams:
 | 
				
			|||||||
                    "type": UserTypes.SERVICE_ACCOUNT,
 | 
					                    "type": UserTypes.SERVICE_ACCOUNT,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
 | 
				
			||||||
 | 
					            self.user.save()
 | 
				
			||||||
            exp = token.get("exp")
 | 
					            exp = token.get("exp")
 | 
				
			||||||
            if created and exp:
 | 
					            if created and exp:
 | 
				
			||||||
                self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
 | 
					                self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
 | 
				
			||||||
 | 
				
			|||||||
@ -28,7 +28,7 @@ class ProxyDockerController(DockerController):
 | 
				
			|||||||
        labels = super()._get_labels()
 | 
					        labels = super()._get_labels()
 | 
				
			||||||
        labels["traefik.enable"] = "true"
 | 
					        labels["traefik.enable"] = "true"
 | 
				
			||||||
        labels[f"traefik.http.routers.{traefik_name}-router.rule"] = (
 | 
					        labels[f"traefik.http.routers.{traefik_name}-router.rule"] = (
 | 
				
			||||||
            f"({' || '.join([f'Host(`{host}`)' for host in hosts])})"
 | 
					            f"({' || '.join([f'Host({host})' for host in hosts])})"
 | 
				
			||||||
            f" && PathPrefix(`/outpost.goauthentik.io`)"
 | 
					            f" && PathPrefix(`/outpost.goauthentik.io`)"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"
 | 
					        labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"
 | 
				
			||||||
 | 
				
			|||||||
@ -164,7 +164,7 @@ class SAMLProvider(Provider):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sign_assertion = models.BooleanField(default=True)
 | 
					    sign_assertion = models.BooleanField(default=True)
 | 
				
			||||||
    sign_response = models.BooleanField(default=True)
 | 
					    sign_response = models.BooleanField(default=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def launch_url(self) -> str | None:
 | 
					    def launch_url(self) -> str | None:
 | 
				
			||||||
 | 
				
			|||||||
@ -50,6 +50,7 @@ class AssertionProcessor:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    _issue_instant: str
 | 
					    _issue_instant: str
 | 
				
			||||||
    _assertion_id: str
 | 
					    _assertion_id: str
 | 
				
			||||||
 | 
					    _response_id: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _valid_not_before: str
 | 
					    _valid_not_before: str
 | 
				
			||||||
    _session_not_on_or_after: str
 | 
					    _session_not_on_or_after: str
 | 
				
			||||||
@ -62,6 +63,7 @@ class AssertionProcessor:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self._issue_instant = get_time_string()
 | 
					        self._issue_instant = get_time_string()
 | 
				
			||||||
        self._assertion_id = get_random_id()
 | 
					        self._assertion_id = get_random_id()
 | 
				
			||||||
 | 
					        self._response_id = get_random_id()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self._valid_not_before = get_time_string(
 | 
					        self._valid_not_before = get_time_string(
 | 
				
			||||||
            timedelta_from_string(self.provider.assertion_valid_not_before)
 | 
					            timedelta_from_string(self.provider.assertion_valid_not_before)
 | 
				
			||||||
@ -130,7 +132,9 @@ class AssertionProcessor:
 | 
				
			|||||||
        """Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
 | 
					        """Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
 | 
				
			||||||
        auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
 | 
					        auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
 | 
				
			||||||
        auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
 | 
					        auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
 | 
				
			||||||
        auth_n_statement.attrib["SessionIndex"] = self._assertion_id
 | 
					        auth_n_statement.attrib["SessionIndex"] = sha256(
 | 
				
			||||||
 | 
					            self.http_request.session.session_key.encode("ascii")
 | 
				
			||||||
 | 
					        ).hexdigest()
 | 
				
			||||||
        auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
 | 
					        auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
 | 
					        auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
 | 
				
			||||||
@ -285,7 +289,7 @@ class AssertionProcessor:
 | 
				
			|||||||
        response.attrib["Version"] = "2.0"
 | 
					        response.attrib["Version"] = "2.0"
 | 
				
			||||||
        response.attrib["IssueInstant"] = self._issue_instant
 | 
					        response.attrib["IssueInstant"] = self._issue_instant
 | 
				
			||||||
        response.attrib["Destination"] = self.provider.acs_url
 | 
					        response.attrib["Destination"] = self.provider.acs_url
 | 
				
			||||||
        response.attrib["ID"] = get_random_id()
 | 
					        response.attrib["ID"] = self._response_id
 | 
				
			||||||
        if self.auth_n_request.id:
 | 
					        if self.auth_n_request.id:
 | 
				
			||||||
            response.attrib["InResponseTo"] = self.auth_n_request.id
 | 
					            response.attrib["InResponseTo"] = self.auth_n_request.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -308,7 +312,7 @@ class AssertionProcessor:
 | 
				
			|||||||
        ref = xmlsec.template.add_reference(
 | 
					        ref = xmlsec.template.add_reference(
 | 
				
			||||||
            signature_node,
 | 
					            signature_node,
 | 
				
			||||||
            digest_algorithm_transform,
 | 
					            digest_algorithm_transform,
 | 
				
			||||||
            uri="#" + self._assertion_id,
 | 
					            uri="#" + element.attrib["ID"],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
 | 
					        xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
 | 
				
			||||||
        xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
 | 
					        xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
 | 
				
			||||||
 | 
				
			|||||||
@ -180,6 +180,10 @@ class TestAuthNRequest(TestCase):
 | 
				
			|||||||
        # Now create a response and convert it to string (provider)
 | 
					        # Now create a response and convert it to string (provider)
 | 
				
			||||||
        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
 | 
					        response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
 | 
				
			||||||
        response = response_proc.build_response()
 | 
					        response = response_proc.build_response()
 | 
				
			||||||
 | 
					        # Ensure both response and assertion ID are in the response twice (once as ID attribute,
 | 
				
			||||||
 | 
					        # once as ds:Reference URI)
 | 
				
			||||||
 | 
					        self.assertEqual(response.count(response_proc._assertion_id), 2)
 | 
				
			||||||
 | 
					        self.assertEqual(response.count(response_proc._response_id), 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Now parse the response (source)
 | 
					        # Now parse the response (source)
 | 
				
			||||||
        http_request.POST = QueryDict(mutable=True)
 | 
					        http_request.POST = QueryDict(mutable=True)
 | 
				
			||||||
 | 
				
			|||||||
@ -2,9 +2,10 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from itertools import batched
 | 
					from itertools import batched
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import transaction
 | 
				
			||||||
from pydantic import ValidationError
 | 
					from pydantic import ValidationError
 | 
				
			||||||
from pydanticscim.group import GroupMember
 | 
					from pydanticscim.group import GroupMember
 | 
				
			||||||
from pydanticscim.responses import PatchOp, PatchOperation
 | 
					from pydanticscim.responses import PatchOp
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Group
 | 
					from authentik.core.models import Group
 | 
				
			||||||
from authentik.lib.sync.mapper import PropertyMappingManager
 | 
					from authentik.lib.sync.mapper import PropertyMappingManager
 | 
				
			||||||
@ -19,7 +20,7 @@ from authentik.providers.scim.clients.base import SCIMClient
 | 
				
			|||||||
from authentik.providers.scim.clients.exceptions import (
 | 
					from authentik.providers.scim.clients.exceptions import (
 | 
				
			||||||
    SCIMRequestException,
 | 
					    SCIMRequestException,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchRequest
 | 
					from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
 | 
				
			||||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
 | 
					from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
 | 
				
			||||||
from authentik.providers.scim.models import (
 | 
					from authentik.providers.scim.models import (
 | 
				
			||||||
    SCIMMapping,
 | 
					    SCIMMapping,
 | 
				
			||||||
@ -104,13 +105,47 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
            provider=self.provider, group=group, scim_id=scim_id
 | 
					            provider=self.provider, group=group, scim_id=scim_id
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        users = list(group.users.order_by("id").values_list("id", flat=True))
 | 
					        users = list(group.users.order_by("id").values_list("id", flat=True))
 | 
				
			||||||
        self._patch_add_users(group, users)
 | 
					        self._patch_add_users(connection, users)
 | 
				
			||||||
        return connection
 | 
					        return connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update(self, group: Group, connection: SCIMProviderGroup):
 | 
					    def update(self, group: Group, connection: SCIMProviderGroup):
 | 
				
			||||||
        """Update existing group"""
 | 
					        """Update existing group"""
 | 
				
			||||||
        scim_group = self.to_schema(group, connection)
 | 
					        scim_group = self.to_schema(group, connection)
 | 
				
			||||||
        scim_group.id = connection.scim_id
 | 
					        scim_group.id = connection.scim_id
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if self._config.patch.supported:
 | 
				
			||||||
 | 
					                return self._update_patch(group, scim_group, connection)
 | 
				
			||||||
 | 
					            return self._update_put(group, scim_group, connection)
 | 
				
			||||||
 | 
					        except NotFoundSyncException:
 | 
				
			||||||
 | 
					            # Resource missing is handled by self.write, which will re-create the group
 | 
				
			||||||
 | 
					            raise
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _update_patch(
 | 
				
			||||||
 | 
					        self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Update a group via PATCH request"""
 | 
				
			||||||
 | 
					        # Patch group's attributes instead of replacing it and re-adding users if we can
 | 
				
			||||||
 | 
					        self._request(
 | 
				
			||||||
 | 
					            "PATCH",
 | 
				
			||||||
 | 
					            f"/Groups/{connection.scim_id}",
 | 
				
			||||||
 | 
					            json=PatchRequest(
 | 
				
			||||||
 | 
					                Operations=[
 | 
				
			||||||
 | 
					                    PatchOperation(
 | 
				
			||||||
 | 
					                        op=PatchOp.replace,
 | 
				
			||||||
 | 
					                        path=None,
 | 
				
			||||||
 | 
					                        value=scim_group.model_dump(mode="json", exclude_unset=True),
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ).model_dump(
 | 
				
			||||||
 | 
					                mode="json",
 | 
				
			||||||
 | 
					                exclude_unset=True,
 | 
				
			||||||
 | 
					                exclude_none=True,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return self.patch_compare_users(group)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup):
 | 
				
			||||||
 | 
					        """Update a group via PUT request"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            self._request(
 | 
					            self._request(
 | 
				
			||||||
                "PUT",
 | 
					                "PUT",
 | 
				
			||||||
@ -120,33 +155,25 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
                    exclude_unset=True,
 | 
					                    exclude_unset=True,
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            users = list(group.users.order_by("id").values_list("id", flat=True))
 | 
					            return self.patch_compare_users(group)
 | 
				
			||||||
            return self._patch_add_users(group, users)
 | 
					 | 
				
			||||||
        except NotFoundSyncException:
 | 
					 | 
				
			||||||
            # Resource missing is handled by self.write, which will re-create the group
 | 
					 | 
				
			||||||
            raise
 | 
					 | 
				
			||||||
        except (SCIMRequestException, ObjectExistsSyncException):
 | 
					        except (SCIMRequestException, ObjectExistsSyncException):
 | 
				
			||||||
            # Some providers don't support PUT on groups, so this is mainly a fix for the initial
 | 
					            # Some providers don't support PUT on groups, so this is mainly a fix for the initial
 | 
				
			||||||
            # sync, send patch add requests for all the users the group currently has
 | 
					            # sync, send patch add requests for all the users the group currently has
 | 
				
			||||||
            users = list(group.users.order_by("id").values_list("id", flat=True))
 | 
					            return self._update_patch(group, scim_group, connection)
 | 
				
			||||||
            self._patch_add_users(group, users)
 | 
					 | 
				
			||||||
            # Also update the group name
 | 
					 | 
				
			||||||
            return self._patch(
 | 
					 | 
				
			||||||
                scim_group.id,
 | 
					 | 
				
			||||||
                PatchOperation(
 | 
					 | 
				
			||||||
                    op=PatchOp.replace,
 | 
					 | 
				
			||||||
                    path="displayName",
 | 
					 | 
				
			||||||
                    value=scim_group.displayName,
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update_group(self, group: Group, action: Direction, users_set: set[int]):
 | 
					    def update_group(self, group: Group, action: Direction, users_set: set[int]):
 | 
				
			||||||
        """Update a group, either using PUT to replace it or PATCH if supported"""
 | 
					        """Update a group, either using PUT to replace it or PATCH if supported"""
 | 
				
			||||||
 | 
					        scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
 | 
				
			||||||
 | 
					        if not scim_group:
 | 
				
			||||||
 | 
					            self.logger.warning(
 | 
				
			||||||
 | 
					                "could not sync group membership, group does not exist", group=group
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        if self._config.patch.supported:
 | 
					        if self._config.patch.supported:
 | 
				
			||||||
            if action == Direction.add:
 | 
					            if action == Direction.add:
 | 
				
			||||||
                return self._patch_add_users(group, users_set)
 | 
					                return self._patch_add_users(scim_group, users_set)
 | 
				
			||||||
            if action == Direction.remove:
 | 
					            if action == Direction.remove:
 | 
				
			||||||
                return self._patch_remove_users(group, users_set)
 | 
					                return self._patch_remove_users(scim_group, users_set)
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            return self.write(group)
 | 
					            return self.write(group)
 | 
				
			||||||
        except SCIMRequestException as exc:
 | 
					        except SCIMRequestException as exc:
 | 
				
			||||||
@ -154,19 +181,24 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
                # Assume that provider does not support PUT and also doesn't support
 | 
					                # Assume that provider does not support PUT and also doesn't support
 | 
				
			||||||
                # ServiceProviderConfig, so try PATCH as a fallback
 | 
					                # ServiceProviderConfig, so try PATCH as a fallback
 | 
				
			||||||
                if action == Direction.add:
 | 
					                if action == Direction.add:
 | 
				
			||||||
                    return self._patch_add_users(group, users_set)
 | 
					                    return self._patch_add_users(scim_group, users_set)
 | 
				
			||||||
                if action == Direction.remove:
 | 
					                if action == Direction.remove:
 | 
				
			||||||
                    return self._patch_remove_users(group, users_set)
 | 
					                    return self._patch_remove_users(scim_group, users_set)
 | 
				
			||||||
            raise exc
 | 
					            raise exc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _patch(
 | 
					    def _patch_chunked(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
        group_id: str,
 | 
					        group_id: str,
 | 
				
			||||||
        *ops: PatchOperation,
 | 
					        *ops: PatchOperation,
 | 
				
			||||||
    ):
 | 
					    ):
 | 
				
			||||||
 | 
					        """Helper function that chunks patch requests based on the maxOperations attribute.
 | 
				
			||||||
 | 
					        This is not strictly according to specs but there's nothing in the schema that allows the
 | 
				
			||||||
 | 
					        us to know what the maximum patch operations per request should be."""
 | 
				
			||||||
        chunk_size = self._config.bulk.maxOperations
 | 
					        chunk_size = self._config.bulk.maxOperations
 | 
				
			||||||
        if chunk_size < 1:
 | 
					        if chunk_size < 1:
 | 
				
			||||||
            chunk_size = len(ops)
 | 
					            chunk_size = len(ops)
 | 
				
			||||||
 | 
					        if len(ops) < 1:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        for chunk in batched(ops, chunk_size):
 | 
					        for chunk in batched(ops, chunk_size):
 | 
				
			||||||
            req = PatchRequest(Operations=list(chunk))
 | 
					            req = PatchRequest(Operations=list(chunk))
 | 
				
			||||||
            self._request(
 | 
					            self._request(
 | 
				
			||||||
@ -177,16 +209,70 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _patch_add_users(self, group: Group, users_set: set[int]):
 | 
					    @transaction.atomic
 | 
				
			||||||
        """Add users in users_set to group"""
 | 
					    def patch_compare_users(self, group: Group):
 | 
				
			||||||
        if len(users_set) < 1:
 | 
					        """Compare users with a SCIM group and add/remove any differences"""
 | 
				
			||||||
            return
 | 
					        # Get scim group first
 | 
				
			||||||
        scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
 | 
					        scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
 | 
				
			||||||
        if not scim_group:
 | 
					        if not scim_group:
 | 
				
			||||||
            self.logger.warning(
 | 
					            self.logger.warning(
 | 
				
			||||||
                "could not sync group membership, group does not exist", group=group
 | 
					                "could not sync group membership, group does not exist", group=group
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					        # Get a list of all users in the authentik group
 | 
				
			||||||
 | 
					        raw_users_should = list(group.users.order_by("id").values_list("id", flat=True))
 | 
				
			||||||
 | 
					        # Lookup the SCIM IDs of the users
 | 
				
			||||||
 | 
					        users_should: list[str] = list(
 | 
				
			||||||
 | 
					            SCIMProviderUser.objects.filter(
 | 
				
			||||||
 | 
					                user__pk__in=raw_users_should, provider=self.provider
 | 
				
			||||||
 | 
					            ).values_list("scim_id", flat=True)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if len(raw_users_should) != len(users_should):
 | 
				
			||||||
 | 
					            self.logger.warning(
 | 
				
			||||||
 | 
					                "User count mismatch, not all users in the group are synced to SCIM yet.",
 | 
				
			||||||
 | 
					                group=group,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        # Get current group status
 | 
				
			||||||
 | 
					        current_group = SCIMGroupSchema.model_validate(
 | 
				
			||||||
 | 
					            self._request("GET", f"/Groups/{scim_group.scim_id}")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        users_to_add = []
 | 
				
			||||||
 | 
					        users_to_remove = []
 | 
				
			||||||
 | 
					        # Check users currently in group and if they shouldn't be in the group and remove them
 | 
				
			||||||
 | 
					        for user in current_group.members or []:
 | 
				
			||||||
 | 
					            if user.value not in users_should:
 | 
				
			||||||
 | 
					                users_to_remove.append(user.value)
 | 
				
			||||||
 | 
					        # Check users that should be in the group and add them
 | 
				
			||||||
 | 
					        for user in users_should:
 | 
				
			||||||
 | 
					            if len([x for x in current_group.members if x.value == user]) < 1:
 | 
				
			||||||
 | 
					                users_to_add.append(user)
 | 
				
			||||||
 | 
					        # Only send request if we need to make changes
 | 
				
			||||||
 | 
					        if len(users_to_add) < 1 and len(users_to_remove) < 1:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        return self._patch_chunked(
 | 
				
			||||||
 | 
					            scim_group.scim_id,
 | 
				
			||||||
 | 
					            *[
 | 
				
			||||||
 | 
					                PatchOperation(
 | 
				
			||||||
 | 
					                    op=PatchOp.add,
 | 
				
			||||||
 | 
					                    path="members",
 | 
				
			||||||
 | 
					                    value=[{"value": x}],
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                for x in users_to_add
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            *[
 | 
				
			||||||
 | 
					                PatchOperation(
 | 
				
			||||||
 | 
					                    op=PatchOp.remove,
 | 
				
			||||||
 | 
					                    path="members",
 | 
				
			||||||
 | 
					                    value=[{"value": x}],
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                for x in users_to_remove
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
 | 
				
			||||||
 | 
					        """Add users in users_set to group"""
 | 
				
			||||||
 | 
					        if len(users_set) < 1:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        user_ids = list(
 | 
					        user_ids = list(
 | 
				
			||||||
            SCIMProviderUser.objects.filter(
 | 
					            SCIMProviderUser.objects.filter(
 | 
				
			||||||
                user__pk__in=users_set, provider=self.provider
 | 
					                user__pk__in=users_set, provider=self.provider
 | 
				
			||||||
@ -194,7 +280,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        if len(user_ids) < 1:
 | 
					        if len(user_ids) < 1:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        self._patch(
 | 
					        self._patch_chunked(
 | 
				
			||||||
            scim_group.scim_id,
 | 
					            scim_group.scim_id,
 | 
				
			||||||
            *[
 | 
					            *[
 | 
				
			||||||
                PatchOperation(
 | 
					                PatchOperation(
 | 
				
			||||||
@ -206,16 +292,10 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
            ],
 | 
					            ],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _patch_remove_users(self, group: Group, users_set: set[int]):
 | 
					    def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
 | 
				
			||||||
        """Remove users in users_set from group"""
 | 
					        """Remove users in users_set from group"""
 | 
				
			||||||
        if len(users_set) < 1:
 | 
					        if len(users_set) < 1:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
 | 
					 | 
				
			||||||
        if not scim_group:
 | 
					 | 
				
			||||||
            self.logger.warning(
 | 
					 | 
				
			||||||
                "could not sync group membership, group does not exist", group=group
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        user_ids = list(
 | 
					        user_ids = list(
 | 
				
			||||||
            SCIMProviderUser.objects.filter(
 | 
					            SCIMProviderUser.objects.filter(
 | 
				
			||||||
                user__pk__in=users_set, provider=self.provider
 | 
					                user__pk__in=users_set, provider=self.provider
 | 
				
			||||||
@ -223,7 +303,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        if len(user_ids) < 1:
 | 
					        if len(user_ids) < 1:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        self._patch(
 | 
					        self._patch_chunked(
 | 
				
			||||||
            scim_group.scim_id,
 | 
					            scim_group.scim_id,
 | 
				
			||||||
            *[
 | 
					            *[
 | 
				
			||||||
                PatchOperation(
 | 
					                PatchOperation(
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from pydantic import Field
 | 
					from pydantic import Field
 | 
				
			||||||
from pydanticscim.group import Group as BaseGroup
 | 
					from pydanticscim.group import Group as BaseGroup
 | 
				
			||||||
 | 
					from pydanticscim.responses import PatchOperation as BasePatchOperation
 | 
				
			||||||
from pydanticscim.responses import PatchRequest as BasePatchRequest
 | 
					from pydanticscim.responses import PatchRequest as BasePatchRequest
 | 
				
			||||||
from pydanticscim.responses import SCIMError as BaseSCIMError
 | 
					from pydanticscim.responses import SCIMError as BaseSCIMError
 | 
				
			||||||
from pydanticscim.service_provider import Bulk as BaseBulk
 | 
					from pydanticscim.service_provider import Bulk as BaseBulk
 | 
				
			||||||
@ -68,6 +69,12 @@ class PatchRequest(BasePatchRequest):
 | 
				
			|||||||
    schemas: tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:PatchOp",)
 | 
					    schemas: tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:PatchOp",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PatchOperation(BasePatchOperation):
 | 
				
			||||||
 | 
					    """PatchOperation with optional path"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    path: str | None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SCIMError(BaseSCIMError):
 | 
					class SCIMError(BaseSCIMError):
 | 
				
			||||||
    """SCIM error with optional status code"""
 | 
					    """SCIM error with optional status code"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -252,3 +252,118 @@ class SCIMMembershipTests(TestCase):
 | 
				
			|||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_member_add_save(self):
 | 
				
			||||||
 | 
					        """Test member add + save"""
 | 
				
			||||||
 | 
					        config = ServiceProviderConfiguration.default()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        config.patch.supported = True
 | 
				
			||||||
 | 
					        user_scim_id = generate_id()
 | 
				
			||||||
 | 
					        group_scim_id = generate_id()
 | 
				
			||||||
 | 
					        uid = generate_id()
 | 
				
			||||||
 | 
					        group = Group.objects.create(
 | 
				
			||||||
 | 
					            name=uid,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        user = User.objects.create(username=generate_id())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test initial sync of group creation
 | 
				
			||||||
 | 
					        with Mocker() as mocker:
 | 
				
			||||||
 | 
					            mocker.get(
 | 
				
			||||||
 | 
					                "https://localhost/ServiceProviderConfig",
 | 
				
			||||||
 | 
					                json=config.model_dump(),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            mocker.post(
 | 
				
			||||||
 | 
					                "https://localhost/Users",
 | 
				
			||||||
 | 
					                json={
 | 
				
			||||||
 | 
					                    "id": user_scim_id,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            mocker.post(
 | 
				
			||||||
 | 
					                "https://localhost/Groups",
 | 
				
			||||||
 | 
					                json={
 | 
				
			||||||
 | 
					                    "id": group_scim_id,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.configure()
 | 
				
			||||||
 | 
					            sync_tasks.trigger_single_task(self.provider, scim_sync).get()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.call_count, 6)
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[0].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[1].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[2].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[3].method, "POST")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[4].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[5].method, "POST")
 | 
				
			||||||
 | 
					            self.assertJSONEqual(
 | 
				
			||||||
 | 
					                mocker.request_history[3].body,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
 | 
				
			||||||
 | 
					                    "emails": [],
 | 
				
			||||||
 | 
					                    "active": True,
 | 
				
			||||||
 | 
					                    "externalId": user.uid,
 | 
				
			||||||
 | 
					                    "name": {"familyName": " ", "formatted": " ", "givenName": ""},
 | 
				
			||||||
 | 
					                    "displayName": "",
 | 
				
			||||||
 | 
					                    "userName": user.username,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.assertJSONEqual(
 | 
				
			||||||
 | 
					                mocker.request_history[5].body,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
 | 
				
			||||||
 | 
					                    "externalId": str(group.pk),
 | 
				
			||||||
 | 
					                    "displayName": group.name,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with Mocker() as mocker:
 | 
				
			||||||
 | 
					            mocker.get(
 | 
				
			||||||
 | 
					                "https://localhost/ServiceProviderConfig",
 | 
				
			||||||
 | 
					                json=config.model_dump(),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            mocker.get(
 | 
				
			||||||
 | 
					                f"https://localhost/Groups/{group_scim_id}",
 | 
				
			||||||
 | 
					                json={},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            mocker.patch(
 | 
				
			||||||
 | 
					                f"https://localhost/Groups/{group_scim_id}",
 | 
				
			||||||
 | 
					                json={},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            group.users.add(user)
 | 
				
			||||||
 | 
					            group.save()
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.call_count, 5)
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[0].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[1].method, "PATCH")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[2].method, "GET")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[3].method, "PATCH")
 | 
				
			||||||
 | 
					            self.assertEqual(mocker.request_history[4].method, "GET")
 | 
				
			||||||
 | 
					            self.assertJSONEqual(
 | 
				
			||||||
 | 
					                mocker.request_history[1].body,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
 | 
				
			||||||
 | 
					                    "Operations": [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            "op": "add",
 | 
				
			||||||
 | 
					                            "path": "members",
 | 
				
			||||||
 | 
					                            "value": [{"value": user_scim_id}],
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.assertJSONEqual(
 | 
				
			||||||
 | 
					                mocker.request_history[3].body,
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "Operations": [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            "op": "replace",
 | 
				
			||||||
 | 
					                            "value": {
 | 
				
			||||||
 | 
					                                "id": group_scim_id,
 | 
				
			||||||
 | 
					                                "displayName": group.name,
 | 
				
			||||||
 | 
					                                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
 | 
				
			||||||
 | 
					                                "externalId": str(group.pk),
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    ]
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
				
			|||||||
@ -87,7 +87,11 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
def _get_startup_tasks_default_tenant() -> list[Callable]:
 | 
					def _get_startup_tasks_default_tenant() -> list[Callable]:
 | 
				
			||||||
    """Get all tasks to be run on startup for the default tenant"""
 | 
					    """Get all tasks to be run on startup for the default tenant"""
 | 
				
			||||||
    return []
 | 
					    from authentik.outposts.tasks import outpost_connection_discovery
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					        outpost_connection_discovery,
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _get_startup_tasks_all_tenants() -> list[Callable]:
 | 
					def _get_startup_tasks_all_tenants() -> list[Callable]:
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from collections.abc import Callable
 | 
					from collections.abc import Callable
 | 
				
			||||||
from hashlib import sha512
 | 
					from hashlib import sha512
 | 
				
			||||||
 | 
					from ipaddress import ip_address
 | 
				
			||||||
from time import perf_counter, time
 | 
					from time import perf_counter, time
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -174,6 +175,7 @@ class ClientIPMiddleware:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
 | 
					    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
 | 
				
			||||||
        self.get_response = get_response
 | 
					        self.get_response = get_response
 | 
				
			||||||
 | 
					        self.logger = get_logger().bind()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str:
 | 
					    def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str:
 | 
				
			||||||
        """Attempt to get the client's IP by checking common HTTP Headers.
 | 
					        """Attempt to get the client's IP by checking common HTTP Headers.
 | 
				
			||||||
@ -185,11 +187,16 @@ class ClientIPMiddleware:
 | 
				
			|||||||
            "HTTP_X_FORWARDED_FOR",
 | 
					            "HTTP_X_FORWARDED_FOR",
 | 
				
			||||||
            "REMOTE_ADDR",
 | 
					            "REMOTE_ADDR",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        for _header in headers:
 | 
					        try:
 | 
				
			||||||
            if _header in meta:
 | 
					            for _header in headers:
 | 
				
			||||||
                ips: list[str] = meta.get(_header).split(",")
 | 
					                if _header in meta:
 | 
				
			||||||
                return ips[0].strip()
 | 
					                    ips: list[str] = meta.get(_header).split(",")
 | 
				
			||||||
        return self.default_ip
 | 
					                    # Ensure the IP parses as a valid IP
 | 
				
			||||||
 | 
					                    return str(ip_address(ips[0].strip()))
 | 
				
			||||||
 | 
					            return self.default_ip
 | 
				
			||||||
 | 
					        except ValueError as exc:
 | 
				
			||||||
 | 
					            self.logger.debug("Invalid remote IP", exc=exc)
 | 
				
			||||||
 | 
					            return self.default_ip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # FIXME: this should probably not be in `root` but rather in a middleware in `outposts`
 | 
					    # FIXME: this should probably not be in `root` but rather in a middleware in `outposts`
 | 
				
			||||||
    # but for now it's fine
 | 
					    # but for now it's fine
 | 
				
			||||||
@ -226,7 +233,11 @@ class ClientIPMiddleware:
 | 
				
			|||||||
        Scope.get_isolation_scope().set_user(user)
 | 
					        Scope.get_isolation_scope().set_user(user)
 | 
				
			||||||
        # Set the outpost service account on the request
 | 
					        # Set the outpost service account on the request
 | 
				
			||||||
        setattr(request, self.request_attr_outpost_user, user)
 | 
					        setattr(request, self.request_attr_outpost_user, user)
 | 
				
			||||||
        return delegated_ip
 | 
					        try:
 | 
				
			||||||
 | 
					            return str(ip_address(delegated_ip))
 | 
				
			||||||
 | 
					        except ValueError as exc:
 | 
				
			||||||
 | 
					            self.logger.debug("Invalid remote IP from Outpost", exc=exc)
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_client_ip(self, request: HttpRequest | None) -> str:
 | 
					    def _get_client_ip(self, request: HttpRequest | None) -> str:
 | 
				
			||||||
        """Attempt to get the client's IP by checking common HTTP Headers.
 | 
					        """Attempt to get the client's IP by checking common HTTP Headers.
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
"""authentik storage backends"""
 | 
					"""authentik storage backends"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					from urllib.parse import parse_qsl, urlsplit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import SuspiciousOperation
 | 
					from django.core.exceptions import SuspiciousOperation
 | 
				
			||||||
@ -110,3 +111,34 @@ class S3Storage(BaseS3Storage):
 | 
				
			|||||||
        if self.querystring_auth:
 | 
					        if self.querystring_auth:
 | 
				
			||||||
            return url
 | 
					            return url
 | 
				
			||||||
        return self._strip_signing_parameters(url)
 | 
					        return self._strip_signing_parameters(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _strip_signing_parameters(self, url):
 | 
				
			||||||
 | 
					        # Boto3 does not currently support generating URLs that are unsigned. Instead
 | 
				
			||||||
 | 
					        # we take the signed URLs and strip any querystring params related to signing
 | 
				
			||||||
 | 
					        # and expiration.
 | 
				
			||||||
 | 
					        # Note that this may end up with URLs that are still invalid, especially if
 | 
				
			||||||
 | 
					        # params are passed in that only work with signed URLs, e.g. response header
 | 
				
			||||||
 | 
					        # params.
 | 
				
			||||||
 | 
					        # The code attempts to strip all query parameters that match names of known
 | 
				
			||||||
 | 
					        # parameters from v2 and v4 signatures, regardless of the actual signature
 | 
				
			||||||
 | 
					        # version used.
 | 
				
			||||||
 | 
					        split_url = urlsplit(url)
 | 
				
			||||||
 | 
					        qs = parse_qsl(split_url.query, keep_blank_values=True)
 | 
				
			||||||
 | 
					        blacklist = {
 | 
				
			||||||
 | 
					            "x-amz-algorithm",
 | 
				
			||||||
 | 
					            "x-amz-credential",
 | 
				
			||||||
 | 
					            "x-amz-date",
 | 
				
			||||||
 | 
					            "x-amz-expires",
 | 
				
			||||||
 | 
					            "x-amz-signedheaders",
 | 
				
			||||||
 | 
					            "x-amz-signature",
 | 
				
			||||||
 | 
					            "x-amz-security-token",
 | 
				
			||||||
 | 
					            "awsaccesskeyid",
 | 
				
			||||||
 | 
					            "expires",
 | 
				
			||||||
 | 
					            "signature",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        filtered_qs = ((key, val) for key, val in qs if key.lower() not in blacklist)
 | 
				
			||||||
 | 
					        # Note: Parameters that did not have a value in the original query string will
 | 
				
			||||||
 | 
					        # have an '=' sign appended to it, e.g ?foo&bar becomes ?foo=&bar=
 | 
				
			||||||
 | 
					        joined_qs = ("=".join(keyval) for keyval in filtered_qs)
 | 
				
			||||||
 | 
					        split_url = split_url._replace(query="&".join(joined_qs))
 | 
				
			||||||
 | 
					        return split_url.geturl()
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,7 @@
 | 
				
			|||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_spectacular.utils import extend_schema, inline_serializer
 | 
					from drf_spectacular.utils import extend_schema, inline_serializer
 | 
				
			||||||
from guardian.shortcuts import get_objects_for_user
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
@ -39,9 +40,8 @@ class LDAPSourceSerializer(SourceSerializer):
 | 
				
			|||||||
        """Get cached source connectivity"""
 | 
					        """Get cached source connectivity"""
 | 
				
			||||||
        return cache.get(CACHE_KEY_STATUS + source.slug, None)
 | 
					        return cache.get(CACHE_KEY_STATUS + source.slug, None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
 | 
					    def validate_sync_users_password(self, sync_users_password: bool) -> bool:
 | 
				
			||||||
        """Check that only a single source has password_sync on"""
 | 
					        """Check that only a single source has password_sync on"""
 | 
				
			||||||
        sync_users_password = attrs.get("sync_users_password", True)
 | 
					 | 
				
			||||||
        if sync_users_password:
 | 
					        if sync_users_password:
 | 
				
			||||||
            sources = LDAPSource.objects.filter(sync_users_password=True)
 | 
					            sources = LDAPSource.objects.filter(sync_users_password=True)
 | 
				
			||||||
            if self.instance:
 | 
					            if self.instance:
 | 
				
			||||||
@ -49,11 +49,31 @@ class LDAPSourceSerializer(SourceSerializer):
 | 
				
			|||||||
            if sources.exists():
 | 
					            if sources.exists():
 | 
				
			||||||
                raise ValidationError(
 | 
					                raise ValidationError(
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        "sync_users_password": (
 | 
					                        "sync_users_password": _(
 | 
				
			||||||
                            "Only a single LDAP Source with password synchronization is allowed"
 | 
					                            "Only a single LDAP Source with password synchronization is allowed"
 | 
				
			||||||
                        )
 | 
					                        )
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					        return sync_users_password
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        """Validate property mappings with sync_ flags"""
 | 
				
			||||||
 | 
					        types = ["user", "group"]
 | 
				
			||||||
 | 
					        for type in types:
 | 
				
			||||||
 | 
					            toggle_value = attrs.get(f"sync_{type}s", False)
 | 
				
			||||||
 | 
					            mappings_field = f"{type}_property_mappings"
 | 
				
			||||||
 | 
					            mappings_value = attrs.get(mappings_field, [])
 | 
				
			||||||
 | 
					            if toggle_value and len(mappings_value) == 0:
 | 
				
			||||||
 | 
					                raise ValidationError(
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        mappings_field: _(
 | 
				
			||||||
 | 
					                            (
 | 
				
			||||||
 | 
					                                "When 'Sync {type}s' is enabled, '{type}s property "
 | 
				
			||||||
 | 
					                                "mappings' cannot be empty."
 | 
				
			||||||
 | 
					                            ).format(type=type)
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
        return super().validate(attrs)
 | 
					        return super().validate(attrs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
@ -166,11 +186,12 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
        for sync_class in SYNC_CLASSES:
 | 
					        for sync_class in SYNC_CLASSES:
 | 
				
			||||||
            class_name = sync_class.name()
 | 
					            class_name = sync_class.name()
 | 
				
			||||||
            all_objects.setdefault(class_name, [])
 | 
					            all_objects.setdefault(class_name, [])
 | 
				
			||||||
            for obj in sync_class(source).get_objects(size_limit=10):
 | 
					            for page in sync_class(source).get_objects(size_limit=10):
 | 
				
			||||||
                obj: dict
 | 
					                for obj in page:
 | 
				
			||||||
                obj.pop("raw_attributes", None)
 | 
					                    obj: dict
 | 
				
			||||||
                obj.pop("raw_dn", None)
 | 
					                    obj.pop("raw_attributes", None)
 | 
				
			||||||
                all_objects[class_name].append(obj)
 | 
					                    obj.pop("raw_dn", None)
 | 
				
			||||||
 | 
					                    all_objects[class_name].append(obj)
 | 
				
			||||||
        return Response(data=all_objects)
 | 
					        return Response(data=all_objects)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -26,17 +26,16 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
 | 
				
			|||||||
    """Ensure that source is synced on save (if enabled)"""
 | 
					    """Ensure that source is synced on save (if enabled)"""
 | 
				
			||||||
    if not instance.enabled:
 | 
					    if not instance.enabled:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					    ldap_connectivity_check.delay(instance.pk)
 | 
				
			||||||
    # Don't sync sources when they don't have any property mappings. This will only happen if:
 | 
					    # Don't sync sources when they don't have any property mappings. This will only happen if:
 | 
				
			||||||
    # - the user forgets to set them or
 | 
					    # - the user forgets to set them or
 | 
				
			||||||
    # - the source is newly created, this is the first save event
 | 
					    # - the source is newly created, this is the first save event
 | 
				
			||||||
    #   and the mappings are created with an m2m event
 | 
					    #   and the mappings are created with an m2m event
 | 
				
			||||||
    if (
 | 
					    if instance.sync_users and not instance.user_property_mappings.exists():
 | 
				
			||||||
        not instance.user_property_mappings.exists()
 | 
					        return
 | 
				
			||||||
        or not instance.group_property_mappings.exists()
 | 
					    if instance.sync_groups and not instance.group_property_mappings.exists():
 | 
				
			||||||
    ):
 | 
					 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    ldap_sync_single.delay(instance.pk)
 | 
					    ldap_sync_single.delay(instance.pk)
 | 
				
			||||||
    ldap_connectivity_check.delay(instance.pk)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(password_validate)
 | 
					@receiver(password_validate)
 | 
				
			||||||
 | 
				
			|||||||
@ -38,7 +38,11 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
				
			|||||||
            search_base=self.base_dn_groups,
 | 
					            search_base=self.base_dn_groups,
 | 
				
			||||||
            search_filter=self._source.group_object_filter,
 | 
					            search_filter=self._source.group_object_filter,
 | 
				
			||||||
            search_scope=SUBTREE,
 | 
					            search_scope=SUBTREE,
 | 
				
			||||||
            attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
 | 
					            attributes=[
 | 
				
			||||||
 | 
					                ALL_ATTRIBUTES,
 | 
				
			||||||
 | 
					                ALL_OPERATIONAL_ATTRIBUTES,
 | 
				
			||||||
 | 
					                self._source.object_uniqueness_field,
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
            **kwargs,
 | 
					            **kwargs,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -53,9 +57,9 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
				
			|||||||
                continue
 | 
					                continue
 | 
				
			||||||
            attributes = group.get("attributes", {})
 | 
					            attributes = group.get("attributes", {})
 | 
				
			||||||
            group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
 | 
					            group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
 | 
				
			||||||
            if self._source.object_uniqueness_field not in attributes:
 | 
					            if not attributes.get(self._source.object_uniqueness_field):
 | 
				
			||||||
                self.message(
 | 
					                self.message(
 | 
				
			||||||
                    f"Cannot find uniqueness field in attributes: '{group_dn}'",
 | 
					                    f"Uniqueness field not found/not set in attributes: '{group_dn}'",
 | 
				
			||||||
                    attributes=attributes.keys(),
 | 
					                    attributes=attributes.keys(),
 | 
				
			||||||
                    dn=group_dn,
 | 
					                    dn=group_dn,
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
				
			|||||||
@ -40,7 +40,11 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
				
			|||||||
            search_base=self.base_dn_users,
 | 
					            search_base=self.base_dn_users,
 | 
				
			||||||
            search_filter=self._source.user_object_filter,
 | 
					            search_filter=self._source.user_object_filter,
 | 
				
			||||||
            search_scope=SUBTREE,
 | 
					            search_scope=SUBTREE,
 | 
				
			||||||
            attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
 | 
					            attributes=[
 | 
				
			||||||
 | 
					                ALL_ATTRIBUTES,
 | 
				
			||||||
 | 
					                ALL_OPERATIONAL_ATTRIBUTES,
 | 
				
			||||||
 | 
					                self._source.object_uniqueness_field,
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
            **kwargs,
 | 
					            **kwargs,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -55,9 +59,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
				
			|||||||
                continue
 | 
					                continue
 | 
				
			||||||
            attributes = user.get("attributes", {})
 | 
					            attributes = user.get("attributes", {})
 | 
				
			||||||
            user_dn = flatten(user.get("entryDN", user.get("dn")))
 | 
					            user_dn = flatten(user.get("entryDN", user.get("dn")))
 | 
				
			||||||
            if self._source.object_uniqueness_field not in attributes:
 | 
					            if not attributes.get(self._source.object_uniqueness_field):
 | 
				
			||||||
                self.message(
 | 
					                self.message(
 | 
				
			||||||
                    f"Cannot find uniqueness field in attributes: '{user_dn}'",
 | 
					                    f"Uniqueness field not found/not set in attributes: '{user_dn}'",
 | 
				
			||||||
                    attributes=attributes.keys(),
 | 
					                    attributes=attributes.keys(),
 | 
				
			||||||
                    dn=user_dn,
 | 
					                    dn=user_dn,
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								authentik/sources/ldap/sync/vendor/ms_ad.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								authentik/sources/ldap/sync/vendor/ms_ad.py
									
									
									
									
										vendored
									
									
								
							@ -78,7 +78,9 @@ class MicrosoftActiveDirectory(BaseLDAPSynchronizer):
 | 
				
			|||||||
        #   /useraccountcontrol-manipulate-account-properties
 | 
					        #   /useraccountcontrol-manipulate-account-properties
 | 
				
			||||||
        uac_bit = attributes.get("userAccountControl", 512)
 | 
					        uac_bit = attributes.get("userAccountControl", 512)
 | 
				
			||||||
        uac = UserAccountControl(uac_bit)
 | 
					        uac = UserAccountControl(uac_bit)
 | 
				
			||||||
        is_active = UserAccountControl.ACCOUNTDISABLE not in uac
 | 
					        is_active = (
 | 
				
			||||||
 | 
					            UserAccountControl.ACCOUNTDISABLE not in uac and UserAccountControl.LOCKOUT not in uac
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        if is_active != user.is_active:
 | 
					        if is_active != user.is_active:
 | 
				
			||||||
            user.is_active = is_active
 | 
					            user.is_active = is_active
 | 
				
			||||||
            user.save()
 | 
					            user.save()
 | 
				
			||||||
 | 
				
			|||||||
@ -50,3 +50,35 @@ class LDAPAPITests(APITestCase):
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertFalse(serializer.is_valid())
 | 
					        self.assertFalse(serializer.is_valid())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_sync_users_mapping_empty(self):
 | 
				
			||||||
 | 
					        """Check that when sync_users is enabled, property mappings must be set"""
 | 
				
			||||||
 | 
					        serializer = LDAPSourceSerializer(
 | 
				
			||||||
 | 
					            data={
 | 
				
			||||||
 | 
					                "name": "foo",
 | 
				
			||||||
 | 
					                "slug": " foo",
 | 
				
			||||||
 | 
					                "server_uri": "ldaps://1.2.3.4",
 | 
				
			||||||
 | 
					                "bind_cn": "",
 | 
				
			||||||
 | 
					                "bind_password": LDAP_PASSWORD,
 | 
				
			||||||
 | 
					                "base_dn": "dc=foo",
 | 
				
			||||||
 | 
					                "sync_users": True,
 | 
				
			||||||
 | 
					                "user_property_mappings": [],
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertFalse(serializer.is_valid())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_sync_groups_mapping_empty(self):
 | 
				
			||||||
 | 
					        """Check that when sync_groups is enabled, property mappings must be set"""
 | 
				
			||||||
 | 
					        serializer = LDAPSourceSerializer(
 | 
				
			||||||
 | 
					            data={
 | 
				
			||||||
 | 
					                "name": "foo",
 | 
				
			||||||
 | 
					                "slug": " foo",
 | 
				
			||||||
 | 
					                "server_uri": "ldaps://1.2.3.4",
 | 
				
			||||||
 | 
					                "bind_cn": "",
 | 
				
			||||||
 | 
					                "bind_password": LDAP_PASSWORD,
 | 
				
			||||||
 | 
					                "base_dn": "dc=foo",
 | 
				
			||||||
 | 
					                "sync_groups": True,
 | 
				
			||||||
 | 
					                "group_property_mappings": [],
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertFalse(serializer.is_valid())
 | 
				
			||||||
 | 
				
			|||||||
@ -82,3 +82,5 @@ entries:
 | 
				
			|||||||
    order: 10
 | 
					    order: 10
 | 
				
			||||||
    target: !KeyOf default-authentication-flow-password-binding
 | 
					    target: !KeyOf default-authentication-flow-password-binding
 | 
				
			||||||
    policy: !KeyOf default-authentication-flow-password-optional
 | 
					    policy: !KeyOf default-authentication-flow-password-optional
 | 
				
			||||||
 | 
					  attrs:
 | 
				
			||||||
 | 
					    failure_result: true
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@
 | 
				
			|||||||
    "$schema": "http://json-schema.org/draft-07/schema",
 | 
					    "$schema": "http://json-schema.org/draft-07/schema",
 | 
				
			||||||
    "$id": "https://goauthentik.io/blueprints/schema.json",
 | 
					    "$id": "https://goauthentik.io/blueprints/schema.json",
 | 
				
			||||||
    "type": "object",
 | 
					    "type": "object",
 | 
				
			||||||
    "title": "authentik 2024.8.0 Blueprint schema",
 | 
					    "title": "authentik 2024.8.4 Blueprint schema",
 | 
				
			||||||
    "required": [
 | 
					    "required": [
 | 
				
			||||||
        "version",
 | 
					        "version",
 | 
				
			||||||
        "entries"
 | 
					        "entries"
 | 
				
			||||||
 | 
				
			|||||||
@ -31,7 +31,7 @@ services:
 | 
				
			|||||||
    volumes:
 | 
					    volumes:
 | 
				
			||||||
      - redis:/data
 | 
					      - redis:/data
 | 
				
			||||||
  server:
 | 
					  server:
 | 
				
			||||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.0}
 | 
					    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
 | 
				
			||||||
    restart: unless-stopped
 | 
					    restart: unless-stopped
 | 
				
			||||||
    command: server
 | 
					    command: server
 | 
				
			||||||
    environment:
 | 
					    environment:
 | 
				
			||||||
@ -52,7 +52,7 @@ services:
 | 
				
			|||||||
      - postgresql
 | 
					      - postgresql
 | 
				
			||||||
      - redis
 | 
					      - redis
 | 
				
			||||||
  worker:
 | 
					  worker:
 | 
				
			||||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.0}
 | 
					    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.4}
 | 
				
			||||||
    restart: unless-stopped
 | 
					    restart: unless-stopped
 | 
				
			||||||
    command: worker
 | 
					    command: worker
 | 
				
			||||||
    environment:
 | 
					    environment:
 | 
				
			||||||
 | 
				
			|||||||
@ -29,4 +29,4 @@ func UserAgent() string {
 | 
				
			|||||||
	return fmt.Sprintf("authentik@%s", FullVersion())
 | 
						return fmt.Sprintf("authentik@%s", FullVersion())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const VERSION = "2024.8.0"
 | 
					const VERSION = "2024.8.4"
 | 
				
			||||||
 | 
				
			|||||||
@ -35,10 +35,11 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
 | 
				
			|||||||
	req PaginatorRequest[Treq, Tres],
 | 
						req PaginatorRequest[Treq, Tres],
 | 
				
			||||||
	opts PaginatorOptions,
 | 
						opts PaginatorOptions,
 | 
				
			||||||
) ([]Tobj, error) {
 | 
					) ([]Tobj, error) {
 | 
				
			||||||
 | 
						var bfreq, cfreq interface{}
 | 
				
			||||||
	fetchOffset := func(page int32) (Tres, error) {
 | 
						fetchOffset := func(page int32) (Tres, error) {
 | 
				
			||||||
		req.Page(page)
 | 
							bfreq = req.Page(page)
 | 
				
			||||||
		req.PageSize(int32(opts.PageSize))
 | 
							cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize))
 | 
				
			||||||
		res, _, err := req.Execute()
 | 
							res, _, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute()
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
 | 
								opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								internal/outpost/ak/api_utils_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								internal/outpost/ak/api_utils_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					package ak
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// func Test_PaginatorCompile(t *testing.T) {
 | 
				
			||||||
 | 
					// 	req := api.ApiCoreUsersListRequest{}
 | 
				
			||||||
 | 
					// 	Paginator(req, PaginatorOptions{
 | 
				
			||||||
 | 
					// 		PageSize: 100,
 | 
				
			||||||
 | 
					// 	})
 | 
				
			||||||
 | 
					// }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// func Test_PaginatorCompileExplicit(t *testing.T) {
 | 
				
			||||||
 | 
					// 	req := api.ApiCoreUsersListRequest{}
 | 
				
			||||||
 | 
					// 	Paginator[
 | 
				
			||||||
 | 
					// 		api.User,
 | 
				
			||||||
 | 
					// 		api.ApiCoreUsersListRequest,
 | 
				
			||||||
 | 
					// 		*api.PaginatedUserList,
 | 
				
			||||||
 | 
					// 	](req, PaginatorOptions{
 | 
				
			||||||
 | 
					// 		PageSize: 100,
 | 
				
			||||||
 | 
					// 	})
 | 
				
			||||||
 | 
					// }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// func Test_PaginatorCompileOther(t *testing.T) {
 | 
				
			||||||
 | 
					// 	req := api.ApiOutpostsProxyListRequest{}
 | 
				
			||||||
 | 
					// 	Paginator(req, PaginatorOptions{
 | 
				
			||||||
 | 
					// 		PageSize: 100,
 | 
				
			||||||
 | 
					// 	})
 | 
				
			||||||
 | 
					// }
 | 
				
			||||||
@ -96,7 +96,7 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
 | 
				
			|||||||
		return ldap.LDAPResultOperationsError, nil
 | 
							return ldap.LDAPResultOperationsError, nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	flags.UserPk = userInfo.User.Pk
 | 
						flags.UserPk = userInfo.User.Pk
 | 
				
			||||||
	flags.CanSearch = access.HasSearchPermission != nil
 | 
						flags.CanSearch = access.GetHasSearchPermission()
 | 
				
			||||||
	db.si.SetFlags(req.BindDN, &flags)
 | 
						db.si.SetFlags(req.BindDN, &flags)
 | 
				
			||||||
	if flags.CanSearch {
 | 
						if flags.CanSearch {
 | 
				
			||||||
		req.Log().Debug("Allowed access to search")
 | 
							req.Log().Debug("Allowed access to search")
 | 
				
			||||||
 | 
				
			|||||||
@ -193,7 +193,17 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server) (*A
 | 
				
			|||||||
	})
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	mux.HandleFunc("/outpost.goauthentik.io/start", func(w http.ResponseWriter, r *http.Request) {
 | 
						mux.HandleFunc("/outpost.goauthentik.io/start", func(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
		a.handleAuthStart(w, r, "")
 | 
							fwd := ""
 | 
				
			||||||
 | 
							// This should only really be hit for nginx forward_auth
 | 
				
			||||||
 | 
							// as for that the auth start redirect URL is generated by the
 | 
				
			||||||
 | 
							// reverse proxy, and as such we won't have a request we just
 | 
				
			||||||
 | 
							// denied to reference for final URL
 | 
				
			||||||
 | 
							rd, ok := a.checkRedirectParam(r)
 | 
				
			||||||
 | 
							if ok {
 | 
				
			||||||
 | 
								a.log.WithField("rd", rd).Trace("Setting redirect")
 | 
				
			||||||
 | 
								fwd = rd
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							a.handleAuthStart(w, r, fwd)
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleAuthCallback)
 | 
						mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleAuthCallback)
 | 
				
			||||||
	mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut)
 | 
						mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut)
 | 
				
			||||||
 | 
				
			|||||||
@ -15,36 +15,6 @@ const (
 | 
				
			|||||||
	LogoutSignature   = "X-authentik-logout"
 | 
						LogoutSignature   = "X-authentik-logout"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
 | 
					 | 
				
			||||||
	rd := r.URL.Query().Get(redirectParam)
 | 
					 | 
				
			||||||
	if rd == "" {
 | 
					 | 
				
			||||||
		return "", false
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	u, err := url.Parse(rd)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		a.log.WithError(err).Warning("Failed to parse redirect URL")
 | 
					 | 
				
			||||||
		return "", false
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	// Check to make sure we only redirect to allowed places
 | 
					 | 
				
			||||||
	if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
 | 
					 | 
				
			||||||
		ext, err := url.Parse(a.proxyConfig.ExternalHost)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return "", false
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		ext.Scheme = ""
 | 
					 | 
				
			||||||
		if !strings.Contains(u.String(), ext.String()) {
 | 
					 | 
				
			||||||
			a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host")
 | 
					 | 
				
			||||||
			return "", false
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	} else {
 | 
					 | 
				
			||||||
		if !strings.HasSuffix(u.Host, *a.proxyConfig.CookieDomain) {
 | 
					 | 
				
			||||||
			a.log.WithField("host", u.Host).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Host was not included in cookie domain")
 | 
					 | 
				
			||||||
			return "", false
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return u.String(), true
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, fwd string) {
 | 
					func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, fwd string) {
 | 
				
			||||||
	state, err := a.createState(r, fwd)
 | 
						state, err := a.createState(r, fwd)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 | 
				
			|||||||
@ -5,10 +5,13 @@ import (
 | 
				
			|||||||
	"encoding/base64"
 | 
						"encoding/base64"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
						"net/url"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/golang-jwt/jwt/v5"
 | 
						"github.com/golang-jwt/jwt/v5"
 | 
				
			||||||
	"github.com/gorilla/securecookie"
 | 
						"github.com/gorilla/securecookie"
 | 
				
			||||||
	"github.com/mitchellh/mapstructure"
 | 
						"github.com/mitchellh/mapstructure"
 | 
				
			||||||
 | 
						"goauthentik.io/api/v3"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type OAuthState struct {
 | 
					type OAuthState struct {
 | 
				
			||||||
@ -27,6 +30,44 @@ func (oas *OAuthState) GetAudience() (jwt.ClaimStrings, error)       { return ni
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
 | 
					var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Validate that the given redirect parameter (?rd=...) is valid and can be used
 | 
				
			||||||
 | 
					// For proxy/forward_single this checks that if the `rd` param has a Hostname (and is a full URL)
 | 
				
			||||||
 | 
					// the hostname matches what's configured, or no hostname must be given
 | 
				
			||||||
 | 
					// For forward_domain this checks if the domain of the URL in `rd` ends with the configured domain
 | 
				
			||||||
 | 
					func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
 | 
				
			||||||
 | 
						rd := r.URL.Query().Get(redirectParam)
 | 
				
			||||||
 | 
						if rd == "" {
 | 
				
			||||||
 | 
							return "", false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						u, err := url.Parse(rd)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							a.log.WithError(err).Warning("Failed to parse redirect URL")
 | 
				
			||||||
 | 
							return "", false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						// Check to make sure we only redirect to allowed places
 | 
				
			||||||
 | 
						if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
 | 
				
			||||||
 | 
							ext, err := url.Parse(a.proxyConfig.ExternalHost)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return "", false
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							// Either hostname needs to match the configured domain, or host name must be empty for just a path
 | 
				
			||||||
 | 
							if u.Host == "" {
 | 
				
			||||||
 | 
								u.Host = ext.Host
 | 
				
			||||||
 | 
								u.Scheme = ext.Scheme
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if u.Host != ext.Host {
 | 
				
			||||||
 | 
								a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host")
 | 
				
			||||||
 | 
								return "", false
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							if !strings.HasSuffix(u.Host, *a.proxyConfig.CookieDomain) {
 | 
				
			||||||
 | 
								a.log.WithField("host", u.Host).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Host was not included in cookie domain")
 | 
				
			||||||
 | 
								return "", false
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return u.String(), true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (a *Application) createState(r *http.Request, fwd string) (string, error) {
 | 
					func (a *Application) createState(r *http.Request, fwd string) (string, error) {
 | 
				
			||||||
	s, _ := a.sessions.Get(r, a.SessionName())
 | 
						s, _ := a.sessions.Get(r, a.SessionName())
 | 
				
			||||||
	if s.ID == "" {
 | 
						if s.ID == "" {
 | 
				
			||||||
@ -39,17 +80,6 @@ func (a *Application) createState(r *http.Request, fwd string) (string, error) {
 | 
				
			|||||||
		SessionID: s.ID,
 | 
							SessionID: s.ID,
 | 
				
			||||||
		Redirect:  fwd,
 | 
							Redirect:  fwd,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if fwd == "" {
 | 
					 | 
				
			||||||
		// This should only really be hit for nginx forward_auth
 | 
					 | 
				
			||||||
		// as for that the auth start redirect URL is generated by the
 | 
					 | 
				
			||||||
		// reverse proxy, and as such we won't have a request we just
 | 
					 | 
				
			||||||
		// denied to reference for final URL
 | 
					 | 
				
			||||||
		rd, ok := a.checkRedirectParam(r)
 | 
					 | 
				
			||||||
		if ok {
 | 
					 | 
				
			||||||
			a.log.WithField("rd", rd).Trace("Setting redirect")
 | 
					 | 
				
			||||||
			st.Redirect = rd
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, st)
 | 
						token := jwt.NewWithClaims(jwt.SigningMethodHS256, st)
 | 
				
			||||||
	tokenString, err := token.SignedString([]byte(a.proxyConfig.GetCookieSecret()))
 | 
						tokenString, err := token.SignedString([]byte(a.proxyConfig.GetCookieSecret()))
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 | 
				
			|||||||
@ -8,25 +8,45 @@ import (
 | 
				
			|||||||
	"goauthentik.io/api/v3"
 | 
						"goauthentik.io/api/v3"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestCheckRedirectParam(t *testing.T) {
 | 
					func TestCheckRedirectParam_None(t *testing.T) {
 | 
				
			||||||
	a := newTestApplication()
 | 
						a := newTestApplication()
 | 
				
			||||||
 | 
						// Test no rd param
 | 
				
			||||||
	req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil)
 | 
						req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	rd, ok := a.checkRedirectParam(req)
 | 
						rd, ok := a.checkRedirectParam(req)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, false, ok)
 | 
						assert.Equal(t, false, ok)
 | 
				
			||||||
	assert.Equal(t, "", rd)
 | 
						assert.Equal(t, "", rd)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
 | 
					func TestCheckRedirectParam_Invalid(t *testing.T) {
 | 
				
			||||||
 | 
						a := newTestApplication()
 | 
				
			||||||
 | 
						// Test invalid rd param
 | 
				
			||||||
 | 
						req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	rd, ok = a.checkRedirectParam(req)
 | 
						rd, ok := a.checkRedirectParam(req)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, false, ok)
 | 
						assert.Equal(t, false, ok)
 | 
				
			||||||
	assert.Equal(t, "", rd)
 | 
						assert.Equal(t, "", rd)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil)
 | 
					func TestCheckRedirectParam_ValidFull(t *testing.T) {
 | 
				
			||||||
 | 
						a := newTestApplication()
 | 
				
			||||||
 | 
						// Test valid full rd param
 | 
				
			||||||
 | 
						req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	rd, ok = a.checkRedirectParam(req)
 | 
						rd, ok := a.checkRedirectParam(req)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						assert.Equal(t, true, ok)
 | 
				
			||||||
 | 
						assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestCheckRedirectParam_ValidPartial(t *testing.T) {
 | 
				
			||||||
 | 
						a := newTestApplication()
 | 
				
			||||||
 | 
						// Test valid partial rd param
 | 
				
			||||||
 | 
						req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=/test?foo", nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						rd, ok := a.checkRedirectParam(req)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, true, ok)
 | 
						assert.Equal(t, true, ok)
 | 
				
			||||||
	assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
 | 
						assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ import (
 | 
				
			|||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"net"
 | 
						"net"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
	"sync"
 | 
						"sync"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	sentryhttp "github.com/getsentry/sentry-go/http"
 | 
						sentryhttp "github.com/getsentry/sentry-go/http"
 | 
				
			||||||
@ -70,12 +71,20 @@ func NewProxyServer(ac *ak.APIController) *ProxyServer {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool {
 | 
					func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool {
 | 
				
			||||||
 | 
						// Always handle requests for outpost paths that should answer regardless of hostname
 | 
				
			||||||
 | 
						if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") ||
 | 
				
			||||||
 | 
							strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") {
 | 
				
			||||||
 | 
							ps.mux.ServeHTTP(rw, r)
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						// lookup app by hostname
 | 
				
			||||||
	a, _ := ps.lookupApp(r)
 | 
						a, _ := ps.lookupApp(r)
 | 
				
			||||||
	if a == nil {
 | 
						if a == nil {
 | 
				
			||||||
		return false
 | 
							return false
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						// check if the app should handle this URL, or is setup in proxy mode
 | 
				
			||||||
	if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY {
 | 
						if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY {
 | 
				
			||||||
		a.ServeHTTP(rw, r)
 | 
							ps.mux.ServeHTTP(rw, r)
 | 
				
			||||||
		return true
 | 
							return true
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return false
 | 
						return false
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
    "name": "@goauthentik/authentik",
 | 
					    "name": "@goauthentik/authentik",
 | 
				
			||||||
    "version": "2024.8.0",
 | 
					    "version": "2024.8.4",
 | 
				
			||||||
    "private": true
 | 
					    "private": true
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										58
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										58
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@ -1053,38 +1053,38 @@ toml = ["tomli"]
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "cryptography"
 | 
					name = "cryptography"
 | 
				
			||||||
version = "43.0.0"
 | 
					version = "43.0.1"
 | 
				
			||||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 | 
					description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 | 
				
			||||||
optional = false
 | 
					optional = false
 | 
				
			||||||
python-versions = ">=3.7"
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
files = [
 | 
					files = [
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
 | 
					    {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
 | 
					    {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
 | 
					    {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
 | 
					    {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
 | 
					    {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
 | 
					    {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
 | 
					    {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
 | 
					    {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
 | 
					    {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
 | 
				
			||||||
    {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
 | 
					    {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
 | 
				
			||||||
    {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
 | 
					    {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[package.dependencies]
 | 
					[package.dependencies]
 | 
				
			||||||
@ -1097,7 +1097,7 @@ nox = ["nox"]
 | 
				
			|||||||
pep8test = ["check-sdist", "click", "mypy", "ruff"]
 | 
					pep8test = ["check-sdist", "click", "mypy", "ruff"]
 | 
				
			||||||
sdist = ["build"]
 | 
					sdist = ["build"]
 | 
				
			||||||
ssh = ["bcrypt (>=3.1.5)"]
 | 
					ssh = ["bcrypt (>=3.1.5)"]
 | 
				
			||||||
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
 | 
					test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
 | 
				
			||||||
test-randomorder = ["pytest-randomly"]
 | 
					test-randomorder = ["pytest-randomly"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
[tool.poetry]
 | 
					[tool.poetry]
 | 
				
			||||||
name = "authentik"
 | 
					name = "authentik"
 | 
				
			||||||
version = "2024.8.0"
 | 
					version = "2024.8.4"
 | 
				
			||||||
description = ""
 | 
					description = ""
 | 
				
			||||||
authors = ["authentik Team <hello@goauthentik.io>"]
 | 
					authors = ["authentik Team <hello@goauthentik.io>"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
openapi: 3.0.3
 | 
					openapi: 3.0.3
 | 
				
			||||||
info:
 | 
					info:
 | 
				
			||||||
  title: authentik
 | 
					  title: authentik
 | 
				
			||||||
  version: 2024.8.0
 | 
					  version: 2024.8.4
 | 
				
			||||||
  description: Making authentication simple.
 | 
					  description: Making authentication simple.
 | 
				
			||||||
  contact:
 | 
					  contact:
 | 
				
			||||||
    email: hello@goauthentik.io
 | 
					    email: hello@goauthentik.io
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ from ldap3.core.exceptions import LDAPInvalidCredentialsResult
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.blueprints.tests import apply_blueprint, reconcile_app
 | 
					from authentik.blueprints.tests import apply_blueprint, reconcile_app
 | 
				
			||||||
from authentik.core.models import Application, User
 | 
					from authentik.core.models import Application, User
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_user
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.flows.models import Flow
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
@ -331,6 +332,83 @@ class TestProviderLDAP(SeleniumTestCase):
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
        self.assert_list_dict_equal(expected, response)
 | 
					        self.assert_list_dict_equal(expected, response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @retry()
 | 
				
			||||||
 | 
					    @apply_blueprint(
 | 
				
			||||||
 | 
					        "default/flow-default-authentication-flow.yaml",
 | 
				
			||||||
 | 
					        "default/flow-default-invalidation-flow.yaml",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @reconcile_app("authentik_tenants")
 | 
				
			||||||
 | 
					    @reconcile_app("authentik_outposts")
 | 
				
			||||||
 | 
					    def test_ldap_bind_search_no_perms(self):
 | 
				
			||||||
 | 
					        """Test simple bind + search"""
 | 
				
			||||||
 | 
					        user = create_test_user()
 | 
				
			||||||
 | 
					        self._prepare()
 | 
				
			||||||
 | 
					        server = Server("ldap://localhost:3389", get_info=ALL)
 | 
				
			||||||
 | 
					        _connection = Connection(
 | 
				
			||||||
 | 
					            server,
 | 
				
			||||||
 | 
					            raise_exceptions=True,
 | 
				
			||||||
 | 
					            user=f"cn={user.username},ou=users,dc=ldap,dc=goauthentik,dc=io",
 | 
				
			||||||
 | 
					            password=user.username,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        _connection.bind()
 | 
				
			||||||
 | 
					        self.assertTrue(
 | 
				
			||||||
 | 
					            Event.objects.filter(
 | 
				
			||||||
 | 
					                action=EventAction.LOGIN,
 | 
				
			||||||
 | 
					                user={
 | 
				
			||||||
 | 
					                    "pk": user.pk,
 | 
				
			||||||
 | 
					                    "email": user.email,
 | 
				
			||||||
 | 
					                    "username": user.username,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        _connection.search(
 | 
				
			||||||
 | 
					            "ou=Users,DC=ldaP,dc=goauthentik,dc=io",
 | 
				
			||||||
 | 
					            "(objectClass=user)",
 | 
				
			||||||
 | 
					            search_scope=SUBTREE,
 | 
				
			||||||
 | 
					            attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        response: list = _connection.response
 | 
				
			||||||
 | 
					        # Remove raw_attributes to make checking easier
 | 
				
			||||||
 | 
					        for obj in response:
 | 
				
			||||||
 | 
					            del obj["raw_attributes"]
 | 
				
			||||||
 | 
					            del obj["raw_dn"]
 | 
				
			||||||
 | 
					            obj["attributes"] = dict(obj["attributes"])
 | 
				
			||||||
 | 
					        expected = [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "dn": f"cn={user.username},ou=users,dc=ldap,dc=goauthentik,dc=io",
 | 
				
			||||||
 | 
					                "attributes": {
 | 
				
			||||||
 | 
					                    "cn": user.username,
 | 
				
			||||||
 | 
					                    "sAMAccountName": user.username,
 | 
				
			||||||
 | 
					                    "uid": user.uid,
 | 
				
			||||||
 | 
					                    "name": user.name,
 | 
				
			||||||
 | 
					                    "displayName": user.name,
 | 
				
			||||||
 | 
					                    "sn": user.name,
 | 
				
			||||||
 | 
					                    "mail": user.email,
 | 
				
			||||||
 | 
					                    "objectClass": [
 | 
				
			||||||
 | 
					                        "top",
 | 
				
			||||||
 | 
					                        "person",
 | 
				
			||||||
 | 
					                        "organizationalPerson",
 | 
				
			||||||
 | 
					                        "inetOrgPerson",
 | 
				
			||||||
 | 
					                        "user",
 | 
				
			||||||
 | 
					                        "posixAccount",
 | 
				
			||||||
 | 
					                        "goauthentik.io/ldap/user",
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    "uidNumber": 2000 + user.pk,
 | 
				
			||||||
 | 
					                    "gidNumber": 2000 + user.pk,
 | 
				
			||||||
 | 
					                    "memberOf": [
 | 
				
			||||||
 | 
					                        f"cn={group.name},ou=groups,dc=ldap,dc=goauthentik,dc=io"
 | 
				
			||||||
 | 
					                        for group in user.ak_groups.all()
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    "homeDirectory": f"/home/{user.username}",
 | 
				
			||||||
 | 
					                    "ak-active": True,
 | 
				
			||||||
 | 
					                    "ak-superuser": False,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                "type": "searchResEntry",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        self.assert_list_dict_equal(expected, response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def assert_list_dict_equal(self, expected: list[dict], actual: list[dict], match_key="dn"):
 | 
					    def assert_list_dict_equal(self, expected: list[dict], actual: list[dict], match_key="dn"):
 | 
				
			||||||
        """Assert a list of dictionaries is identical, ignoring the ordering of items"""
 | 
					        """Assert a list of dictionaries is identical, ignoring the ordering of items"""
 | 
				
			||||||
        self.assertEqual(len(expected), len(actual))
 | 
					        self.assertEqual(len(expected), len(actual))
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										8823
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8823
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -91,7 +91,6 @@
 | 
				
			|||||||
        "glob": "^11.0.0",
 | 
					        "glob": "^11.0.0",
 | 
				
			||||||
        "globals": "^15.9.0",
 | 
					        "globals": "^15.9.0",
 | 
				
			||||||
        "lit-analyzer": "^2.0.3",
 | 
					        "lit-analyzer": "^2.0.3",
 | 
				
			||||||
        "lockfile-lint": "^4.14.0",
 | 
					 | 
				
			||||||
        "npm-run-all": "^4.1.5",
 | 
					        "npm-run-all": "^4.1.5",
 | 
				
			||||||
        "prettier": "^3.3.3",
 | 
					        "prettier": "^3.3.3",
 | 
				
			||||||
        "pseudolocale": "^2.1.0",
 | 
					        "pseudolocale": "^2.1.0",
 | 
				
			||||||
@ -257,7 +256,9 @@
 | 
				
			|||||||
            ]
 | 
					            ]
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "lint:lockfile": {
 | 
					        "lint:lockfile": {
 | 
				
			||||||
            "command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
 | 
					            "__comment": "The lockfile-lint package does not have an option to ensure resolved hashes are set everywhere",
 | 
				
			||||||
 | 
					            "shell": true,
 | 
				
			||||||
 | 
					            "command": "[ -z \"$(jq -r '.packages | to_entries[] | select((.key | contains(\"node_modules\")) and (.value | has(\"resolved\") | not)) | .key' < package-lock.json)\" ]"
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "lint:lockfiles": {
 | 
					        "lint:lockfiles": {
 | 
				
			||||||
            "dependencies": [
 | 
					            "dependencies": [
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,6 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
 | 
					import { EVENT_REFRESH } from "@goauthentik/common/constants";
 | 
				
			||||||
 | 
					import { parseAPIError } from "@goauthentik/common/errors";
 | 
				
			||||||
import "@goauthentik/components/ak-radio-input";
 | 
					import "@goauthentik/components/ak-radio-input";
 | 
				
			||||||
import "@goauthentik/components/ak-switch-input";
 | 
					import "@goauthentik/components/ak-switch-input";
 | 
				
			||||||
import "@goauthentik/components/ak-text-input";
 | 
					import "@goauthentik/components/ak-text-input";
 | 
				
			||||||
@ -24,7 +25,6 @@ import {
 | 
				
			|||||||
    type TransactionApplicationRequest,
 | 
					    type TransactionApplicationRequest,
 | 
				
			||||||
    type TransactionApplicationResponse,
 | 
					    type TransactionApplicationResponse,
 | 
				
			||||||
    ValidationError,
 | 
					    ValidationError,
 | 
				
			||||||
    ValidationErrorFromJSON,
 | 
					 | 
				
			||||||
} from "@goauthentik/api";
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import BasePanel from "../BasePanel";
 | 
					import BasePanel from "../BasePanel";
 | 
				
			||||||
@ -59,7 +59,7 @@ const runningState: State = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
const errorState: State = {
 | 
					const errorState: State = {
 | 
				
			||||||
    state: "error",
 | 
					    state: "error",
 | 
				
			||||||
    label: msg("Authentik was unable to save this application:"),
 | 
					    label: msg("authentik was unable to save this application:"),
 | 
				
			||||||
    icon: ["fa-times-circle", "pf-m-danger"],
 | 
					    icon: ["fa-times-circle", "pf-m-danger"],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -133,9 +133,7 @@ export class ApplicationWizardCommitApplication extends BasePanel {
 | 
				
			|||||||
            })
 | 
					            })
 | 
				
			||||||
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
					            // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
				
			||||||
            .catch(async (resolution: any) => {
 | 
					            .catch(async (resolution: any) => {
 | 
				
			||||||
                const errors = (this.errors = ValidationErrorFromJSON(
 | 
					                const errors = await parseAPIError(resolution);
 | 
				
			||||||
                    await resolution.response.json(),
 | 
					 | 
				
			||||||
                ));
 | 
					 | 
				
			||||||
                this.dispatchWizardUpdate({
 | 
					                this.dispatchWizardUpdate({
 | 
				
			||||||
                    update: {
 | 
					                    update: {
 | 
				
			||||||
                        ...this.wizard,
 | 
					                        ...this.wizard,
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,10 @@ import {
 | 
				
			|||||||
    redirectUriHelp,
 | 
					    redirectUriHelp,
 | 
				
			||||||
    subjectModeOptions,
 | 
					    subjectModeOptions,
 | 
				
			||||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
 | 
					} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
 | 
				
			||||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
					import {
 | 
				
			||||||
 | 
					    makeSourceSelector,
 | 
				
			||||||
 | 
					    oauth2SourcesProvider,
 | 
				
			||||||
 | 
					} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
 | 
					import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
 | 
				
			||||||
import "@goauthentik/components/ak-number-input";
 | 
					import "@goauthentik/components/ak-number-input";
 | 
				
			||||||
@ -263,12 +266,12 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
 | 
				
			|||||||
                            name="jwksSources"
 | 
					                            name="jwksSources"
 | 
				
			||||||
                            .errorMessages=${errors?.jwksSources ?? []}
 | 
					                            .errorMessages=${errors?.jwksSources ?? []}
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                            <ak-dual-select-provider
 | 
					                            <ak-dual-select-dynamic-selected
 | 
				
			||||||
                                .provider=${oauth2SourcesProvider}
 | 
					                                .provider=${oauth2SourcesProvider}
 | 
				
			||||||
                                .selected=${provider?.jwksSources}
 | 
					                                .selector=${makeSourceSelector(provider?.jwksSources)}
 | 
				
			||||||
                                available-label=${msg("Available Sources")}
 | 
					                                available-label=${msg("Available Sources")}
 | 
				
			||||||
                                selected-label=${msg("Selected Sources")}
 | 
					                                selected-label=${msg("Selected Sources")}
 | 
				
			||||||
                            ></ak-dual-select-provider>
 | 
					                            ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                            <p class="pf-c-form__helper-text">
 | 
					                            <p class="pf-c-form__helper-text">
 | 
				
			||||||
                                ${msg(
 | 
					                                ${msg(
 | 
				
			||||||
                                    "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
					                                    "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,8 @@
 | 
				
			|||||||
import "@goauthentik/admin/applications/wizard/ak-wizard-title";
 | 
					import "@goauthentik/admin/applications/wizard/ak-wizard-title";
 | 
				
			||||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
					import {
 | 
				
			||||||
 | 
					    makeSourceSelector,
 | 
				
			||||||
 | 
					    oauth2SourcesProvider,
 | 
				
			||||||
 | 
					} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    makeProxyPropertyMappingsSelector,
 | 
					    makeProxyPropertyMappingsSelector,
 | 
				
			||||||
    proxyPropertyMappingsProvider,
 | 
					    proxyPropertyMappingsProvider,
 | 
				
			||||||
@ -11,7 +14,6 @@ import "@goauthentik/components/ak-text-input";
 | 
				
			|||||||
import "@goauthentik/components/ak-textarea-input";
 | 
					import "@goauthentik/components/ak-textarea-input";
 | 
				
			||||||
import "@goauthentik/components/ak-toggle-group";
 | 
					import "@goauthentik/components/ak-toggle-group";
 | 
				
			||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
				
			||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
 | 
					 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { msg } from "@lit/localize";
 | 
					import { msg } from "@lit/localize";
 | 
				
			||||||
@ -228,12 +230,12 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
 | 
				
			|||||||
                            name="jwksSources"
 | 
					                            name="jwksSources"
 | 
				
			||||||
                            .errorMessages=${errors?.jwksSources ?? []}
 | 
					                            .errorMessages=${errors?.jwksSources ?? []}
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                            <ak-dual-select-provider
 | 
					                            <ak-dual-select-dynamic-selected
 | 
				
			||||||
                                .provider=${oauth2SourcesProvider}
 | 
					                                .provider=${oauth2SourcesProvider}
 | 
				
			||||||
                                .selected=${this.instance?.jwksSources}
 | 
					                                .selector=${makeSourceSelector(this.instance?.jwksSources)}
 | 
				
			||||||
                                available-label=${msg("Available Sources")}
 | 
					                                available-label=${msg("Available Sources")}
 | 
				
			||||||
                                selected-label=${msg("Selected Sources")}
 | 
					                                selected-label=${msg("Selected Sources")}
 | 
				
			||||||
                            ></ak-dual-select-provider>
 | 
					                            ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                            <p class="pf-c-form__helper-text">
 | 
					                            <p class="pf-c-form__helper-text">
 | 
				
			||||||
                                ${msg(
 | 
					                                ${msg(
 | 
				
			||||||
                                    "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
					                                    "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,7 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { severityToLabel } from "@goauthentik/common/labels";
 | 
					import { severityToLabel } from "@goauthentik/common/labels";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
				
			||||||
 | 
					import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
 | 
					import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
 | 
				
			||||||
import "@goauthentik/elements/forms/Radio";
 | 
					import "@goauthentik/elements/forms/Radio";
 | 
				
			||||||
@ -16,6 +18,7 @@ import {
 | 
				
			|||||||
    EventsApi,
 | 
					    EventsApi,
 | 
				
			||||||
    Group,
 | 
					    Group,
 | 
				
			||||||
    NotificationRule,
 | 
					    NotificationRule,
 | 
				
			||||||
 | 
					    NotificationTransport,
 | 
				
			||||||
    PaginatedNotificationTransportList,
 | 
					    PaginatedNotificationTransportList,
 | 
				
			||||||
    SeverityEnum,
 | 
					    SeverityEnum,
 | 
				
			||||||
} from "@goauthentik/api";
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
@ -34,6 +37,13 @@ async function eventTransportsProvider(page = 1, search = "") {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function makeTransportSelector(instanceTransports: string[] | undefined) {
 | 
				
			||||||
 | 
					    const localTransports = instanceTransports ? new Set(instanceTransports) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return localTransports
 | 
				
			||||||
 | 
					        ? ([pk, _]: DualSelectPair) => localTransports.has(pk)
 | 
				
			||||||
 | 
					        : ([_0, _1, _2, stage]: DualSelectPair<NotificationTransport>) => stage !== undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@customElement("ak-event-rule-form")
 | 
					@customElement("ak-event-rule-form")
 | 
				
			||||||
export class RuleForm extends ModelForm<NotificationRule, string> {
 | 
					export class RuleForm extends ModelForm<NotificationRule, string> {
 | 
				
			||||||
    eventTransports?: PaginatedNotificationTransportList;
 | 
					    eventTransports?: PaginatedNotificationTransportList;
 | 
				
			||||||
@ -114,12 +124,12 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
 | 
				
			|||||||
                ?required=${true}
 | 
					                ?required=${true}
 | 
				
			||||||
                name="transports"
 | 
					                name="transports"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
                <ak-dual-select-provider
 | 
					                <ak-dual-select-dynamic-selected
 | 
				
			||||||
                    .provider=${eventTransportsProvider}
 | 
					                    .provider=${eventTransportsProvider}
 | 
				
			||||||
                    .selected=${this.instance?.transports}
 | 
					                    .selector=${makeTransportSelector(this.instance?.transports)}
 | 
				
			||||||
                    available-label="${msg("Available Transports")}"
 | 
					                    available-label="${msg("Available Transports")}"
 | 
				
			||||||
                    selected-label="${msg("Selected Transports")}"
 | 
					                    selected-label="${msg("Selected Transports")}"
 | 
				
			||||||
                ></ak-dual-select-provider>
 | 
					                ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                <p class="pf-c-form__helper-text">
 | 
					                <p class="pf-c-form__helper-text">
 | 
				
			||||||
                    ${msg(
 | 
					                    ${msg(
 | 
				
			||||||
                        "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
 | 
					                        "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
 | 
				
			||||||
 | 
				
			|||||||
@ -97,7 +97,8 @@ export class OutpostForm extends ModelForm<Outpost, string> {
 | 
				
			|||||||
    embedded = false;
 | 
					    embedded = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @state()
 | 
					    @state()
 | 
				
			||||||
    providers?: DataProvider;
 | 
					    providers: DataProvider = providerProvider(this.type);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    defaultConfig?: OutpostDefaultConfig;
 | 
					    defaultConfig?: OutpostDefaultConfig;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async loadInstance(pk: string): Promise<Outpost> {
 | 
					    async loadInstance(pk: string): Promise<Outpost> {
 | 
				
			||||||
@ -113,6 +114,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
 | 
				
			|||||||
        this.defaultConfig = await new OutpostsApi(
 | 
					        this.defaultConfig = await new OutpostsApi(
 | 
				
			||||||
            DEFAULT_CONFIG,
 | 
					            DEFAULT_CONFIG,
 | 
				
			||||||
        ).outpostsInstancesDefaultSettingsRetrieve();
 | 
					        ).outpostsInstancesDefaultSettingsRetrieve();
 | 
				
			||||||
 | 
					        this.providers = providerProvider(this.type);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    getSuccessMessage(): string {
 | 
					    getSuccessMessage(): string {
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
interface PropertyMapping {
 | 
					interface PropertyMapping {
 | 
				
			||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    expression: string;
 | 
					    expression?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export abstract class BasePropertyMappingForm<T extends PropertyMapping> extends ModelForm<
 | 
					export abstract class BasePropertyMappingForm<T extends PropertyMapping> extends ModelForm<
 | 
				
			||||||
 | 
				
			|||||||
@ -1,14 +1,14 @@
 | 
				
			|||||||
 | 
					import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import "@goauthentik/elements/CodeMirror";
 | 
					import "@goauthentik/elements/CodeMirror";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { customElement } from "lit/decorators.js";
 | 
					import { customElement } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { NotificationWebhookMapping, PropertymappingsApi } from "@goauthentik/api";
 | 
					import { NotificationWebhookMapping, PropertymappingsApi } from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-property-mapping-notification-form")
 | 
					@customElement("ak-property-mapping-notification-form")
 | 
				
			||||||
export class PropertyMappingNotification extends ModelForm<NotificationWebhookMapping, string> {
 | 
					export class PropertyMappingNotification extends BasePropertyMappingForm<NotificationWebhookMapping> {
 | 
				
			||||||
    loadInstance(pk: string): Promise<NotificationWebhookMapping> {
 | 
					    loadInstance(pk: string): Promise<NotificationWebhookMapping> {
 | 
				
			||||||
        return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsNotificationRetrieve({
 | 
					        return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsNotificationRetrieve({
 | 
				
			||||||
            pmUuid: pk,
 | 
					            pmUuid: pk,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,10 +1,10 @@
 | 
				
			|||||||
 | 
					import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { docLink } from "@goauthentik/common/global";
 | 
					import { docLink } from "@goauthentik/common/global";
 | 
				
			||||||
import "@goauthentik/elements/CodeMirror";
 | 
					import "@goauthentik/elements/CodeMirror";
 | 
				
			||||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
 | 
					import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
 | 
				
			||||||
import "@goauthentik/elements/forms/FormGroup";
 | 
					import "@goauthentik/elements/forms/FormGroup";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
 | 
					 | 
				
			||||||
import "@goauthentik/elements/forms/Radio";
 | 
					import "@goauthentik/elements/forms/Radio";
 | 
				
			||||||
import type { RadioOption } from "@goauthentik/elements/forms/Radio";
 | 
					import type { RadioOption } from "@goauthentik/elements/forms/Radio";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -33,21 +33,13 @@ export const staticSettingOptions: RadioOption<string | undefined>[] = [
 | 
				
			|||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-property-mapping-provider-rac-form")
 | 
					@customElement("ak-property-mapping-provider-rac-form")
 | 
				
			||||||
export class PropertyMappingProviderRACForm extends ModelForm<RACPropertyMapping, string> {
 | 
					export class PropertyMappingProviderRACForm extends BasePropertyMappingForm<RACPropertyMapping> {
 | 
				
			||||||
    loadInstance(pk: string): Promise<RACPropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<RACPropertyMapping> {
 | 
				
			||||||
        return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacRetrieve({
 | 
					        return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacRetrieve({
 | 
				
			||||||
            pmUuid: pk,
 | 
					            pmUuid: pk,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    getSuccessMessage(): string {
 | 
					 | 
				
			||||||
        if (this.instance) {
 | 
					 | 
				
			||||||
            return msg("Successfully updated mapping.");
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            return msg("Successfully created mapping.");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    async send(data: RACPropertyMapping): Promise<RACPropertyMapping> {
 | 
					    async send(data: RACPropertyMapping): Promise<RACPropertyMapping> {
 | 
				
			||||||
        if (this.instance) {
 | 
					        if (this.instance) {
 | 
				
			||||||
            return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacUpdate({
 | 
					            return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacUpdate({
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ import { LDAPSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/api
 | 
				
			|||||||
@customElement("ak-property-mapping-source-ldap-form")
 | 
					@customElement("ak-property-mapping-source-ldap-form")
 | 
				
			||||||
export class PropertyMappingSourceLDAPForm extends BasePropertyMappingForm<LDAPSourcePropertyMapping> {
 | 
					export class PropertyMappingSourceLDAPForm extends BasePropertyMappingForm<LDAPSourcePropertyMapping> {
 | 
				
			||||||
    docLink(): string {
 | 
					    docLink(): string {
 | 
				
			||||||
        return "/docs/sources/property-mappings/expression?utm_source=authentik";
 | 
					        return "/docs/sources/property-mappings/expressions?utm_source=authentik";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    loadInstance(pk: string): Promise<LDAPSourcePropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<LDAPSourcePropertyMapping> {
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ import { OAuthSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/ap
 | 
				
			|||||||
@customElement("ak-property-mapping-source-oauth-form")
 | 
					@customElement("ak-property-mapping-source-oauth-form")
 | 
				
			||||||
export class PropertyMappingSourceOAuthForm extends BasePropertyMappingForm<OAuthSourcePropertyMapping> {
 | 
					export class PropertyMappingSourceOAuthForm extends BasePropertyMappingForm<OAuthSourcePropertyMapping> {
 | 
				
			||||||
    docLink(): string {
 | 
					    docLink(): string {
 | 
				
			||||||
        return "/docs/sources/property-mappings/expression?utm_source=authentik";
 | 
					        return "/docs/sources/property-mappings/expressions?utm_source=authentik";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    loadInstance(pk: string): Promise<OAuthSourcePropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<OAuthSourcePropertyMapping> {
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ import { PlexSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/api
 | 
				
			|||||||
@customElement("ak-property-mapping-source-plex-form")
 | 
					@customElement("ak-property-mapping-source-plex-form")
 | 
				
			||||||
export class PropertyMappingSourcePlexForm extends BasePropertyMappingForm<PlexSourcePropertyMapping> {
 | 
					export class PropertyMappingSourcePlexForm extends BasePropertyMappingForm<PlexSourcePropertyMapping> {
 | 
				
			||||||
    docLink(): string {
 | 
					    docLink(): string {
 | 
				
			||||||
        return "/docs/sources/property-mappings/expression?utm_source=authentik";
 | 
					        return "/docs/sources/property-mappings/expressions?utm_source=authentik";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    loadInstance(pk: string): Promise<PlexSourcePropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<PlexSourcePropertyMapping> {
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ import { PropertymappingsApi, SAMLSourcePropertyMapping } from "@goauthentik/api
 | 
				
			|||||||
@customElement("ak-property-mapping-source-saml-form")
 | 
					@customElement("ak-property-mapping-source-saml-form")
 | 
				
			||||||
export class PropertyMappingSourceSAMLForm extends BasePropertyMappingForm<SAMLSourcePropertyMapping> {
 | 
					export class PropertyMappingSourceSAMLForm extends BasePropertyMappingForm<SAMLSourcePropertyMapping> {
 | 
				
			||||||
    docLink(): string {
 | 
					    docLink(): string {
 | 
				
			||||||
        return "/docs/sources/property-mappings/expression?utm_source=authentik";
 | 
					        return "/docs/sources/property-mappings/expressions?utm_source=authentik";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    loadInstance(pk: string): Promise<SAMLSourcePropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<SAMLSourcePropertyMapping> {
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ import { PropertymappingsApi, SCIMSourcePropertyMapping } from "@goauthentik/api
 | 
				
			|||||||
@customElement("ak-property-mapping-source-scim-form")
 | 
					@customElement("ak-property-mapping-source-scim-form")
 | 
				
			||||||
export class PropertyMappingSourceSCIMForm extends BasePropertyMappingForm<SCIMSourcePropertyMapping> {
 | 
					export class PropertyMappingSourceSCIMForm extends BasePropertyMappingForm<SCIMSourcePropertyMapping> {
 | 
				
			||||||
    docLink(): string {
 | 
					    docLink(): string {
 | 
				
			||||||
        return "/docs/sources/property-mappings/expression?utm_source=authentik";
 | 
					        return "/docs/sources/property-mappings/expressions?utm_source=authentik";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    loadInstance(pk: string): Promise<SCIMSourcePropertyMapping> {
 | 
					    loadInstance(pk: string): Promise<SCIMSourcePropertyMapping> {
 | 
				
			||||||
 | 
				
			|||||||
@ -61,7 +61,9 @@ export class PolicyTestForm extends Form<PropertyMappingTestRequest> {
 | 
				
			|||||||
                  </ak-codemirror>`
 | 
					                  </ak-codemirror>`
 | 
				
			||||||
                : html` <div class="pf-c-form__group-label">
 | 
					                : html` <div class="pf-c-form__group-label">
 | 
				
			||||||
                      <div class="c-form__horizontal-group">
 | 
					                      <div class="c-form__horizontal-group">
 | 
				
			||||||
                          <span class="pf-c-form__label-text">${this.result?.result}</span>
 | 
					                          <span class="pf-c-form__label-text">
 | 
				
			||||||
 | 
					                              <pre>${this.result?.result}</pre>
 | 
				
			||||||
 | 
					                          </span>
 | 
				
			||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
                  </div>`}
 | 
					                  </div>`}
 | 
				
			||||||
        </ak-form-element-horizontal>`;
 | 
					        </ak-form-element-horizontal>`;
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
 | 
				
			|||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { GoogleWorkspaceProviderGroup, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    GoogleWorkspaceProviderGroup,
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersGoogleWorkspaceSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-google-workspace-groups-list")
 | 
					@customElement("ak-provider-google-workspace-groups-list")
 | 
				
			||||||
export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProviderGroup> {
 | 
					export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProviderGroup> {
 | 
				
			||||||
@ -27,12 +32,15 @@ export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProvi
 | 
				
			|||||||
    renderToolbar(): TemplateResult {
 | 
					    renderToolbar(): TemplateResult {
 | 
				
			||||||
        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
					        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
				
			||||||
                <span slot="submit">${msg("Sync")}</span>
 | 
					                <span slot="submit">${msg("Sync")}</span>
 | 
				
			||||||
                <span slot="header">${msg("Sync User")}</span>
 | 
					                <span slot="header">${msg("Sync Group")}</span>
 | 
				
			||||||
                <ak-sync-object-form
 | 
					                <ak-sync-object-form
 | 
				
			||||||
                    .provider=${this.providerId}
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
                    model=${SyncObjectModelEnum.Group}
 | 
					                    model=${SyncObjectModelEnum.Group}
 | 
				
			||||||
                    .sync=${new ProvidersApi(DEFAULT_CONFIG)
 | 
					                    .sync=${(data: ProvidersGoogleWorkspaceSyncObjectCreateRequest) => {
 | 
				
			||||||
                        .providersGoogleWorkspaceSyncObjectCreate}
 | 
					                        return new ProvidersApi(
 | 
				
			||||||
 | 
					                            DEFAULT_CONFIG,
 | 
				
			||||||
 | 
					                        ).providersGoogleWorkspaceSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
                    slot="form"
 | 
					                    slot="form"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                </ak-sync-object-form>
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
 | 
				
			|||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { GoogleWorkspaceProviderUser, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    GoogleWorkspaceProviderUser,
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersGoogleWorkspaceSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-google-workspace-users-list")
 | 
					@customElement("ak-provider-google-workspace-users-list")
 | 
				
			||||||
export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProviderUser> {
 | 
					export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProviderUser> {
 | 
				
			||||||
@ -31,8 +36,11 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
 | 
				
			|||||||
                <ak-sync-object-form
 | 
					                <ak-sync-object-form
 | 
				
			||||||
                    .provider=${this.providerId}
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
                    model=${SyncObjectModelEnum.User}
 | 
					                    model=${SyncObjectModelEnum.User}
 | 
				
			||||||
                    .sync=${new ProvidersApi(DEFAULT_CONFIG)
 | 
					                    .sync=${(data: ProvidersGoogleWorkspaceSyncObjectCreateRequest) => {
 | 
				
			||||||
                        .providersGoogleWorkspaceSyncObjectCreate}
 | 
					                        return new ProvidersApi(
 | 
				
			||||||
 | 
					                            DEFAULT_CONFIG,
 | 
				
			||||||
 | 
					                        ).providersGoogleWorkspaceSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
                    slot="form"
 | 
					                    slot="form"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                </ak-sync-object-form>
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
 | 
				
			|||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { MicrosoftEntraProviderGroup, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    MicrosoftEntraProviderGroup,
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersMicrosoftEntraSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-microsoft-entra-groups-list")
 | 
					@customElement("ak-provider-microsoft-entra-groups-list")
 | 
				
			||||||
export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProviderGroup> {
 | 
					export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProviderGroup> {
 | 
				
			||||||
@ -24,12 +29,15 @@ export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProvide
 | 
				
			|||||||
    renderToolbar(): TemplateResult {
 | 
					    renderToolbar(): TemplateResult {
 | 
				
			||||||
        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
					        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
				
			||||||
                <span slot="submit">${msg("Sync")}</span>
 | 
					                <span slot="submit">${msg("Sync")}</span>
 | 
				
			||||||
                <span slot="header">${msg("Sync User")}</span>
 | 
					                <span slot="header">${msg("Sync Group")}</span>
 | 
				
			||||||
                <ak-sync-object-form
 | 
					                <ak-sync-object-form
 | 
				
			||||||
                    .provider=${this.providerId}
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
                    model=${SyncObjectModelEnum.Group}
 | 
					                    model=${SyncObjectModelEnum.Group}
 | 
				
			||||||
                    .sync=${new ProvidersApi(DEFAULT_CONFIG)
 | 
					                    .sync=${(data: ProvidersMicrosoftEntraSyncObjectCreateRequest) => {
 | 
				
			||||||
                        .providersMicrosoftEntraSyncObjectCreate}
 | 
					                        return new ProvidersApi(
 | 
				
			||||||
 | 
					                            DEFAULT_CONFIG,
 | 
				
			||||||
 | 
					                        ).providersMicrosoftEntraSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
                    slot="form"
 | 
					                    slot="form"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                </ak-sync-object-form>
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,12 @@ import { msg } from "@lit/localize";
 | 
				
			|||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { MicrosoftEntraProviderUser, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    MicrosoftEntraProviderUser,
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersMicrosoftEntraSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-microsoft-entra-users-list")
 | 
					@customElement("ak-provider-microsoft-entra-users-list")
 | 
				
			||||||
export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProviderUser> {
 | 
					export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProviderUser> {
 | 
				
			||||||
@ -31,8 +36,11 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
 | 
				
			|||||||
                <ak-sync-object-form
 | 
					                <ak-sync-object-form
 | 
				
			||||||
                    .provider=${this.providerId}
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
                    model=${SyncObjectModelEnum.User}
 | 
					                    model=${SyncObjectModelEnum.User}
 | 
				
			||||||
                    .sync=${new ProvidersApi(DEFAULT_CONFIG)
 | 
					                    .sync=${(data: ProvidersMicrosoftEntraSyncObjectCreateRequest) => {
 | 
				
			||||||
                        .providersMicrosoftEntraSyncObjectCreate}
 | 
					                        return new ProvidersApi(
 | 
				
			||||||
 | 
					                            DEFAULT_CONFIG,
 | 
				
			||||||
 | 
					                        ).providersMicrosoftEntraSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
                    slot="form"
 | 
					                    slot="form"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                </ak-sync-object-form>
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,12 @@ import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
 | 
					import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const defaultScopes = [
 | 
				
			||||||
 | 
					    "goauthentik.io/providers/oauth2/scope-openid",
 | 
				
			||||||
 | 
					    "goauthentik.io/providers/oauth2/scope-email",
 | 
				
			||||||
 | 
					    "goauthentik.io/providers/oauth2/scope-profile",
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function oauth2PropertyMappingsProvider(page = 1, search = "") {
 | 
					export async function oauth2PropertyMappingsProvider(page = 1, search = "") {
 | 
				
			||||||
    const propertyMappings = await new PropertymappingsApi(
 | 
					    const propertyMappings = await new PropertymappingsApi(
 | 
				
			||||||
        DEFAULT_CONFIG,
 | 
					        DEFAULT_CONFIG,
 | 
				
			||||||
@ -23,6 +29,5 @@ export function makeOAuth2PropertyMappingsSelector(instanceMappings: string[] |
 | 
				
			|||||||
    return localMappings
 | 
					    return localMappings
 | 
				
			||||||
        ? ([pk, _]: DualSelectPair) => localMappings.has(pk)
 | 
					        ? ([pk, _]: DualSelectPair) => localMappings.has(pk)
 | 
				
			||||||
        : ([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
 | 
					        : ([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
 | 
				
			||||||
              scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") &&
 | 
					              scope?.managed && defaultScopes.includes(scope?.managed);
 | 
				
			||||||
              scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access";
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -32,7 +32,7 @@ import {
 | 
				
			|||||||
    makeOAuth2PropertyMappingsSelector,
 | 
					    makeOAuth2PropertyMappingsSelector,
 | 
				
			||||||
    oauth2PropertyMappingsProvider,
 | 
					    oauth2PropertyMappingsProvider,
 | 
				
			||||||
} from "./OAuth2PropertyMappings.js";
 | 
					} from "./OAuth2PropertyMappings.js";
 | 
				
			||||||
import { oauth2SourcesProvider } from "./OAuth2Sources.js";
 | 
					import { makeSourceSelector, oauth2SourcesProvider } from "./OAuth2Sources.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const clientTypeOptions = [
 | 
					export const clientTypeOptions = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@ -52,12 +52,6 @@ export const clientTypeOptions = [
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const defaultScopes = [
 | 
					 | 
				
			||||||
    "goauthentik.io/providers/oauth2/scope-openid",
 | 
					 | 
				
			||||||
    "goauthentik.io/providers/oauth2/scope-email",
 | 
					 | 
				
			||||||
    "goauthentik.io/providers/oauth2/scope-profile",
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const subjectModeOptions = [
 | 
					export const subjectModeOptions = [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        label: msg("Based on the User's hashed ID"),
 | 
					        label: msg("Based on the User's hashed ID"),
 | 
				
			||||||
@ -168,7 +162,6 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
 | 
				
			|||||||
                <ak-flow-search
 | 
					                <ak-flow-search
 | 
				
			||||||
                    flowType=${FlowsInstancesListDesignationEnum.Authentication}
 | 
					                    flowType=${FlowsInstancesListDesignationEnum.Authentication}
 | 
				
			||||||
                    .currentFlow=${provider?.authenticationFlow}
 | 
					                    .currentFlow=${provider?.authenticationFlow}
 | 
				
			||||||
                    required
 | 
					 | 
				
			||||||
                ></ak-flow-search>
 | 
					                ></ak-flow-search>
 | 
				
			||||||
                <p class="pf-c-form__helper-text">
 | 
					                <p class="pf-c-form__helper-text">
 | 
				
			||||||
                    ${msg("Flow used when a user access this provider and is not authenticated.")}
 | 
					                    ${msg("Flow used when a user access this provider and is not authenticated.")}
 | 
				
			||||||
@ -177,7 +170,7 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
 | 
				
			|||||||
            <ak-form-element-horizontal
 | 
					            <ak-form-element-horizontal
 | 
				
			||||||
                name="authorizationFlow"
 | 
					                name="authorizationFlow"
 | 
				
			||||||
                label=${msg("Authorization flow")}
 | 
					                label=${msg("Authorization flow")}
 | 
				
			||||||
                ?required=${true}
 | 
					                required
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
                <ak-flow-search
 | 
					                <ak-flow-search
 | 
				
			||||||
                    flowType=${FlowsInstancesListDesignationEnum.Authorization}
 | 
					                    flowType=${FlowsInstancesListDesignationEnum.Authorization}
 | 
				
			||||||
@ -335,12 +328,12 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
 | 
				
			|||||||
                        label=${msg("Trusted OIDC Sources")}
 | 
					                        label=${msg("Trusted OIDC Sources")}
 | 
				
			||||||
                        name="jwksSources"
 | 
					                        name="jwksSources"
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        <ak-dual-select-provider
 | 
					                        <ak-dual-select-dynamic-selected
 | 
				
			||||||
                            .provider=${oauth2SourcesProvider}
 | 
					                            .provider=${oauth2SourcesProvider}
 | 
				
			||||||
                            .selected=${provider?.jwksSources}
 | 
					                            .selector=${makeSourceSelector(provider?.jwksSources)}
 | 
				
			||||||
                            available-label=${msg("Available Sources")}
 | 
					                            available-label=${msg("Available Sources")}
 | 
				
			||||||
                            selected-label=${msg("Selected Sources")}
 | 
					                            selected-label=${msg("Selected Sources")}
 | 
				
			||||||
                        ></ak-dual-select-provider>
 | 
					                        ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                        <p class="pf-c-form__helper-text">
 | 
					                        <p class="pf-c-form__helper-text">
 | 
				
			||||||
                            ${msg(
 | 
					                            ${msg(
 | 
				
			||||||
                                "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
					                                "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
 | 
					import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { SourcesApi } from "@goauthentik/api";
 | 
					import { OAuthSource, SourcesApi } from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function oauth2SourcesProvider(page = 1, search = "") {
 | 
					export async function oauth2SourcesProvider(page = 1, search = "") {
 | 
				
			||||||
    const oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({
 | 
					    const oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({
 | 
				
			||||||
@ -19,3 +20,11 @@ export async function oauth2SourcesProvider(page = 1, search = "") {
 | 
				
			|||||||
        ]),
 | 
					        ]),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function makeSourceSelector(instanceSources: string[] | undefined) {
 | 
				
			||||||
 | 
					    const localSources = instanceSources ? new Set(instanceSources) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return localSources
 | 
				
			||||||
 | 
					        ? ([pk, _]: DualSelectPair) => localSources.has(pk)
 | 
				
			||||||
 | 
					        : ([_0, _1, _2, prompt]: DualSelectPair<OAuthSource>) => prompt !== undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,14 @@
 | 
				
			|||||||
import "@goauthentik/admin/common/ak-crypto-certificate-search";
 | 
					import "@goauthentik/admin/common/ak-crypto-certificate-search";
 | 
				
			||||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
 | 
					import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
 | 
				
			||||||
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
 | 
					import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
 | 
				
			||||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
					import {
 | 
				
			||||||
 | 
					    makeSourceSelector,
 | 
				
			||||||
 | 
					    oauth2SourcesProvider,
 | 
				
			||||||
 | 
					} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { first } from "@goauthentik/common/utils";
 | 
					import { first } from "@goauthentik/common/utils";
 | 
				
			||||||
import "@goauthentik/components/ak-toggle-group";
 | 
					import "@goauthentik/components/ak-toggle-group";
 | 
				
			||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
				
			||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
 | 
					 | 
				
			||||||
import "@goauthentik/elements/forms/FormGroup";
 | 
					import "@goauthentik/elements/forms/FormGroup";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import "@goauthentik/elements/forms/SearchSelect";
 | 
					import "@goauthentik/elements/forms/SearchSelect";
 | 
				
			||||||
@ -403,12 +405,12 @@ ${this.instance?.skipPathRegex}</textarea
 | 
				
			|||||||
                        label=${msg("Trusted OIDC Sources")}
 | 
					                        label=${msg("Trusted OIDC Sources")}
 | 
				
			||||||
                        name="jwksSources"
 | 
					                        name="jwksSources"
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        <ak-dual-select-provider
 | 
					                        <ak-dual-select-dynamic-selected
 | 
				
			||||||
                            .provider=${oauth2SourcesProvider}
 | 
					                            .provider=${oauth2SourcesProvider}
 | 
				
			||||||
                            .selected=${this.instance?.jwksSources}
 | 
					                            .selector=${makeSourceSelector(this.instance?.jwksSources)}
 | 
				
			||||||
                            available-label=${msg("Available Sources")}
 | 
					                            available-label=${msg("Available Sources")}
 | 
				
			||||||
                            selected-label=${msg("Selected Sources")}
 | 
					                            selected-label=${msg("Selected Sources")}
 | 
				
			||||||
                        ></ak-dual-select-provider>
 | 
					                        ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                        <p class="pf-c-form__helper-text">
 | 
					                        <p class="pf-c-form__helper-text">
 | 
				
			||||||
                            ${msg(
 | 
					                            ${msg(
 | 
				
			||||||
                                "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
					                                "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
 | 
				
			||||||
 | 
				
			|||||||
@ -38,12 +38,15 @@ export async function scimPropertyMappingsProvider(page = 1, search = "") {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function makeSCIMPropertyMappingsSelector(instanceMappings: string[] | undefined) {
 | 
					export function makeSCIMPropertyMappingsSelector(
 | 
				
			||||||
 | 
					    instanceMappings: string[] | undefined,
 | 
				
			||||||
 | 
					    defaultSelected: string,
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
    const localMappings = instanceMappings ? new Set(instanceMappings) : undefined;
 | 
					    const localMappings = instanceMappings ? new Set(instanceMappings) : undefined;
 | 
				
			||||||
    return localMappings
 | 
					    return localMappings
 | 
				
			||||||
        ? ([pk, _]: DualSelectPair) => localMappings.has(pk)
 | 
					        ? ([pk, _]: DualSelectPair) => localMappings.has(pk)
 | 
				
			||||||
        : ([_0, _1, _2, mapping]: DualSelectPair<SCIMMapping>) =>
 | 
					        : ([_0, _1, _2, mapping]: DualSelectPair<SCIMMapping>) =>
 | 
				
			||||||
              mapping?.managed === "goauthentik.io/providers/scim/user";
 | 
					              mapping?.managed === defaultSelected;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-scim-form")
 | 
					@customElement("ak-provider-scim-form")
 | 
				
			||||||
@ -172,6 +175,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
 | 
				
			|||||||
                            .provider=${scimPropertyMappingsProvider}
 | 
					                            .provider=${scimPropertyMappingsProvider}
 | 
				
			||||||
                            .selector=${makeSCIMPropertyMappingsSelector(
 | 
					                            .selector=${makeSCIMPropertyMappingsSelector(
 | 
				
			||||||
                                this.instance?.propertyMappings,
 | 
					                                this.instance?.propertyMappings,
 | 
				
			||||||
 | 
					                                "goauthentik.io/providers/scim/user",
 | 
				
			||||||
                            )}
 | 
					                            )}
 | 
				
			||||||
                            available-label=${msg("Available User Property Mappings")}
 | 
					                            available-label=${msg("Available User Property Mappings")}
 | 
				
			||||||
                            selected-label=${msg("Selected User Property Mappings")}
 | 
					                            selected-label=${msg("Selected User Property Mappings")}
 | 
				
			||||||
@ -188,6 +192,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
 | 
				
			|||||||
                            .provider=${scimPropertyMappingsProvider}
 | 
					                            .provider=${scimPropertyMappingsProvider}
 | 
				
			||||||
                            .selector=${makeSCIMPropertyMappingsSelector(
 | 
					                            .selector=${makeSCIMPropertyMappingsSelector(
 | 
				
			||||||
                                this.instance?.propertyMappingsGroup,
 | 
					                                this.instance?.propertyMappingsGroup,
 | 
				
			||||||
 | 
					                                "goauthentik.io/providers/scim/group",
 | 
				
			||||||
                            )}
 | 
					                            )}
 | 
				
			||||||
                            available-label=${msg("Available Group Property Mappings")}
 | 
					                            available-label=${msg("Available Group Property Mappings")}
 | 
				
			||||||
                            selected-label=${msg("Selected Group Property Mappings")}
 | 
					                            selected-label=${msg("Selected Group Property Mappings")}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,19 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import "@goauthentik/elements/forms/DeleteBulkForm";
 | 
					import "@goauthentik/elements/forms/DeleteBulkForm";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/forms/ModalForm";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/sync/SyncObjectForm";
 | 
				
			||||||
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
 | 
					import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { msg } from "@lit/localize";
 | 
					import { msg } from "@lit/localize";
 | 
				
			||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { ProvidersApi, SCIMProviderGroup } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersScimSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SCIMProviderGroup,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-scim-groups-list")
 | 
					@customElement("ak-provider-scim-groups-list")
 | 
				
			||||||
export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
 | 
					export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
 | 
				
			||||||
@ -20,6 +27,24 @@ export class SCIMProviderGroupList extends Table<SCIMProviderGroup> {
 | 
				
			|||||||
    checkbox = true;
 | 
					    checkbox = true;
 | 
				
			||||||
    clearOnRefresh = true;
 | 
					    clearOnRefresh = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    renderToolbar(): TemplateResult {
 | 
				
			||||||
 | 
					        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
				
			||||||
 | 
					                <span slot="submit">${msg("Sync")}</span>
 | 
				
			||||||
 | 
					                <span slot="header">${msg("Sync Group")}</span>
 | 
				
			||||||
 | 
					                <ak-sync-object-form
 | 
				
			||||||
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
 | 
					                    model=${SyncObjectModelEnum.Group}
 | 
				
			||||||
 | 
					                    .sync=${(data: ProvidersScimSyncObjectCreateRequest) => {
 | 
				
			||||||
 | 
					                        return new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
 | 
					                    slot="form"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
					                <button slot="trigger" class="pf-c-button pf-m-primary">${msg("Sync")}</button>
 | 
				
			||||||
 | 
					            </ak-forms-modal>
 | 
				
			||||||
 | 
					            ${super.renderToolbar()}`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    renderToolbarSelected(): TemplateResult {
 | 
					    renderToolbarSelected(): TemplateResult {
 | 
				
			||||||
        const disabled = this.selectedElements.length < 1;
 | 
					        const disabled = this.selectedElements.length < 1;
 | 
				
			||||||
        return html`<ak-forms-delete-bulk
 | 
					        return html`<ak-forms-delete-bulk
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,19 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import "@goauthentik/elements/forms/DeleteBulkForm";
 | 
					import "@goauthentik/elements/forms/DeleteBulkForm";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/forms/ModalForm";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/sync/SyncObjectForm";
 | 
				
			||||||
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
 | 
					import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { msg } from "@lit/localize";
 | 
					import { msg } from "@lit/localize";
 | 
				
			||||||
import { TemplateResult, html } from "lit";
 | 
					import { TemplateResult, html } from "lit";
 | 
				
			||||||
import { customElement, property } from "lit/decorators.js";
 | 
					import { customElement, property } from "lit/decorators.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { ProvidersApi, SCIMProviderUser } from "@goauthentik/api";
 | 
					import {
 | 
				
			||||||
 | 
					    ProvidersApi,
 | 
				
			||||||
 | 
					    ProvidersScimSyncObjectCreateRequest,
 | 
				
			||||||
 | 
					    SCIMProviderUser,
 | 
				
			||||||
 | 
					    SyncObjectModelEnum,
 | 
				
			||||||
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-provider-scim-users-list")
 | 
					@customElement("ak-provider-scim-users-list")
 | 
				
			||||||
export class SCIMProviderUserList extends Table<SCIMProviderUser> {
 | 
					export class SCIMProviderUserList extends Table<SCIMProviderUser> {
 | 
				
			||||||
@ -20,6 +27,24 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
 | 
				
			|||||||
    checkbox = true;
 | 
					    checkbox = true;
 | 
				
			||||||
    clearOnRefresh = true;
 | 
					    clearOnRefresh = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    renderToolbar(): TemplateResult {
 | 
				
			||||||
 | 
					        return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
 | 
				
			||||||
 | 
					                <span slot="submit">${msg("Sync")}</span>
 | 
				
			||||||
 | 
					                <span slot="header">${msg("Sync User")}</span>
 | 
				
			||||||
 | 
					                <ak-sync-object-form
 | 
				
			||||||
 | 
					                    .provider=${this.providerId}
 | 
				
			||||||
 | 
					                    model=${SyncObjectModelEnum.User}
 | 
				
			||||||
 | 
					                    .sync=${(data: ProvidersScimSyncObjectCreateRequest) => {
 | 
				
			||||||
 | 
					                        return new ProvidersApi(DEFAULT_CONFIG).providersScimSyncObjectCreate(data);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
 | 
					                    slot="form"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                </ak-sync-object-form>
 | 
				
			||||||
 | 
					                <button slot="trigger" class="pf-c-button pf-m-primary">${msg("Sync")}</button>
 | 
				
			||||||
 | 
					            </ak-forms-modal>
 | 
				
			||||||
 | 
					            ${super.renderToolbar()}`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    renderToolbarSelected(): TemplateResult {
 | 
					    renderToolbarSelected(): TemplateResult {
 | 
				
			||||||
        const disabled = this.selectedElements.length < 1;
 | 
					        const disabled = this.selectedElements.length < 1;
 | 
				
			||||||
        return html`<ak-forms-delete-bulk
 | 
					        return html`<ak-forms-delete-bulk
 | 
				
			||||||
 | 
				
			|||||||
@ -327,7 +327,7 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
 | 
				
			|||||||
            <ak-form-group>
 | 
					            <ak-form-group>
 | 
				
			||||||
                <span slot="header"> ${msg("Additional settings")} </span>
 | 
					                <span slot="header"> ${msg("Additional settings")} </span>
 | 
				
			||||||
                <div slot="body" class="pf-c-form">
 | 
					                <div slot="body" class="pf-c-form">
 | 
				
			||||||
                    <ak-form-element-horizontal label=${msg("Group")} name="syncParentGroup">
 | 
					                    <ak-form-element-horizontal label=${msg("Parent Group")} name="syncParentGroup">
 | 
				
			||||||
                        <ak-search-select
 | 
					                        <ak-search-select
 | 
				
			||||||
                            .fetchObjects=${async (query?: string): Promise<Group[]> => {
 | 
					                            .fetchObjects=${async (query?: string): Promise<Group[]> => {
 | 
				
			||||||
                                const args: CoreGroupsListRequest = {
 | 
					                                const args: CoreGroupsListRequest = {
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,9 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
 | 
				
			|||||||
import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
 | 
					import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import "@goauthentik/elements/Alert";
 | 
					import "@goauthentik/elements/Alert";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
				
			||||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
 | 
				
			||||||
 | 
					import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
 | 
				
			||||||
import "@goauthentik/elements/forms/FormGroup";
 | 
					import "@goauthentik/elements/forms/FormGroup";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import "@goauthentik/elements/forms/Radio";
 | 
					import "@goauthentik/elements/forms/Radio";
 | 
				
			||||||
@ -18,6 +20,7 @@ import {
 | 
				
			|||||||
    DeviceClassesEnum,
 | 
					    DeviceClassesEnum,
 | 
				
			||||||
    NotConfiguredActionEnum,
 | 
					    NotConfiguredActionEnum,
 | 
				
			||||||
    PaginatedStageList,
 | 
					    PaginatedStageList,
 | 
				
			||||||
 | 
					    Stage,
 | 
				
			||||||
    StagesApi,
 | 
					    StagesApi,
 | 
				
			||||||
    UserVerificationEnum,
 | 
					    UserVerificationEnum,
 | 
				
			||||||
} from "@goauthentik/api";
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
@ -36,6 +39,14 @@ async function stagesProvider(page = 1, search = "") {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function makeStageSelector(instanceStages: string[] | undefined) {
 | 
				
			||||||
 | 
					    const localStages = instanceStages ? new Set(instanceStages) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return localStages
 | 
				
			||||||
 | 
					        ? ([pk, _]: DualSelectPair) => localStages.has(pk)
 | 
				
			||||||
 | 
					        : ([_0, _1, _2, stage]: DualSelectPair<Stage>) => stage !== undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function authenticatorWebauthnDeviceTypesListProvider(page = 1, search = "") {
 | 
					async function authenticatorWebauthnDeviceTypesListProvider(page = 1, search = "") {
 | 
				
			||||||
    const devicetypes = await new StagesApi(
 | 
					    const devicetypes = await new StagesApi(
 | 
				
			||||||
        DEFAULT_CONFIG,
 | 
					        DEFAULT_CONFIG,
 | 
				
			||||||
@ -205,14 +216,14 @@ export class AuthenticatorValidateStageForm extends BaseStageForm<AuthenticatorV
 | 
				
			|||||||
                                  label=${msg("Configuration stages")}
 | 
					                                  label=${msg("Configuration stages")}
 | 
				
			||||||
                                  name="configurationStages"
 | 
					                                  name="configurationStages"
 | 
				
			||||||
                              >
 | 
					                              >
 | 
				
			||||||
                                  <ak-dual-select-provider
 | 
					                                  <ak-dual-select-dynamic-selected
 | 
				
			||||||
                                      .provider=${stagesProvider}
 | 
					                                      .provider=${stagesProvider}
 | 
				
			||||||
                                      .selected=${Array.from(
 | 
					                                      .selector=${makeStageSelector(
 | 
				
			||||||
                                          this.instance?.configurationStages ?? [],
 | 
					                                          this.instance?.configurationStages,
 | 
				
			||||||
                                      )}
 | 
					                                      )}
 | 
				
			||||||
                                      available-label="${msg("Available Stages")}"
 | 
					                                      available-label="${msg("Available Stages")}"
 | 
				
			||||||
                                      selected-label="${msg("Selected Stages")}"
 | 
					                                      selected-label="${msg("Selected Stages")}"
 | 
				
			||||||
                                  ></ak-dual-select-provider>
 | 
					                                  ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                                  <p class="pf-c-form__helper-text">
 | 
					                                  <p class="pf-c-form__helper-text">
 | 
				
			||||||
                                      ${msg(
 | 
					                                      ${msg(
 | 
				
			||||||
                                          "Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.",
 | 
					                                          "Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.",
 | 
				
			||||||
 | 
				
			|||||||
@ -41,12 +41,13 @@ async function sourcesProvider(page = 1, search = "") {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function makeSourcesSelector(instanceSources: string[] | undefined) {
 | 
					function makeSourcesSelector(instanceSources: string[] | undefined) {
 | 
				
			||||||
    const localSources = instanceSources ? new Set(instanceSources) : undefined;
 | 
					    const localSources = instanceSources ? new Set(instanceSources) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return localSources
 | 
					    return localSources
 | 
				
			||||||
        ? ([pk, _]: DualSelectPair) => localSources.has(pk)
 | 
					        ? ([pk, _]: DualSelectPair) => localSources.has(pk)
 | 
				
			||||||
        : ([_0, _1, _2, source]: DualSelectPair<Source>) =>
 | 
					        : // Creating a new instance, auto-select built-in source only when no other sources exist
 | 
				
			||||||
 | 
					          ([_0, _1, _2, source]: DualSelectPair<Source>) =>
 | 
				
			||||||
              source !== undefined && source.component === "";
 | 
					              source !== undefined && source.component === "";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -75,11 +76,11 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
 | 
				
			|||||||
                stageUuid: this.instance.pk || "",
 | 
					                stageUuid: this.instance.pk || "",
 | 
				
			||||||
                identificationStageRequest: data,
 | 
					                identificationStageRequest: data,
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            return new StagesApi(DEFAULT_CONFIG).stagesIdentificationCreate({
 | 
					 | 
				
			||||||
                identificationStageRequest: data,
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new StagesApi(DEFAULT_CONFIG).stagesIdentificationCreate({
 | 
				
			||||||
 | 
					            identificationStageRequest: data,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    isUserFieldSelected(field: UserFieldsEnum): boolean {
 | 
					    isUserFieldSelected(field: UserFieldsEnum): boolean {
 | 
				
			||||||
@ -232,12 +233,12 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
 | 
				
			|||||||
                        ?required=${true}
 | 
					                        ?required=${true}
 | 
				
			||||||
                        name="sources"
 | 
					                        name="sources"
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        <ak-dual-select-provider-dynamic-selected
 | 
					                        <ak-dual-select-dynamic-selected
 | 
				
			||||||
                            .provider=${sourcesProvider}
 | 
					                            .provider=${sourcesProvider}
 | 
				
			||||||
                            .selected=${makeSourcesSelector(this.instance?.sources)}
 | 
					                            .selector=${makeSourcesSelector(this.instance?.sources)}
 | 
				
			||||||
                            available-label="${msg("Available Stages")}"
 | 
					                            available-label="${msg("Available Stages")}"
 | 
				
			||||||
                            selected-label="${msg("Selected Stages")}"
 | 
					                            selected-label="${msg("Selected Stages")}"
 | 
				
			||||||
                        ></ak-dual-select-provider-dynamic-selected>
 | 
					                        ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                        <p class="pf-c-form__helper-text">
 | 
					                        <p class="pf-c-form__helper-text">
 | 
				
			||||||
                            ${msg(
 | 
					                            ${msg(
 | 
				
			||||||
                                "Select sources should be shown for users to authenticate with. This only affects web-based sources, not LDAP.",
 | 
					                                "Select sources should be shown for users to authenticate with. This only affects web-based sources, not LDAP.",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
 | 
					import { parseAPIError } from "@goauthentik/common/errors";
 | 
				
			||||||
import { first } from "@goauthentik/common/utils";
 | 
					import { first } from "@goauthentik/common/utils";
 | 
				
			||||||
import "@goauthentik/elements/CodeMirror";
 | 
					import "@goauthentik/elements/CodeMirror";
 | 
				
			||||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
 | 
					import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
 | 
				
			||||||
@ -22,7 +23,7 @@ import {
 | 
				
			|||||||
    PromptTypeEnum,
 | 
					    PromptTypeEnum,
 | 
				
			||||||
    ResponseError,
 | 
					    ResponseError,
 | 
				
			||||||
    StagesApi,
 | 
					    StagesApi,
 | 
				
			||||||
    ValidationErrorFromJSON,
 | 
					    ValidationError,
 | 
				
			||||||
} from "@goauthentik/api";
 | 
					} from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PreviewStageHost implements StageHost {
 | 
					class PreviewStageHost implements StageHost {
 | 
				
			||||||
@ -83,10 +84,8 @@ export class PromptForm extends ModelForm<Prompt, string> {
 | 
				
			|||||||
            });
 | 
					            });
 | 
				
			||||||
            this.previewError = undefined;
 | 
					            this.previewError = undefined;
 | 
				
			||||||
        } catch (exc) {
 | 
					        } catch (exc) {
 | 
				
			||||||
            const errorMessage = ValidationErrorFromJSON(
 | 
					            const errorMessage = parseAPIError(exc as ResponseError);
 | 
				
			||||||
                await (exc as ResponseError).response.json(),
 | 
					            this.previewError = (errorMessage as ValidationError).nonFieldErrors;
 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
            this.previewError = errorMessage.nonFieldErrors;
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,8 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
 | 
				
			|||||||
import "@goauthentik/admin/stages/prompt/PromptForm";
 | 
					import "@goauthentik/admin/stages/prompt/PromptForm";
 | 
				
			||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
					import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
				
			||||||
import { PFSize } from "@goauthentik/common/enums";
 | 
					import { PFSize } from "@goauthentik/common/enums";
 | 
				
			||||||
 | 
					import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
 | 
				
			||||||
 | 
					import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
 | 
				
			||||||
import "@goauthentik/elements/forms/FormGroup";
 | 
					import "@goauthentik/elements/forms/FormGroup";
 | 
				
			||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
					import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
				
			||||||
import "@goauthentik/elements/forms/ModalForm";
 | 
					import "@goauthentik/elements/forms/ModalForm";
 | 
				
			||||||
@ -11,9 +13,9 @@ import { TemplateResult, html, nothing } from "lit";
 | 
				
			|||||||
import { customElement } from "lit/decorators.js";
 | 
					import { customElement } from "lit/decorators.js";
 | 
				
			||||||
import { ifDefined } from "lit/directives/if-defined.js";
 | 
					import { ifDefined } from "lit/directives/if-defined.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { PoliciesApi, PromptStage, StagesApi } from "@goauthentik/api";
 | 
					import { PoliciesApi, Policy, Prompt, PromptStage, StagesApi } from "@goauthentik/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function promptsProvider(page = 1, search = "") {
 | 
					async function promptFieldsProvider(page = 1, search = "") {
 | 
				
			||||||
    const prompts = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsList({
 | 
					    const prompts = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsList({
 | 
				
			||||||
        ordering: "field_name",
 | 
					        ordering: "field_name",
 | 
				
			||||||
        pageSize: 20,
 | 
					        pageSize: 20,
 | 
				
			||||||
@ -25,11 +27,19 @@ async function promptsProvider(page = 1, search = "") {
 | 
				
			|||||||
        pagination: prompts.pagination,
 | 
					        pagination: prompts.pagination,
 | 
				
			||||||
        options: prompts.results.map((prompt) => [
 | 
					        options: prompts.results.map((prompt) => [
 | 
				
			||||||
            prompt.pk,
 | 
					            prompt.pk,
 | 
				
			||||||
            str`${prompt.name} ("${prompt.fieldKey}", of type ${prompt.type})`,
 | 
					            msg(str`${prompt.name} ("${prompt.fieldKey}", of type ${prompt.type})`),
 | 
				
			||||||
        ]),
 | 
					        ]),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function makeFieldSelector(instanceFields: string[] | undefined) {
 | 
				
			||||||
 | 
					    const localFields = instanceFields ? new Set(instanceFields) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return localFields
 | 
				
			||||||
 | 
					        ? ([pk, _]: DualSelectPair) => localFields.has(pk)
 | 
				
			||||||
 | 
					        : ([_0, _1, _2, prompt]: DualSelectPair<Prompt>) => prompt !== undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function policiesProvider(page = 1, search = "") {
 | 
					async function policiesProvider(page = 1, search = "") {
 | 
				
			||||||
    const policies = await new PoliciesApi(DEFAULT_CONFIG).policiesAllList({
 | 
					    const policies = await new PoliciesApi(DEFAULT_CONFIG).policiesAllList({
 | 
				
			||||||
        ordering: "name",
 | 
					        ordering: "name",
 | 
				
			||||||
@ -47,6 +57,14 @@ async function policiesProvider(page = 1, search = "") {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function makePoliciesSelector(instancePolicies: string[] | undefined) {
 | 
				
			||||||
 | 
					    const localPolicies = instancePolicies ? new Set(instancePolicies) : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return localPolicies
 | 
				
			||||||
 | 
					        ? ([pk, _]: DualSelectPair) => localPolicies.has(pk)
 | 
				
			||||||
 | 
					        : ([_0, _1, _2, policy]: DualSelectPair<Policy>) => policy !== undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@customElement("ak-stage-prompt-form")
 | 
					@customElement("ak-stage-prompt-form")
 | 
				
			||||||
export class PromptStageForm extends BaseStageForm<PromptStage> {
 | 
					export class PromptStageForm extends BaseStageForm<PromptStage> {
 | 
				
			||||||
    loadInstance(pk: string): Promise<PromptStage> {
 | 
					    loadInstance(pk: string): Promise<PromptStage> {
 | 
				
			||||||
@ -90,12 +108,12 @@ export class PromptStageForm extends BaseStageForm<PromptStage> {
 | 
				
			|||||||
                        ?required=${true}
 | 
					                        ?required=${true}
 | 
				
			||||||
                        name="fields"
 | 
					                        name="fields"
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        <ak-dual-select-provider
 | 
					                        <ak-dual-select-dynamic-selected
 | 
				
			||||||
                            .provider=${promptsProvider}
 | 
					                            .provider=${promptFieldsProvider}
 | 
				
			||||||
                            .selected=${this.instance?.fields}
 | 
					                            .selector=${makeFieldSelector(this.instance?.fields)}
 | 
				
			||||||
                            available-label="${msg("Available Fields")}"
 | 
					                            available-label="${msg("Available Fields")}"
 | 
				
			||||||
                            selected-label="${msg("Selected Fields")}"
 | 
					                            selected-label="${msg("Selected Fields")}"
 | 
				
			||||||
                        ></ak-dual-select-provider>
 | 
					                        ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                        ${this.instance
 | 
					                        ${this.instance
 | 
				
			||||||
                            ? html`<ak-forms-modal size=${PFSize.XLarge}>
 | 
					                            ? html`<ak-forms-modal size=${PFSize.XLarge}>
 | 
				
			||||||
                                  <span slot="submit"> ${msg("Create")} </span>
 | 
					                                  <span slot="submit"> ${msg("Create")} </span>
 | 
				
			||||||
@ -115,12 +133,12 @@ export class PromptStageForm extends BaseStageForm<PromptStage> {
 | 
				
			|||||||
                        label=${msg("Validation Policies")}
 | 
					                        label=${msg("Validation Policies")}
 | 
				
			||||||
                        name="validationPolicies"
 | 
					                        name="validationPolicies"
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        <ak-dual-select-provider
 | 
					                        <ak-dual-select-dynamic-selected
 | 
				
			||||||
                            .provider=${policiesProvider}
 | 
					                            .provider=${policiesProvider}
 | 
				
			||||||
                            .selected=${this.instance?.validationPolicies}
 | 
					                            .selector=${makePoliciesSelector(this.instance?.validationPolicies)}
 | 
				
			||||||
                            available-label="${msg("Available Fields")}"
 | 
					                            available-label="${msg("Available Fields")}"
 | 
				
			||||||
                            selected-label="${msg("Selected Fields")}"
 | 
					                            selected-label="${msg("Selected Fields")}"
 | 
				
			||||||
                        ></ak-dual-select-provider>
 | 
					                        ></ak-dual-select-dynamic-selected>
 | 
				
			||||||
                        <p class="pf-c-form__helper-text">
 | 
					                        <p class="pf-c-form__helper-text">
 | 
				
			||||||
                            ${msg(
 | 
					                            ${msg(
 | 
				
			||||||
                                "Selected policies are executed when the stage is submitted to validate the data.",
 | 
					                                "Selected policies are executed when the stage is submitted to validate the data.",
 | 
				
			||||||
 | 
				
			|||||||
@ -102,10 +102,14 @@ export class UserDeviceTable extends Table<Device> {
 | 
				
			|||||||
            html`${item.name}`,
 | 
					            html`${item.name}`,
 | 
				
			||||||
            html`${deviceTypeName(item)}`,
 | 
					            html`${deviceTypeName(item)}`,
 | 
				
			||||||
            html`${item.confirmed ? msg("Yes") : msg("No")}`,
 | 
					            html`${item.confirmed ? msg("Yes") : msg("No")}`,
 | 
				
			||||||
            html`<div>${getRelativeTime(item.created)}</div>
 | 
					            html`${item.created.getTime() > 0
 | 
				
			||||||
                <small>${item.created.toLocaleString()}</small>`,
 | 
					                ? html`<div>${getRelativeTime(item.created)}</div>
 | 
				
			||||||
            html`<div>${getRelativeTime(item.lastUpdated)}</div>
 | 
					                      <small>${item.created.toLocaleString()}</small>`
 | 
				
			||||||
                <small>${item.lastUpdated.toLocaleString()}</small>`,
 | 
					                : html`-`}`,
 | 
				
			||||||
 | 
					            html`${item.lastUpdated
 | 
				
			||||||
 | 
					                ? html`<div>${getRelativeTime(item.lastUpdated)}</div>
 | 
				
			||||||
 | 
					                      <small>${item.lastUpdated.toLocaleString()}</small>`
 | 
				
			||||||
 | 
					                : html`-`}`,
 | 
				
			||||||
            html`${item.lastUsed
 | 
					            html`${item.lastUsed
 | 
				
			||||||
                ? html`<div>${getRelativeTime(item.lastUsed)}</div>
 | 
					                ? html`<div>${getRelativeTime(item.lastUsed)}</div>
 | 
				
			||||||
                      <small>${item.lastUsed.toLocaleString()}</small>`
 | 
					                      <small>${item.lastUsed.toLocaleString()}</small>`
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
 | 
				
			|||||||
export const ERROR_CLASS = "pf-m-danger";
 | 
					export const ERROR_CLASS = "pf-m-danger";
 | 
				
			||||||
export const PROGRESS_CLASS = "pf-m-in-progress";
 | 
					export const PROGRESS_CLASS = "pf-m-in-progress";
 | 
				
			||||||
export const CURRENT_CLASS = "pf-m-current";
 | 
					export const CURRENT_CLASS = "pf-m-current";
 | 
				
			||||||
export const VERSION = "2024.8.0";
 | 
					export const VERSION = "2024.8.4";
 | 
				
			||||||
export const TITLE_DEFAULT = "authentik";
 | 
					export const TITLE_DEFAULT = "authentik";
 | 
				
			||||||
export const ROUTE_SEPARATOR = ";";
 | 
					export const ROUTE_SEPARATOR = ";";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user