Compare commits
	
		
			165 Commits
		
	
	
		
			events/imp
			...
			eap-but-ac
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 06848be14b | |||
| 4bae3bbe60 | |||
| e33f839d7f | |||
| f5eb827d14 | |||
| 9045f5ba73 | |||
| 7b97e92094 | |||
| 3027cdcc4b | |||
| 67f627a925 | |||
| f1101e0c01 | |||
| fb01a117ad | |||
| fad18db70b | |||
| e0c837257c | |||
| 2a567ccc85 | |||
| e36373ceab | |||
| d8a625be03 | |||
| 4d944f7444 | |||
| c49274042b | |||
| 10fc15ffe0 | |||
| 7c996d9d9d | |||
| 5d25f68b71 | |||
| 8da54d5811 | |||
| 4571f5e644 | |||
| ee234ea3aa | |||
| 82c177b7eb | |||
| 1155ccb3e8 | |||
| 1575b96262 | |||
| 19bb77638a | |||
| d6cf129eaa | |||
| b6686cff14 | |||
| 8cf8f1e199 | |||
| 50c50c4109 | |||
| 51f4a8d83d | |||
| 3ada3a7e0e | |||
| fa06c9fe4e | |||
| 2a024238fe | |||
| 91c87b7c3c | |||
| 318443f270 | |||
| ac88784089 | |||
| 855afa7b9f | |||
| 240abfef41 | |||
| 03075f1890 | |||
| 5bc0ed6e11 | |||
| 8f4cfc28c7 | |||
| 6d77eaaab7 | |||
| 9cee59537c | |||
| fc5c0e2789 | |||
| 573446689f | |||
| fd4bfe604d | |||
| 06e76a5b37 | |||
| 3c228bf5c3 | |||
| 8a80f07db2 | |||
| ae59a3e576 | |||
| df21e678d6 | |||
| a71532b3e3 | |||
| d7cb0b3ea1 | |||
| ba8f137885 | |||
| 958ff66070 | |||
| ad57c66a32 | |||
| 2bba0ddd74 | |||
| 767c0a8e45 | |||
| b10c795a26 | |||
| 8088e08fd9 | |||
| eab6e288d7 | |||
| 91c2863358 | |||
| 1638e95bc7 | |||
| 8f75131541 | |||
| c85471575a | |||
| 5d00dc7e9e | |||
| 6982e7d1c9 | |||
| c7fe987c5a | |||
| e48739c8a0 | |||
| b2ee585c43 | |||
| 97e8ea8e76 | |||
| 1f1e0c9db1 | |||
| ca47a803fe | |||
| c606eb53b0 | |||
| 62357133b0 | |||
| 99d2d91257 | |||
| 69d9363fce | |||
| cfc7f6b993 | |||
| bebbbe9b90 | |||
| 188d3c69c1 | |||
| 877f312145 | |||
| f471a98bc7 | |||
| e874cfc21d | |||
| ec7bdf74aa | |||
| e87bc94b95 | |||
| a3865abaa9 | |||
| 7100d3c674 | |||
| c0c2d2ad3c | |||
| dc287989db | |||
| 03204f6943 | |||
| fcd369e466 | |||
| cb79407bc1 | |||
| 04a88daf34 | |||
| c6a49da5c3 | |||
| bfeeecf3fa | |||
| d86b5e7c8a | |||
| a95776891e | |||
| 031158fdba | |||
| b2fbb92498 | |||
| b1b6bf1a19 | |||
| 179d9d0721 | |||
| 8e94d58851 | |||
| 026669cfce | |||
| c83cea6963 | |||
| 8e01cc2df8 | |||
| 279cec203d | |||
| 41c5030c1e | |||
| 3206fdb7ef | |||
| d7c0868eef | |||
| 7d96a89697 | |||
| dfb0007777 | |||
| 816d9668eb | |||
| 371d35ec06 | |||
| 664d3593ca | |||
| 7acd27eea8 | |||
| 83550dc50d | |||
| c272dd70fd | |||
| ae1d82dc69 | |||
| dd42eeab62 | |||
| 680db9bae6 | |||
| 31b72751bc | |||
| 8210067479 | |||
| 423911d974 | |||
| d4ca070d76 | |||
| db1e8b291f | |||
| 44ff6fce23 | |||
| 085c22a41a | |||
| fb2887fa4b | |||
| ed41eb66de | |||
| ee8122baa7 | |||
| f0d70eef6f | |||
| ff966d763b | |||
| e00b68cafe | |||
| bf4e8dbedc | |||
| d09b7757b6 | |||
| ca2f0439f6 | |||
| 27b7b0b0e7 | |||
| 88073305eb | |||
| 37657e47a3 | |||
| 0d649a70c9 | |||
| 7ec3055018 | |||
| 50ffce87c4 | |||
| a4393ac9f0 | |||
| e235c854a5 | |||
| 910b69f89d | |||
| f89cc98014 | |||
| 91a675a5a1 | |||
| 71be3acd1a | |||
| 0b6ab171ce | |||
| 0c73572b0c | |||
| 03d0899a76 | |||
| 91f79c97d8 | |||
| 19324c61a3 | |||
| d297733614 | |||
| f201f41a1b | |||
| f58f679171 | |||
| 1bea5e38a1 | |||
| 4d1c63e7fa | |||
| e341032bf9 | |||
| e3ff242956 | |||
| c6756bf809 | |||
| cf9b7eaa64 | |||
| 53d8f9bd8c | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2025.6.2 | current_version = 2025.6.3 | ||||||
| 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*))? | ||||||
|  | |||||||
| @ -38,6 +38,8 @@ jobs: | |||||||
|       # Needed for attestation |       # Needed for attestation | ||||||
|       id-token: write |       id-token: write | ||||||
|       attestations: write |       attestations: write | ||||||
|  |       # Needed for checkout | ||||||
|  |       contents: read | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: docker/setup-qemu-action@v3.6.0 |       - uses: docker/setup-qemu-action@v3.6.0 | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,14 +9,15 @@ on: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   test-container: |   test-container: | ||||||
|  |     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         version: |         version: | ||||||
|           - docs |           - docs | ||||||
|  |           - version-2025-4 | ||||||
|           - version-2025-2 |           - version-2025-2 | ||||||
|           - version-2024-12 |  | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - run: | |       - run: | | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -247,11 +247,13 @@ jobs: | |||||||
|       # Needed for attestation |       # Needed for attestation | ||||||
|       id-token: write |       id-token: write | ||||||
|       attestations: write |       attestations: write | ||||||
|  |       # Needed for checkout | ||||||
|  |       contents: read | ||||||
|     needs: ci-core-mark |     needs: ci-core-mark | ||||||
|     uses: ./.github/workflows/_reusable-docker-build.yaml |     uses: ./.github/workflows/_reusable-docker-build.yaml | ||||||
|     secrets: inherit |     secrets: inherit | ||||||
|     with: |     with: | ||||||
|       image_name: ghcr.io/goauthentik/dev-server |       image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }} | ||||||
|       release: false |       release: false | ||||||
|   pr-comment: |   pr-comment: | ||||||
|     needs: |     needs: | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -59,6 +59,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           jobs: ${{ toJSON(needs) }} |           jobs: ${{ toJSON(needs) }} | ||||||
|   build-container: |   build-container: | ||||||
|  |     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     needs: |     needs: | ||||||
|       - ci-outpost-mark |       - ci-outpost-mark | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -63,6 +63,7 @@ jobs: | |||||||
|         working-directory: website/ |         working-directory: website/ | ||||||
|         run: npm run ${{ matrix.job }} |         run: npm run ${{ matrix.job }} | ||||||
|   build-container: |   build-container: | ||||||
|  |     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     permissions: |     permissions: | ||||||
|       # Needed to upload container images to ghcr.io |       # Needed to upload container images to ghcr.io | ||||||
| @ -122,3 +123,4 @@ jobs: | |||||||
|       - uses: re-actors/alls-green@release/v1 |       - uses: re-actors/alls-green@release/v1 | ||||||
|         with: |         with: | ||||||
|           jobs: ${{ toJSON(needs) }} |           jobs: ${{ toJSON(needs) }} | ||||||
|  |           allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }} | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @ -2,7 +2,7 @@ name: "CodeQL" | |||||||
|  |  | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [main, "*", next, version*] |     branches: [main, next, version*] | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: [main] |     branches: [main] | ||||||
|   schedule: |   schedule: | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								.github/workflows/repo-mirror-cleanup.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/repo-mirror-cleanup.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | name: "authentik-repo-mirror-cleanup" | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   workflow_dispatch: | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   to_internal: | ||||||
|  |     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |         with: | ||||||
|  |           fetch-depth: 0 | ||||||
|  |       - if: ${{ env.MIRROR_KEY != '' }} | ||||||
|  |         uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb | ||||||
|  |         with: | ||||||
|  |           target_repo_url: git@github.com:goauthentik/authentik-internal.git | ||||||
|  |           ssh_private_key: ${{ secrets.GH_MIRROR_KEY }} | ||||||
|  |           args: --tags --force --prune | ||||||
|  |         env: | ||||||
|  |           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} | ||||||
							
								
								
									
										9
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,11 +11,10 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           fetch-depth: 0 |           fetch-depth: 0 | ||||||
|       - if: ${{ env.MIRROR_KEY != '' }} |       - if: ${{ env.MIRROR_KEY != '' }} | ||||||
|         uses: pixta-dev/repository-mirroring-action@v1 |         uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb | ||||||
|         with: |         with: | ||||||
|           target_repo_url: |           target_repo_url: git@github.com:goauthentik/authentik-internal.git | ||||||
|             git@github.com:goauthentik/authentik-internal.git |           ssh_private_key: ${{ secrets.GH_MIRROR_KEY }} | ||||||
|           ssh_private_key: |           args: --tags --force | ||||||
|             ${{ secrets.GH_MIRROR_KEY }} |  | ||||||
|         env: |         env: | ||||||
|           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} |           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ env: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   compile: |   compile: | ||||||
|  |     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - id: generate_token |       - id: generate_token | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -6,13 +6,15 @@ | |||||||
|         "!Context scalar", |         "!Context scalar", | ||||||
|         "!Enumerate sequence", |         "!Enumerate sequence", | ||||||
|         "!Env scalar", |         "!Env scalar", | ||||||
|  |         "!Env sequence", | ||||||
|         "!Find sequence", |         "!Find sequence", | ||||||
|         "!Format sequence", |         "!Format sequence", | ||||||
|         "!If sequence", |         "!If sequence", | ||||||
|         "!Index scalar", |         "!Index scalar", | ||||||
|         "!KeyOf scalar", |         "!KeyOf scalar", | ||||||
|         "!Value scalar", |         "!Value scalar", | ||||||
|         "!AtIndex scalar" |         "!AtIndex scalar", | ||||||
|  |         "!ParseJSON scalar" | ||||||
|     ], |     ], | ||||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", |     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", |     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||||
|  | |||||||
| @ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | |||||||
|     /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" |     /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||||
|  |  | ||||||
| # Stage 4: Download uv | # Stage 4: Download uv | ||||||
| FROM ghcr.io/astral-sh/uv:0.7.13 AS uv | FROM ghcr.io/astral-sh/uv:0.7.17 AS uv | ||||||
| # Stage 5: Base python image | # Stage 5: Base python image | ||||||
| FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base | FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								Makefile
									
									
									
									
									
								
							| @ -150,9 +150,9 @@ gen-client-ts: gen-clean-ts  ## Build and install the authentik API for Typescri | |||||||
| 		--additional-properties=npmVersion=${NPM_VERSION} \ | 		--additional-properties=npmVersion=${NPM_VERSION} \ | ||||||
| 		--git-repo-id authentik \ | 		--git-repo-id authentik \ | ||||||
| 		--git-user-id goauthentik | 		--git-user-id goauthentik | ||||||
| 	mkdir -p web/node_modules/@goauthentik/api |  | ||||||
| 	cd ${PWD}/${GEN_API_TS} && npm i | 	cd ${PWD}/${GEN_API_TS} && npm link | ||||||
| 	\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api | 	cd ${PWD}/web && npm link @goauthentik/api | ||||||
|  |  | ||||||
| gen-client-py: gen-clean-py ## Build and install the authentik API for Python | gen-client-py: gen-clean-py ## Build and install the authentik API for Python | ||||||
| 	docker run \ | 	docker run \ | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2025.6.2" | __version__ = "2025.6.3" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ entries: | |||||||
|     - attrs: |     - attrs: | ||||||
|           attributes: |           attributes: | ||||||
|               env_null: !Env [bar-baz, null] |               env_null: !Env [bar-baz, null] | ||||||
|  |               json_parse: !ParseJSON '{"foo": "bar"}' | ||||||
|               policy_pk1: |               policy_pk1: | ||||||
|                   !Format [ |                   !Format [ | ||||||
|                       "%s-%s", |                       "%s-%s", | ||||||
|  | |||||||
| @ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable: | |||||||
|  |  | ||||||
|  |  | ||||||
| for blueprint_file in Path("blueprints/").glob("**/*.yaml"): | for blueprint_file in Path("blueprints/").glob("**/*.yaml"): | ||||||
|     if "local" in str(blueprint_file): |     if "local" in str(blueprint_file) or "testing" in str(blueprint_file): | ||||||
|         continue |         continue | ||||||
|     setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) |     setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ from collections.abc import Callable | |||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from authentik.blueprints.v1.importer import is_model_allowed |  | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.providers.oauth2.models import RefreshToken | from authentik.providers.oauth2.models import RefreshToken | ||||||
|  |  | ||||||
| @ -22,10 +21,13 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable: | |||||||
|             return |             return | ||||||
|         model_class = test_model() |         model_class = test_model() | ||||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) |         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||||
|  |         # Models that have subclasses don't have to have a serializer | ||||||
|  |         if len(test_model.__subclasses__()) > 0: | ||||||
|  |             return | ||||||
|         self.assertIsNotNone(model_class.serializer) |         self.assertIsNotNone(model_class.serializer) | ||||||
|         if model_class.serializer.Meta().model == RefreshToken: |         if model_class.serializer.Meta().model == RefreshToken: | ||||||
|             return |             return | ||||||
|         self.assertEqual(model_class.serializer.Meta().model, test_model) |         self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model)) | ||||||
|  |  | ||||||
|     return tester |     return tester | ||||||
|  |  | ||||||
| @ -34,6 +36,6 @@ for app in apps.get_app_configs(): | |||||||
|     if not app.label.startswith("authentik"): |     if not app.label.startswith("authentik"): | ||||||
|         continue |         continue | ||||||
|     for model in app.get_models(): |     for model in app.get_models(): | ||||||
|         if not is_model_allowed(model): |         if not issubclass(model, SerializerModel): | ||||||
|             continue |             continue | ||||||
|         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) |         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) | ||||||
|  | |||||||
| @ -215,6 +215,7 @@ class TestBlueprintsV1(TransactionTestCase): | |||||||
|                     }, |                     }, | ||||||
|                     "nested_context": "context-nested-value", |                     "nested_context": "context-nested-value", | ||||||
|                     "env_null": None, |                     "env_null": None, | ||||||
|  |                     "json_parse": {"foo": "bar"}, | ||||||
|                     "at_index_sequence": "foo", |                     "at_index_sequence": "foo", | ||||||
|                     "at_index_sequence_default": "non existent", |                     "at_index_sequence_default": "non existent", | ||||||
|                     "at_index_mapping": 2, |                     "at_index_mapping": 2, | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from copy import copy | |||||||
| from dataclasses import asdict, dataclass, field, is_dataclass | from dataclasses import asdict, dataclass, field, is_dataclass | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from functools import reduce | from functools import reduce | ||||||
|  | from json import JSONDecodeError, loads | ||||||
| from operator import ixor | from operator import ixor | ||||||
| from os import getenv | from os import getenv | ||||||
| from typing import Any, Literal, Union | from typing import Any, Literal, Union | ||||||
| @ -291,6 +292,22 @@ class Context(YAMLTag): | |||||||
|         return value |         return value | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ParseJSON(YAMLTag): | ||||||
|  |     """Parse JSON from context/env/etc value""" | ||||||
|  |  | ||||||
|  |     raw: str | ||||||
|  |  | ||||||
|  |     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: | ||||||
|  |         super().__init__() | ||||||
|  |         self.raw = node.value | ||||||
|  |  | ||||||
|  |     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||||
|  |         try: | ||||||
|  |             return loads(self.raw) | ||||||
|  |         except JSONDecodeError as exc: | ||||||
|  |             raise EntryInvalidError.from_entry(exc, entry) from exc | ||||||
|  |  | ||||||
|  |  | ||||||
| class Format(YAMLTag): | class Format(YAMLTag): | ||||||
|     """Format a string""" |     """Format a string""" | ||||||
|  |  | ||||||
| @ -666,6 +683,7 @@ class BlueprintLoader(SafeLoader): | |||||||
|         self.add_constructor("!Value", Value) |         self.add_constructor("!Value", Value) | ||||||
|         self.add_constructor("!Index", Index) |         self.add_constructor("!Index", Index) | ||||||
|         self.add_constructor("!AtIndex", AtIndex) |         self.add_constructor("!AtIndex", AtIndex) | ||||||
|  |         self.add_constructor("!ParseJSON", ParseJSON) | ||||||
|  |  | ||||||
|  |  | ||||||
| class EntryInvalidError(SentryIgnoredException): | class EntryInvalidError(SentryIgnoredException): | ||||||
|  | |||||||
| @ -1,8 +1,6 @@ | |||||||
| """Authenticator Devices API Views""" | """Authenticator Devices API Views""" | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from drf_spectacular.utils import extend_schema | ||||||
| from drf_spectacular.types import OpenApiTypes |  | ||||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema |  | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import ( | ||||||
|     BooleanField, |     BooleanField, | ||||||
| @ -15,6 +13,7 @@ from rest_framework.request import Request | |||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.viewsets import ViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.users import ParamUserSerializer | ||||||
| from authentik.core.api.utils import MetaNameSerializer | from authentik.core.api.utils import MetaNameSerializer | ||||||
| from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice | from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice | ||||||
| from authentik.stages.authenticator import device_classes, devices_for_user | from authentik.stages.authenticator import device_classes, devices_for_user | ||||||
| @ -23,7 +22,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | |||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceSerializer(MetaNameSerializer): | class DeviceSerializer(MetaNameSerializer): | ||||||
|     """Serializer for Duo authenticator devices""" |     """Serializer for authenticator devices""" | ||||||
|  |  | ||||||
|     pk = CharField() |     pk = CharField() | ||||||
|     name = CharField() |     name = CharField() | ||||||
| @ -33,22 +32,27 @@ class DeviceSerializer(MetaNameSerializer): | |||||||
|     last_updated = DateTimeField(read_only=True) |     last_updated = DateTimeField(read_only=True) | ||||||
|     last_used = DateTimeField(read_only=True, allow_null=True) |     last_used = DateTimeField(read_only=True, allow_null=True) | ||||||
|     extra_description = SerializerMethodField() |     extra_description = SerializerMethodField() | ||||||
|  |     external_id = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_type(self, instance: Device) -> str: |     def get_type(self, instance: Device) -> str: | ||||||
|         """Get type of device""" |         """Get type of device""" | ||||||
|         return instance._meta.label |         return instance._meta.label | ||||||
|  |  | ||||||
|     def get_extra_description(self, instance: Device) -> str: |     def get_extra_description(self, instance: Device) -> str | None: | ||||||
|         """Get extra description""" |         """Get extra description""" | ||||||
|         if isinstance(instance, WebAuthnDevice): |         if isinstance(instance, WebAuthnDevice): | ||||||
|             return ( |             return instance.device_type.description if instance.device_type else None | ||||||
|                 instance.device_type.description |  | ||||||
|                 if instance.device_type |  | ||||||
|                 else _("Extra description not available") |  | ||||||
|             ) |  | ||||||
|         if isinstance(instance, EndpointDevice): |         if isinstance(instance, EndpointDevice): | ||||||
|             return instance.data.get("deviceSignals", {}).get("deviceModel") |             return instance.data.get("deviceSignals", {}).get("deviceModel") | ||||||
|         return "" |         return None | ||||||
|  |  | ||||||
|  |     def get_external_id(self, instance: Device) -> str | None: | ||||||
|  |         """Get external Device ID""" | ||||||
|  |         if isinstance(instance, WebAuthnDevice): | ||||||
|  |             return instance.device_type.aaguid if instance.device_type else None | ||||||
|  |         if isinstance(instance, EndpointDevice): | ||||||
|  |             return instance.data.get("deviceSignals", {}).get("deviceModel") | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceViewSet(ViewSet): | class DeviceViewSet(ViewSet): | ||||||
| @ -57,7 +61,6 @@ class DeviceViewSet(ViewSet): | |||||||
|     serializer_class = DeviceSerializer |     serializer_class = DeviceSerializer | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAuthenticated] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: DeviceSerializer(many=True)}) |  | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Get all devices for current user""" |         """Get all devices for current user""" | ||||||
|         devices = devices_for_user(request.user) |         devices = devices_for_user(request.user) | ||||||
| @ -79,18 +82,11 @@ class AdminDeviceViewSet(ViewSet): | |||||||
|             yield from device_set |             yield from device_set | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         parameters=[ |         parameters=[ParamUserSerializer], | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="user", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 type=OpenApiTypes.INT, |  | ||||||
|             ) |  | ||||||
|         ], |  | ||||||
|         responses={200: DeviceSerializer(many=True)}, |         responses={200: DeviceSerializer(many=True)}, | ||||||
|     ) |     ) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Get all devices for current user""" |         """Get all devices for current user""" | ||||||
|         kwargs = {} |         args = ParamUserSerializer(data=request.query_params) | ||||||
|         if "user" in request.query_params: |         args.is_valid(raise_exception=True) | ||||||
|             kwargs = {"user": request.query_params["user"]} |         return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data) | ||||||
|         return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data) |  | ||||||
|  | |||||||
| @ -90,6 +90,12 @@ from authentik.stages.email.utils import TemplateEmailMessage | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ParamUserSerializer(PassiveSerializer): | ||||||
|  |     """Partial serializer for query parameters to select a user""" | ||||||
|  |  | ||||||
|  |     user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserGroupSerializer(ModelSerializer): | class UserGroupSerializer(ModelSerializer): | ||||||
|     """Simplified Group Serializer for user's groups""" |     """Simplified Group Serializer for user's groups""" | ||||||
|  |  | ||||||
| @ -401,7 +407,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             StrField(User, "path"), |             StrField(User, "path"), | ||||||
|             BoolField(User, "is_active", nullable=True), |             BoolField(User, "is_active", nullable=True), | ||||||
|             ChoiceSearchField(User, "type"), |             ChoiceSearchField(User, "type"), | ||||||
|             JSONSearchField(User, "attributes"), |             JSONSearchField(User, "attributes", suggest_nested=False), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
|  | from django.db import models | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from drf_spectacular.extensions import OpenApiSerializerFieldExtension | from drf_spectacular.extensions import OpenApiSerializerFieldExtension | ||||||
| from drf_spectacular.plumbing import build_basic_type | from drf_spectacular.plumbing import build_basic_type | ||||||
| @ -30,7 +31,27 @@ def is_dict(value: Any): | |||||||
|     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") |     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JSONDictField(JSONField): | ||||||
|  |     """JSON Field which only allows dictionaries""" | ||||||
|  |  | ||||||
|  |     default_validators = [is_dict] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JSONExtension(OpenApiSerializerFieldExtension): | ||||||
|  |     """Generate API Schema for JSON fields as""" | ||||||
|  |  | ||||||
|  |     target_class = "authentik.core.api.utils.JSONDictField" | ||||||
|  |  | ||||||
|  |     def map_serializer_field(self, auto_schema, direction): | ||||||
|  |         return build_basic_type(OpenApiTypes.OBJECT) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ModelSerializer(BaseModelSerializer): | class ModelSerializer(BaseModelSerializer): | ||||||
|  |  | ||||||
|  |     # By default, JSON fields we have are used to store dictionaries | ||||||
|  |     serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy() | ||||||
|  |     serializer_field_mapping[models.JSONField] = JSONDictField | ||||||
|  |  | ||||||
|     def create(self, validated_data): |     def create(self, validated_data): | ||||||
|         instance = super().create(validated_data) |         instance = super().create(validated_data) | ||||||
|  |  | ||||||
| @ -71,21 +92,6 @@ class ModelSerializer(BaseModelSerializer): | |||||||
|         return instance |         return instance | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONDictField(JSONField): |  | ||||||
|     """JSON Field which only allows dictionaries""" |  | ||||||
|  |  | ||||||
|     default_validators = [is_dict] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONExtension(OpenApiSerializerFieldExtension): |  | ||||||
|     """Generate API Schema for JSON fields as""" |  | ||||||
|  |  | ||||||
|     target_class = "authentik.core.api.utils.JSONDictField" |  | ||||||
|  |  | ||||||
|     def map_serializer_field(self, auto_schema, direction): |  | ||||||
|         return build_basic_type(OpenApiTypes.OBJECT) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PassiveSerializer(Serializer): | class PassiveSerializer(Serializer): | ||||||
|     """Base serializer class which doesn't implement create/update methods""" |     """Base serializer class which doesn't implement create/update methods""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ from authentik.core.expression.exceptions import SkipObjectException | |||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
| PROPERTY_MAPPING_TIME = Histogram( | PROPERTY_MAPPING_TIME = Histogram( | ||||||
| @ -68,11 +69,12 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|         # For dry-run requests we don't save exceptions |         # For dry-run requests we don't save exceptions | ||||||
|         if self.dry_run: |         if self.dry_run: | ||||||
|             return |             return | ||||||
|  |         error_string = exception_to_string(exc) | ||||||
|         event = Event.new( |         event = Event.new( | ||||||
|             EventAction.PROPERTY_MAPPING_EXCEPTION, |             EventAction.PROPERTY_MAPPING_EXCEPTION, | ||||||
|             expression=expression_source, |             expression=expression_source, | ||||||
|             message="Failed to execute property mapping", |             message=error_string, | ||||||
|         ).with_exception(exc) |         ) | ||||||
|         if "request" in self._context: |         if "request" in self._context: | ||||||
|             req: PolicyRequest = self._context["request"] |             req: PolicyRequest = self._context["request"] | ||||||
|             if req.http_request: |             if req.http_request: | ||||||
|  | |||||||
| @ -13,7 +13,6 @@ class Command(TenantCommand): | |||||||
|         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() | ||||||
|  | |||||||
| @ -1082,6 +1082,12 @@ class AuthenticatedSession(SerializerModel): | |||||||
|  |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> type[Serializer]: | ||||||
|  |         from authentik.core.api.authenticated_sessions import AuthenticatedSessionSerializer | ||||||
|  |  | ||||||
|  |         return AuthenticatedSessionSerializer | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         verbose_name = _("Authenticated Session") |         verbose_name = _("Authenticated Session") | ||||||
|         verbose_name_plural = _("Authenticated Sessions") |         verbose_name_plural = _("Authenticated Sessions") | ||||||
|  | |||||||
| @ -1,10 +1,8 @@ | |||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_out |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import post_delete, post_save, pre_delete | from django.db.models.signals import post_delete, post_save, pre_delete | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http.request import HttpRequest |  | ||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
|  |  | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
| @ -62,31 +60,6 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: | |||||||
|             instance.save() |             instance.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) |  | ||||||
| def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_): |  | ||||||
|     """Session revoked trigger (user logged out)""" |  | ||||||
|     if not request.session or not request.session.session_key or not user: |  | ||||||
|         return |  | ||||||
|     send_ssf_event( |  | ||||||
|         EventTypes.CAEP_SESSION_REVOKED, |  | ||||||
|         { |  | ||||||
|             "initiating_entity": "user", |  | ||||||
|         }, |  | ||||||
|         sub_id={ |  | ||||||
|             "format": "complex", |  | ||||||
|             "session": { |  | ||||||
|                 "format": "opaque", |  | ||||||
|                 "id": sha256(request.session.session_key.encode("ascii")).hexdigest(), |  | ||||||
|             }, |  | ||||||
|             "user": { |  | ||||||
|                 "format": "email", |  | ||||||
|                 "email": user.email, |  | ||||||
|             }, |  | ||||||
|         }, |  | ||||||
|         request=request, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=AuthenticatedSession) | @receiver(pre_delete, sender=AuthenticatedSession) | ||||||
| def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): | def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): | ||||||
|     """Session revoked trigger (users' session has been deleted) |     """Session revoked trigger (users' session has been deleted) | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from djangoql.ast import Name | |||||||
| from djangoql.exceptions import DjangoQLError | from djangoql.exceptions import DjangoQLError | ||||||
| from djangoql.queryset import apply_search | from djangoql.queryset import apply_search | ||||||
| from djangoql.schema import DjangoQLSchema | from djangoql.schema import DjangoQLSchema | ||||||
| from rest_framework.filters import SearchFilter | from rest_framework.filters import BaseFilterBackend, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -39,19 +39,21 @@ class BaseSchema(DjangoQLSchema): | |||||||
|         return super().resolve_name(name) |         return super().resolve_name(name) | ||||||
|  |  | ||||||
|  |  | ||||||
| class QLSearch(SearchFilter): | class QLSearch(BaseFilterBackend): | ||||||
|     """rest_framework search filter which uses DjangoQL""" |     """rest_framework search filter which uses DjangoQL""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         super().__init__() | ||||||
|  |         self._fallback = SearchFilter() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def enabled(self): |     def enabled(self): | ||||||
|         return apps.get_app_config("authentik_enterprise").enabled() |         return apps.get_app_config("authentik_enterprise").enabled() | ||||||
|  |  | ||||||
|     def get_search_terms(self, request) -> str: |     def get_search_terms(self, request: Request) -> str: | ||||||
|         """ |         """Search terms are set by a ?search=... query parameter, | ||||||
|         Search terms are set by a ?search=... query parameter, |         and may be comma and/or whitespace delimited.""" | ||||||
|         and may be comma and/or whitespace delimited. |         params = request.query_params.get("search", "") | ||||||
|         """ |  | ||||||
|         params = request.query_params.get(self.search_param, "") |  | ||||||
|         params = params.replace("\x00", "")  # strip null characters |         params = params.replace("\x00", "")  # strip null characters | ||||||
|         return params |         return params | ||||||
|  |  | ||||||
| @ -70,9 +72,9 @@ class QLSearch(SearchFilter): | |||||||
|         search_query = self.get_search_terms(request) |         search_query = self.get_search_terms(request) | ||||||
|         schema = self.get_schema(request, view) |         schema = self.get_schema(request, view) | ||||||
|         if len(search_query) == 0 or not self.enabled: |         if len(search_query) == 0 or not self.enabled: | ||||||
|             return super().filter_queryset(request, queryset, view) |             return self._fallback.filter_queryset(request, queryset, view) | ||||||
|         try: |         try: | ||||||
|             return apply_search(queryset, search_query, schema=schema) |             return apply_search(queryset, search_query, schema=schema) | ||||||
|         except DjangoQLError as exc: |         except DjangoQLError as exc: | ||||||
|             LOGGER.debug("Failed to parse search expression", exc=exc) |             LOGGER.debug("Failed to parse search expression", exc=exc) | ||||||
|             return super().filter_queryset(request, queryset, view) |             return self._fallback.filter_queryset(request, queryset, view) | ||||||
|  | |||||||
| @ -57,7 +57,7 @@ class QLTest(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(res.status_code, 200) |         self.assertEqual(res.status_code, 200) | ||||||
|         content = loads(res.content) |         content = loads(res.content) | ||||||
|         self.assertGreaterEqual(content["pagination"]["count"], 1) |         self.assertEqual(content["pagination"]["count"], 1) | ||||||
|         self.assertEqual(content["results"][0]["username"], self.user.username) |         self.assertEqual(content["results"][0]["username"], self.user.username) | ||||||
|  |  | ||||||
|     def test_search_json(self): |     def test_search_json(self): | ||||||
|  | |||||||
| @ -97,6 +97,7 @@ class SourceStageFinal(StageView): | |||||||
|         token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) |         token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||||
|         self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) |         self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) | ||||||
|         plan = token.plan |         plan = token.plan | ||||||
|  |         plan.context.update(self.executor.plan.context) | ||||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = token |         plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||||
|         response = plan.to_redirect(self.request, token.flow) |         response = plan.to_redirect(self.request, token.flow) | ||||||
|         token.delete() |         token.delete() | ||||||
|  | |||||||
| @ -90,10 +90,12 @@ class TestSourceStage(FlowTestCase): | |||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) |         plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) | ||||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token |         plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token | ||||||
|  |         plan.context["foo"] = "bar" | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|  |  | ||||||
|         # Pretend we've just returned from the source |         # Pretend we've just returned from the source | ||||||
|  |         with self.assertFlowFinishes() as ff: | ||||||
|             response = self.client.get( |             response = self.client.get( | ||||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True |                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True | ||||||
|             ) |             ) | ||||||
| @ -101,3 +103,4 @@ class TestSourceStage(FlowTestCase): | |||||||
|             self.assertStageRedirects( |             self.assertStageRedirects( | ||||||
|                 response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) |                 response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||||
|             ) |             ) | ||||||
|  |         self.assertEqual(ff().context["foo"], "bar") | ||||||
|  | |||||||
| @ -19,8 +19,8 @@ from authentik.blueprints.v1.importer import excluded_models | |||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.utils import model_to_dict | from authentik.events.utils import model_to_dict | ||||||
| from authentik.lib.sentry import before_send | from authentik.lib.sentry import should_ignore_exception | ||||||
| from authentik.lib.utils.errors import exception_to_dict | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.stages.authenticator_static.models import StaticToken | from authentik.stages.authenticator_static.models import StaticToken | ||||||
|  |  | ||||||
| IGNORED_MODELS = tuple( | IGNORED_MODELS = tuple( | ||||||
| @ -170,16 +170,14 @@ class AuditMiddleware: | |||||||
|             thread = EventNewThread( |             thread = EventNewThread( | ||||||
|                 EventAction.SUSPICIOUS_REQUEST, |                 EventAction.SUSPICIOUS_REQUEST, | ||||||
|                 request, |                 request, | ||||||
|                 message=str(exception), |                 message=exception_to_string(exception), | ||||||
|                 exception=exception_to_dict(exception), |  | ||||||
|             ) |             ) | ||||||
|             thread.run() |             thread.run() | ||||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: |         elif not should_ignore_exception(exception): | ||||||
|             thread = EventNewThread( |             thread = EventNewThread( | ||||||
|                 EventAction.SYSTEM_EXCEPTION, |                 EventAction.SYSTEM_EXCEPTION, | ||||||
|                 request, |                 request, | ||||||
|                 message=str(exception), |                 message=exception_to_string(exception), | ||||||
|                 exception=exception_to_dict(exception), |  | ||||||
|             ) |             ) | ||||||
|             thread.run() |             thread.run() | ||||||
|  |  | ||||||
|  | |||||||
| @ -38,7 +38,6 @@ from authentik.events.utils import ( | |||||||
| ) | ) | ||||||
| from authentik.lib.models import DomainlessURLValidator, SerializerModel | from authentik.lib.models import DomainlessURLValidator, SerializerModel | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.errors import exception_to_dict |  | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.utils.http import get_http_session | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
| @ -164,12 +163,6 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|         event = Event(action=action, app=app, context=cleaned_kwargs) |         event = Event(action=action, app=app, context=cleaned_kwargs) | ||||||
|         return event |         return event | ||||||
|  |  | ||||||
|     def with_exception(self, exc: Exception) -> "Event": |  | ||||||
|         """Add data from 'exc' to the event in a database-saveable format""" |  | ||||||
|         self.context.setdefault("message", str(exc)) |  | ||||||
|         self.context["exception"] = exception_to_dict(exc) |  | ||||||
|         return self |  | ||||||
|  |  | ||||||
|     def set_user(self, user: User) -> "Event": |     def set_user(self, user: User) -> "Event": | ||||||
|         """Set `.user` based on user, ensuring the correct attributes are copied. |         """Set `.user` based on user, ensuring the correct attributes are copied. | ||||||
|         This should only be used when self.from_http is *not* used.""" |         This should only be used when self.from_http is *not* used.""" | ||||||
| @ -200,17 +193,32 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|             brand: Brand = request.brand |             brand: Brand = request.brand | ||||||
|             self.brand = sanitize_dict(model_to_dict(brand)) |             self.brand = sanitize_dict(model_to_dict(brand)) | ||||||
|         if hasattr(request, "user"): |         if hasattr(request, "user"): | ||||||
|             original_user = None |             self.user = get_user(request.user) | ||||||
|             if hasattr(request, "session"): |  | ||||||
|                 original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None) |  | ||||||
|             self.user = get_user(request.user, original_user) |  | ||||||
|         if user: |         if user: | ||||||
|             self.user = get_user(user) |             self.user = get_user(user) | ||||||
|         # Check if we're currently impersonating, and add that user |  | ||||||
|         if hasattr(request, "session"): |         if hasattr(request, "session"): | ||||||
|  |             from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
|  |  | ||||||
|  |             # Check if we're currently impersonating, and add that user | ||||||
|             if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: |             if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: | ||||||
|                 self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) |                 self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) | ||||||
|                 self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) |                 self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) | ||||||
|  |             # Special case for events that happen during a flow, the user might not be authenticated | ||||||
|  |             # yet but is a pending user instead | ||||||
|  |             if SESSION_KEY_PLAN in request.session: | ||||||
|  |                 from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||||
|  |  | ||||||
|  |                 plan: FlowPlan = request.session[SESSION_KEY_PLAN] | ||||||
|  |                 pending_user = plan.context.get(PLAN_CONTEXT_PENDING_USER, None) | ||||||
|  |                 # Only save `authenticated_as` if there's a different pending user in the flow | ||||||
|  |                 # than the user that is authenticated | ||||||
|  |                 if pending_user and ( | ||||||
|  |                     (pending_user.pk and pending_user.pk != self.user.get("pk")) | ||||||
|  |                     or (not pending_user.pk) | ||||||
|  |                 ): | ||||||
|  |                     orig_user = self.user.copy() | ||||||
|  |  | ||||||
|  |                     self.user = {"authenticated_as": orig_user, **get_user(pending_user)} | ||||||
|         # User 255.255.255.255 as fallback if IP cannot be determined |         # User 255.255.255.255 as fallback if IP cannot be determined | ||||||
|         self.client_ip = ClientIPMiddleware.get_client_ip(request) |         self.client_ip = ClientIPMiddleware.get_client_ip(request) | ||||||
|         # Enrich event data |         # Enrich event data | ||||||
|  | |||||||
| @ -127,8 +127,8 @@ class SystemTask(TenantTask): | |||||||
|         ) |         ) | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SYSTEM_TASK_EXCEPTION, |             EventAction.SYSTEM_TASK_EXCEPTION, | ||||||
|             message=f"Task {self.__name__} encountered an error", |             message=f"Task {self.__name__} encountered an error: {exception_to_string(exc)}", | ||||||
|         ).with_exception(exc).save() |         ).save() | ||||||
|  |  | ||||||
|     def run(self, *args, **kwargs): |     def run(self, *args, **kwargs): | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  | |||||||
| @ -2,7 +2,9 @@ | |||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.events.context_processors.base import get_context_processors | ||||||
| from authentik.events.context_processors.geoip import GeoIPContextProcessor | from authentik.events.context_processors.geoip import GeoIPContextProcessor | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestGeoIP(TestCase): | class TestGeoIP(TestCase): | ||||||
| @ -13,8 +15,7 @@ class TestGeoIP(TestCase): | |||||||
|  |  | ||||||
|     def test_simple(self): |     def test_simple(self): | ||||||
|         """Test simple city wrapper""" |         """Test simple city wrapper""" | ||||||
|         # IPs from |         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||||
|         # https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json |  | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             self.reader.city_dict("2.125.160.216"), |             self.reader.city_dict("2.125.160.216"), | ||||||
|             { |             { | ||||||
| @ -25,3 +26,12 @@ class TestGeoIP(TestCase): | |||||||
|                 "long": -1.25, |                 "long": -1.25, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_special_chars(self): | ||||||
|  |         """Test city name with special characters""" | ||||||
|  |         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||||
|  |         event = Event.new(EventAction.LOGIN) | ||||||
|  |         event.client_ip = "89.160.20.112" | ||||||
|  |         for processor in get_context_processors(): | ||||||
|  |             processor.enrich_event(event) | ||||||
|  |         event.save() | ||||||
|  | |||||||
| @ -8,9 +8,11 @@ from django.views.debug import SafeExceptionReporterFilter | |||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.models import Group | from authentik.core.models import Group, User | ||||||
|  | from authentik.core.tests.utils import create_test_user | ||||||
| from authentik.events.models import Event | from authentik.events.models import Event | ||||||
| from authentik.flows.views.executor import QS_QUERY | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||||
|  | from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
|  |  | ||||||
| @ -116,3 +118,92 @@ class TestEvents(TestCase): | |||||||
|                 "pk": brand.pk.hex, |                 "pk": brand.pk.hex, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_from_http_flow_pending_user(self): | ||||||
|  |         """Test request from flow request with a pending user""" | ||||||
|  |         user = create_test_user() | ||||||
|  |  | ||||||
|  |         session = self.client.session | ||||||
|  |         plan = FlowPlan(generate_id()) | ||||||
|  |         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|  |         session.save() | ||||||
|  |  | ||||||
|  |         request = self.factory.get("/") | ||||||
|  |         request.session = session | ||||||
|  |         request.user = user | ||||||
|  |  | ||||||
|  |         event = Event.new("unittest").from_http(request) | ||||||
|  |         self.assertEqual( | ||||||
|  |             event.user, | ||||||
|  |             { | ||||||
|  |                 "email": user.email, | ||||||
|  |                 "pk": user.pk, | ||||||
|  |                 "username": user.username, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_from_http_flow_pending_user_anon(self): | ||||||
|  |         """Test request from flow request with a pending user""" | ||||||
|  |         user = create_test_user() | ||||||
|  |         anon = get_anonymous_user() | ||||||
|  |  | ||||||
|  |         session = self.client.session | ||||||
|  |         plan = FlowPlan(generate_id()) | ||||||
|  |         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|  |         session.save() | ||||||
|  |  | ||||||
|  |         request = self.factory.get("/") | ||||||
|  |         request.session = session | ||||||
|  |         request.user = anon | ||||||
|  |  | ||||||
|  |         event = Event.new("unittest").from_http(request) | ||||||
|  |         self.assertEqual( | ||||||
|  |             event.user, | ||||||
|  |             { | ||||||
|  |                 "authenticated_as": { | ||||||
|  |                     "pk": anon.pk, | ||||||
|  |                     "is_anonymous": True, | ||||||
|  |                     "username": "AnonymousUser", | ||||||
|  |                     "email": "", | ||||||
|  |                 }, | ||||||
|  |                 "email": user.email, | ||||||
|  |                 "pk": user.pk, | ||||||
|  |                 "username": user.username, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_from_http_flow_pending_user_fake(self): | ||||||
|  |         """Test request from flow request with a pending user""" | ||||||
|  |         user = User( | ||||||
|  |             username=generate_id(), | ||||||
|  |             email=generate_id(), | ||||||
|  |         ) | ||||||
|  |         anon = get_anonymous_user() | ||||||
|  |  | ||||||
|  |         session = self.client.session | ||||||
|  |         plan = FlowPlan(generate_id()) | ||||||
|  |         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|  |         session.save() | ||||||
|  |  | ||||||
|  |         request = self.factory.get("/") | ||||||
|  |         request.session = session | ||||||
|  |         request.user = anon | ||||||
|  |  | ||||||
|  |         event = Event.new("unittest").from_http(request) | ||||||
|  |         self.assertEqual( | ||||||
|  |             event.user, | ||||||
|  |             { | ||||||
|  |                 "authenticated_as": { | ||||||
|  |                     "pk": anon.pk, | ||||||
|  |                     "is_anonymous": True, | ||||||
|  |                     "username": "AnonymousUser", | ||||||
|  |                     "email": "", | ||||||
|  |                 }, | ||||||
|  |                 "email": user.email, | ||||||
|  |                 "pk": user.pk, | ||||||
|  |                 "username": user.username, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -74,8 +74,8 @@ def model_to_dict(model: Model) -> dict[str, Any]: | |||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_user(user: User | AnonymousUser, original_user: User | None = None) -> dict[str, Any]: | def get_user(user: User | AnonymousUser) -> dict[str, Any]: | ||||||
|     """Convert user object to dictionary, optionally including the original user""" |     """Convert user object to dictionary""" | ||||||
|     if isinstance(user, AnonymousUser): |     if isinstance(user, AnonymousUser): | ||||||
|         try: |         try: | ||||||
|             user = get_anonymous_user() |             user = get_anonymous_user() | ||||||
| @ -88,10 +88,6 @@ def get_user(user: User | AnonymousUser, original_user: User | None = None) -> d | |||||||
|     } |     } | ||||||
|     if user.username == settings.ANONYMOUS_USER_NAME: |     if user.username == settings.ANONYMOUS_USER_NAME: | ||||||
|         user_data["is_anonymous"] = True |         user_data["is_anonymous"] = True | ||||||
|     if original_user: |  | ||||||
|         original_data = get_user(original_user) |  | ||||||
|         original_data["on_behalf_of"] = user_data |  | ||||||
|         return original_data |  | ||||||
|     return user_data |     return user_data | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,8 +4,10 @@ from unittest.mock import MagicMock, PropertyMock, patch | |||||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.test import override_settings | ||||||
| from django.test.client import RequestFactory | from django.test.client import RequestFactory | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  | from rest_framework.exceptions import ParseError | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | from authentik.core.tests.utils import create_test_flow, create_test_user | ||||||
| @ -648,3 +650,25 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|             self.assertStageResponse(response, flow, component="ak-stage-identification") |             self.assertStageResponse(response, flow, component="ak-stage-identification") | ||||||
|             response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True) |             response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True) | ||||||
|             self.assertStageResponse(response, flow, component="ak-stage-access-denied") |             self.assertStageResponse(response, flow, component="ak-stage-access-denied") | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "authentik.flows.views.executor.to_stage_response", | ||||||
|  |         TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|  |     def test_invalid_json(self): | ||||||
|  |         """Test invalid JSON body""" | ||||||
|  |         flow = create_test_flow() | ||||||
|  |         FlowStageBinding.objects.create( | ||||||
|  |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|  |         ) | ||||||
|  |         url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) | ||||||
|  |  | ||||||
|  |         with override_settings(TEST=False, DEBUG=False): | ||||||
|  |             self.client.logout() | ||||||
|  |             response = self.client.post(url, data="{", content_type="application/json") | ||||||
|  |             self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         with self.assertRaises(ParseError): | ||||||
|  |             self.client.logout() | ||||||
|  |             response = self.client.post(url, data="{", content_type="application/json") | ||||||
|  |             self.assertEqual(response.status_code, 200) | ||||||
|  | |||||||
| @ -55,7 +55,8 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
| from authentik.flows.stage import AccessDeniedStage, StageView | from authentik.flows.stage import AccessDeniedStage, StageView | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | from authentik.lib.utils.reflection import all_subclasses, class_to_path | ||||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| @ -233,12 +234,13 @@ class FlowExecutorView(APIView): | |||||||
|         """Handle exception in stage execution""" |         """Handle exception in stage execution""" | ||||||
|         if settings.DEBUG or settings.TEST: |         if settings.DEBUG or settings.TEST: | ||||||
|             raise exc |             raise exc | ||||||
|         capture_exception(exc) |  | ||||||
|         self._logger.warning(exc) |         self._logger.warning(exc) | ||||||
|  |         if not should_ignore_exception(exc): | ||||||
|  |             capture_exception(exc) | ||||||
|             Event.new( |             Event.new( | ||||||
|                 action=EventAction.SYSTEM_EXCEPTION, |                 action=EventAction.SYSTEM_EXCEPTION, | ||||||
|             message="System exception during flow execution.", |                 message=exception_to_string(exc), | ||||||
|         ).with_exception(exc).from_http(self.request) |             ).from_http(self.request) | ||||||
|         challenge = FlowErrorChallenge(self.request, exc) |         challenge = FlowErrorChallenge(self.request, exc) | ||||||
|         challenge.is_valid(raise_exception=True) |         challenge.is_valid(raise_exception=True) | ||||||
|         return to_stage_response(self.request, HttpChallengeResponse(challenge)) |         return to_stage_response(self.request, HttpChallengeResponse(challenge)) | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from django_redis.exceptions import ConnectionInterrupted | |||||||
| from docker.errors import DockerException | from docker.errors import DockerException | ||||||
| from h11 import LocalProtocolError | from h11 import LocalProtocolError | ||||||
| from ldap3.core.exceptions import LDAPException | from ldap3.core.exceptions import LDAPException | ||||||
|  | from psycopg.errors import Error | ||||||
| from redis.exceptions import ConnectionError as RedisConnectionError | from redis.exceptions import ConnectionError as RedisConnectionError | ||||||
| from redis.exceptions import RedisError, ResponseError | from redis.exceptions import RedisError, ResponseError | ||||||
| from rest_framework.exceptions import APIException | from rest_framework.exceptions import APIException | ||||||
| @ -44,6 +45,49 @@ class SentryIgnoredException(Exception): | |||||||
|     """Base Class for all errors that are suppressed, and not sent to sentry.""" |     """Base Class for all errors that are suppressed, and not sent to sentry.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ignored_classes = ( | ||||||
|  |     # Inbuilt types | ||||||
|  |     KeyboardInterrupt, | ||||||
|  |     ConnectionResetError, | ||||||
|  |     OSError, | ||||||
|  |     PermissionError, | ||||||
|  |     # Django Errors | ||||||
|  |     Error, | ||||||
|  |     ImproperlyConfigured, | ||||||
|  |     DatabaseError, | ||||||
|  |     OperationalError, | ||||||
|  |     InternalError, | ||||||
|  |     ProgrammingError, | ||||||
|  |     SuspiciousOperation, | ||||||
|  |     ValidationError, | ||||||
|  |     # Redis errors | ||||||
|  |     RedisConnectionError, | ||||||
|  |     ConnectionInterrupted, | ||||||
|  |     RedisError, | ||||||
|  |     ResponseError, | ||||||
|  |     # websocket errors | ||||||
|  |     ChannelFull, | ||||||
|  |     WebSocketException, | ||||||
|  |     LocalProtocolError, | ||||||
|  |     # rest_framework error | ||||||
|  |     APIException, | ||||||
|  |     # celery errors | ||||||
|  |     WorkerLostError, | ||||||
|  |     CeleryError, | ||||||
|  |     SoftTimeLimitExceeded, | ||||||
|  |     # custom baseclass | ||||||
|  |     SentryIgnoredException, | ||||||
|  |     # ldap errors | ||||||
|  |     LDAPException, | ||||||
|  |     # Docker errors | ||||||
|  |     DockerException, | ||||||
|  |     # End-user errors | ||||||
|  |     Http404, | ||||||
|  |     # AsyncIO | ||||||
|  |     CancelledError, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SentryTransport(HttpTransport): | class SentryTransport(HttpTransport): | ||||||
|     """Custom sentry transport with custom user-agent""" |     """Custom sentry transport with custom user-agent""" | ||||||
|  |  | ||||||
| @ -101,56 +145,17 @@ def traces_sampler(sampling_context: dict) -> float: | |||||||
|     return float(CONFIG.get("error_reporting.sample_rate", 0.1)) |     return float(CONFIG.get("error_reporting.sample_rate", 0.1)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def should_ignore_exception(exc: Exception) -> bool: | ||||||
|  |     """Check if an exception should be dropped""" | ||||||
|  |     return isinstance(exc, ignored_classes) | ||||||
|  |  | ||||||
|  |  | ||||||
| def before_send(event: dict, hint: dict) -> dict | None: | def before_send(event: dict, hint: dict) -> dict | None: | ||||||
|     """Check if error is database error, and ignore if so""" |     """Check if error is database error, and ignore if so""" | ||||||
|  |  | ||||||
|     from psycopg.errors import Error |  | ||||||
|  |  | ||||||
|     ignored_classes = ( |  | ||||||
|         # Inbuilt types |  | ||||||
|         KeyboardInterrupt, |  | ||||||
|         ConnectionResetError, |  | ||||||
|         OSError, |  | ||||||
|         PermissionError, |  | ||||||
|         # Django Errors |  | ||||||
|         Error, |  | ||||||
|         ImproperlyConfigured, |  | ||||||
|         DatabaseError, |  | ||||||
|         OperationalError, |  | ||||||
|         InternalError, |  | ||||||
|         ProgrammingError, |  | ||||||
|         SuspiciousOperation, |  | ||||||
|         ValidationError, |  | ||||||
|         # Redis errors |  | ||||||
|         RedisConnectionError, |  | ||||||
|         ConnectionInterrupted, |  | ||||||
|         RedisError, |  | ||||||
|         ResponseError, |  | ||||||
|         # websocket errors |  | ||||||
|         ChannelFull, |  | ||||||
|         WebSocketException, |  | ||||||
|         LocalProtocolError, |  | ||||||
|         # rest_framework error |  | ||||||
|         APIException, |  | ||||||
|         # celery errors |  | ||||||
|         WorkerLostError, |  | ||||||
|         CeleryError, |  | ||||||
|         SoftTimeLimitExceeded, |  | ||||||
|         # custom baseclass |  | ||||||
|         SentryIgnoredException, |  | ||||||
|         # ldap errors |  | ||||||
|         LDAPException, |  | ||||||
|         # Docker errors |  | ||||||
|         DockerException, |  | ||||||
|         # End-user errors |  | ||||||
|         Http404, |  | ||||||
|         # AsyncIO |  | ||||||
|         CancelledError, |  | ||||||
|     ) |  | ||||||
|     exc_value = None |     exc_value = None | ||||||
|     if "exc_info" in hint: |     if "exc_info" in hint: | ||||||
|         _, exc_value, _ = hint["exc_info"] |         _, exc_value, _ = hint["exc_info"] | ||||||
|         if isinstance(exc_value, ignored_classes): |         if should_ignore_exception(exc_value): | ||||||
|             LOGGER.debug("dropping exception", exc=exc_value) |             LOGGER.debug("dropping exception", exc=exc_value) | ||||||
|             return None |             return None | ||||||
|     if "logger" in event: |     if "logger" in event: | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.lib.expression.exceptions import ControlFlowException | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager | from authentik.lib.sync.mapper import PropertyMappingManager | ||||||
| from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync | from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from django.db.models import Model |     from django.db.models import Model | ||||||
| @ -105,9 +106,9 @@ class BaseOutgoingSyncClient[ | |||||||
|             # Value error can be raised when assigning invalid data to an attribute |             # Value error can be raised when assigning invalid data to an attribute | ||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message="Failed to evaluate property-mapping", |                 message=f"Failed to evaluate property-mapping {exception_to_string(exc)}", | ||||||
|                 mapping=exc.mapping, |                 mapping=exc.mapping, | ||||||
|             ).with_exception(exc).save() |             ).save() | ||||||
|             raise StopSync(exc, obj, exc.mapping) from exc |             raise StopSync(exc, obj, exc.mapping) from exc | ||||||
|         if not raw_final_object: |         if not raw_final_object: | ||||||
|             raise StopSync(ValueError("No mappings configured"), obj) |             raise StopSync(ValueError("No mappings configured"), obj) | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from authentik.lib.sentry import SentryIgnoredException, before_send | from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestSentry(TestCase): | class TestSentry(TestCase): | ||||||
| @ -10,8 +10,8 @@ class TestSentry(TestCase): | |||||||
|  |  | ||||||
|     def test_error_not_sent(self): |     def test_error_not_sent(self): | ||||||
|         """Test SentryIgnoredError not sent""" |         """Test SentryIgnoredError not sent""" | ||||||
|         self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)})) |         self.assertTrue(should_ignore_exception(SentryIgnoredException())) | ||||||
|  |  | ||||||
|     def test_error_sent(self): |     def test_error_sent(self): | ||||||
|         """Test error sent""" |         """Test error sent""" | ||||||
|         self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) |         self.assertFalse(should_ignore_exception(ValueError())) | ||||||
|  | |||||||
| @ -2,8 +2,6 @@ | |||||||
|  |  | ||||||
| from traceback import extract_tb | from traceback import extract_tb | ||||||
|  |  | ||||||
| from structlog.tracebacks import ExceptionDictTransformer |  | ||||||
|  |  | ||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
|  |  | ||||||
| TRACEBACK_HEADER = "Traceback (most recent call last):" | TRACEBACK_HEADER = "Traceback (most recent call last):" | ||||||
| @ -19,8 +17,3 @@ def exception_to_string(exc: Exception) -> str: | |||||||
|             f"{class_to_path(exc.__class__)}: {str(exc)}", |             f"{class_to_path(exc.__class__)}: {str(exc)}", | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def exception_to_dict(exc: Exception) -> dict: |  | ||||||
|     """Format exception as a dictionary""" |  | ||||||
|     return ExceptionDictTransformer()((type(exc), exc, exc.__traceback__)) |  | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.outposts.controllers.k8s.utils import get_namespace | from authentik.outposts.controllers.k8s.utils import get_namespace | ||||||
|  |  | ||||||
| OUR_VERSION = parse(__version__) | OUR_VERSION = parse(__version__) | ||||||
| @ -325,8 +326,9 @@ class Outpost(SerializerModel, ManagedModel): | |||||||
|                                 "While setting the permissions for the service-account, a " |                                 "While setting the permissions for the service-account, a " | ||||||
|                                 "permission was not found: Check " |                                 "permission was not found: Check " | ||||||
|                                 "https://goauthentik.io/docs/troubleshooting/missing_permission" |                                 "https://goauthentik.io/docs/troubleshooting/missing_permission" | ||||||
|                             ), |                             ) | ||||||
|                         ).with_exception(exc).set_user(user).save() |                             + exception_to_string(exc), | ||||||
|  |                         ).set_user(user).save() | ||||||
|                 else: |                 else: | ||||||
|                     app_label, perm = model_or_perm.split(".") |                     app_label, perm = model_or_perm.split(".") | ||||||
|                     permission = Permission.objects.filter( |                     permission = Permission.objects.filter( | ||||||
|  | |||||||
| @ -1,15 +1,13 @@ | |||||||
| """authentik outpost signals""" | """authentik outpost signals""" | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_out |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save | from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.models import AuthenticatedSession, Provider, User | from authentik.core.models import AuthenticatedSession, Provider | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||||
| @ -82,14 +80,6 @@ def pre_delete_cleanup(sender, instance: Outpost, **_): | |||||||
|     outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) |     outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) |  | ||||||
| def logout_revoke_direct(sender: type[User], request: HttpRequest, **_): |  | ||||||
|     """Catch logout by direct logout and forward to providers""" |  | ||||||
|     if not request.session or not request.session.session_key: |  | ||||||
|         return |  | ||||||
|     outpost_session_end.delay(request.session.session_key) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=AuthenticatedSession) | @receiver(pre_delete, sender=AuthenticatedSession) | ||||||
| def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): | def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): | ||||||
|     """Catch logout by expiring sessions being deleted""" |     """Catch logout by expiring sessions being deleted""" | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.errors import exception_to_dict | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| @ -95,13 +95,10 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|         except PolicyException as exc: |         except PolicyException as exc: | ||||||
|             # Either use passed original exception or whatever we have |             # Either use passed original exception or whatever we have | ||||||
|             src_exc = exc.src_exc if exc.src_exc else exc |             src_exc = exc.src_exc if exc.src_exc else exc | ||||||
|  |             error_string = exception_to_string(src_exc) | ||||||
|             # Create policy exception event, only when we're not debugging |             # Create policy exception event, only when we're not debugging | ||||||
|             if not self.request.debug: |             if not self.request.debug: | ||||||
|                 self.create_event( |                 self.create_event(EventAction.POLICY_EXCEPTION, message=error_string) | ||||||
|                     EventAction.POLICY_EXCEPTION, |  | ||||||
|                     message="Policy failed to execute", |  | ||||||
|                     exception=exception_to_dict(src_exc), |  | ||||||
|                 ) |  | ||||||
|             LOGGER.debug("P_ENG(proc): error, using failure result", exc=src_exc) |             LOGGER.debug("P_ENG(proc): error, using failure result", exc=src_exc) | ||||||
|             policy_result = PolicyResult(self.binding.failure_result, str(src_exc)) |             policy_result = PolicyResult(self.binding.failure_result, str(src_exc)) | ||||||
|         policy_result.source_binding = self.binding |         policy_result.source_binding = self.binding | ||||||
| @ -146,5 +143,5 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|         try: |         try: | ||||||
|             self.connection.send(self.profiling_wrapper()) |             self.connection.send(self.profiling_wrapper()) | ||||||
|         except Exception as exc: |         except Exception as exc: | ||||||
|             LOGGER.warning("Policy failed to run", exc=exc) |             LOGGER.warning("Policy failed to run", exc=exception_to_string(exc)) | ||||||
|             self.connection.send(PolicyResult(False, str(exc))) |             self.connection.send(PolicyResult(False, str(exc))) | ||||||
|  | |||||||
| @ -237,4 +237,4 @@ class TestPolicyProcess(TestCase): | |||||||
|         self.assertEqual(len(events), 1) |         self.assertEqual(len(events), 1) | ||||||
|         event = events.first() |         event = events.first() | ||||||
|         self.assertEqual(event.user["username"], self.user.username) |         self.assertEqual(event.user["username"], self.user.username) | ||||||
|         self.assertIn("Policy failed to execute", event.context["message"]) |         self.assertIn("division by zero", event.context["message"]) | ||||||
|  | |||||||
| @ -1,23 +1,10 @@ | |||||||
| from django.contrib.auth.signals import user_logged_out |  | ||||||
| from django.db.models.signals import post_save, pre_delete | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest |  | ||||||
|  |  | ||||||
| from authentik.core.models import AuthenticatedSession, User | from authentik.core.models import AuthenticatedSession, User | ||||||
| from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken | from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) |  | ||||||
| def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_): |  | ||||||
|     """Revoke tokens upon user logout""" |  | ||||||
|     if not request.session or not request.session.session_key: |  | ||||||
|         return |  | ||||||
|     AccessToken.objects.filter( |  | ||||||
|         user=user, |  | ||||||
|         session__session__session_key=request.session.session_key, |  | ||||||
|     ).delete() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=AuthenticatedSession) | @receiver(pre_delete, sender=AuthenticatedSession) | ||||||
| def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_): | def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_): | ||||||
|     """Revoke tokens upon user logout""" |     """Revoke tokens upon user logout""" | ||||||
|  | |||||||
| @ -66,7 +66,10 @@ class RACClientConsumer(AsyncWebsocketConsumer): | |||||||
|     def init_outpost_connection(self): |     def init_outpost_connection(self): | ||||||
|         """Initialize guac connection settings""" |         """Initialize guac connection settings""" | ||||||
|         self.token = ( |         self.token = ( | ||||||
|             ConnectionToken.filter_not_expired(token=self.scope["url_route"]["kwargs"]["token"]) |             ConnectionToken.filter_not_expired( | ||||||
|  |                 token=self.scope["url_route"]["kwargs"]["token"], | ||||||
|  |                 session__session__session_key=self.scope["session"].session_key, | ||||||
|  |             ) | ||||||
|             .select_related("endpoint", "provider", "session", "session__user") |             .select_related("endpoint", "provider", "session", "session__user") | ||||||
|             .first() |             .first() | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -2,13 +2,11 @@ | |||||||
|  |  | ||||||
| from asgiref.sync import async_to_sync | from asgiref.sync import async_to_sync | ||||||
| from channels.layers import get_channel_layer | from channels.layers import get_channel_layer | ||||||
| from django.contrib.auth.signals import user_logged_out |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models.signals import post_delete, post_save, pre_delete | from django.db.models.signals import post_delete, post_save, pre_delete | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest |  | ||||||
|  |  | ||||||
| from authentik.core.models import AuthenticatedSession, User | from authentik.core.models import AuthenticatedSession | ||||||
| from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | ||||||
| from authentik.providers.rac.consumer_client import ( | from authentik.providers.rac.consumer_client import ( | ||||||
|     RAC_CLIENT_GROUP_SESSION, |     RAC_CLIENT_GROUP_SESSION, | ||||||
| @ -17,21 +15,6 @@ from authentik.providers.rac.consumer_client import ( | |||||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint | from authentik.providers.rac.models import ConnectionToken, Endpoint | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) |  | ||||||
| def user_logged_out_session(sender, request: HttpRequest, user: User, **_): |  | ||||||
|     """Disconnect any open RAC connections""" |  | ||||||
|     if not request.session or not request.session.session_key: |  | ||||||
|         return |  | ||||||
|     layer = get_channel_layer() |  | ||||||
|     async_to_sync(layer.group_send)( |  | ||||||
|         RAC_CLIENT_GROUP_SESSION |  | ||||||
|         % { |  | ||||||
|             "session": request.session.session_key, |  | ||||||
|         }, |  | ||||||
|         {"type": "event.disconnect", "reason": "session_logout"}, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=AuthenticatedSession) | @receiver(pre_delete, sender=AuthenticatedSession) | ||||||
| def user_session_deleted(sender, instance: AuthenticatedSession, **_): | def user_session_deleted(sender, instance: AuthenticatedSession, **_): | ||||||
|     layer = get_channel_layer() |     layer = get_channel_layer() | ||||||
|  | |||||||
| @ -87,3 +87,22 @@ class TestRACViews(APITestCase): | |||||||
|         ) |         ) | ||||||
|         body = loads(flow_response.content) |         body = loads(flow_response.content) | ||||||
|         self.assertEqual(body["component"], "ak-stage-access-denied") |         self.assertEqual(body["component"], "ak-stage-access-denied") | ||||||
|  |  | ||||||
|  |     def test_different_session(self): | ||||||
|  |         """Test request""" | ||||||
|  |         self.client.force_login(self.user) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_providers_rac:start", | ||||||
|  |                 kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         flow_response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||||
|  |         ) | ||||||
|  |         body = loads(flow_response.content) | ||||||
|  |         next_url = body["to"] | ||||||
|  |         self.client.logout() | ||||||
|  |         final_response = self.client.get(next_url) | ||||||
|  |         self.assertEqual(final_response.url, reverse("authentik_core:if-user")) | ||||||
|  | |||||||
| @ -68,7 +68,10 @@ class RACInterface(InterfaceView): | |||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: |     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||||
|         # Early sanity check to ensure token still exists |         # Early sanity check to ensure token still exists | ||||||
|         token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first() |         token = ConnectionToken.filter_not_expired( | ||||||
|  |             token=self.kwargs["token"], | ||||||
|  |             session__session__session_key=request.session.session_key, | ||||||
|  |         ).first() | ||||||
|         if not token: |         if not token: | ||||||
|             return redirect("authentik_core:if-user") |             return redirect("authentik_core:if-user") | ||||||
|         self.token = token |         self.token = token | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ from authentik.core.models import Application | |||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.exceptions import ControlFlowException | from authentik.lib.expression.exceptions import ControlFlowException | ||||||
| from authentik.lib.sync.mapper import PropertyMappingManager | from authentik.lib.sync.mapper import PropertyMappingManager | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | from authentik.policies.api.exec import PolicyTestResultSerializer | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| @ -43,6 +44,7 @@ class RadiusProviderSerializer(ProviderSerializer): | |||||||
|             "shared_secret", |             "shared_secret", | ||||||
|             "outpost_set", |             "outpost_set", | ||||||
|             "mfa_support", |             "mfa_support", | ||||||
|  |             "certificate", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = ProviderSerializer.Meta.extra_kwargs |         extra_kwargs = ProviderSerializer.Meta.extra_kwargs | ||||||
|  |  | ||||||
| @ -78,6 +80,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer): | |||||||
|             "client_networks", |             "client_networks", | ||||||
|             "shared_secret", |             "shared_secret", | ||||||
|             "mfa_support", |             "mfa_support", | ||||||
|  |             "certificate", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -141,9 +144,9 @@ class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet): | |||||||
|             # Value error can be raised when assigning invalid data to an attribute |             # Value error can be raised when assigning invalid data to an attribute | ||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message="Failed to evaluate property-mapping", |                 message=f"Failed to evaluate property-mapping {exception_to_string(exc)}", | ||||||
|                 mapping=exc.mapping, |                 mapping=exc.mapping, | ||||||
|             ).with_exception(exc).save() |             ).save() | ||||||
|             return None |             return None | ||||||
|         return b64encode(packet.RequestPacket()).decode() |         return b64encode(packet.RequestPacket()).decode() | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 5.1.9 on 2025-05-16 13:53 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_crypto", "0004_alter_certificatekeypair_name"), | ||||||
|  |         ("authentik_providers_radius", "0004_alter_radiusproviderpropertymapping_options"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="radiusprovider", | ||||||
|  |             name="certificate", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="authentik_crypto.certificatekeypair", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,11 +1,14 @@ | |||||||
| """Radius Provider""" | """Radius Provider""" | ||||||
|  |  | ||||||
|  | from collections.abc import Iterable | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.templatetags.static import static | from django.templatetags.static import static | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
|  |  | ||||||
| from authentik.core.models import PropertyMapping, Provider | from authentik.core.models import PropertyMapping, Provider | ||||||
|  | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.outposts.models import OutpostModel | from authentik.outposts.models import OutpostModel | ||||||
|  |  | ||||||
| @ -38,6 +41,10 @@ class RadiusProvider(OutpostModel, Provider): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     certificate = models.ForeignKey( | ||||||
|  |         CertificateKeyPair, on_delete=models.CASCADE, default=None, null=True | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def launch_url(self) -> str | None: |     def launch_url(self) -> str | None: | ||||||
|         """Radius never has a launch URL""" |         """Radius never has a launch URL""" | ||||||
| @ -57,6 +64,12 @@ class RadiusProvider(OutpostModel, Provider): | |||||||
|  |  | ||||||
|         return RadiusProviderSerializer |         return RadiusProviderSerializer | ||||||
|  |  | ||||||
|  |     def get_required_objects(self) -> Iterable[models.Model | str]: | ||||||
|  |         required_models = [self, "authentik_stages_mtls.pass_outpost_certificate"] | ||||||
|  |         if self.certificate is not None: | ||||||
|  |             required_models.append(self.certificate) | ||||||
|  |         return required_models | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"Radius Provider {self.name}" |         return f"Radius Provider {self.name}" | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ from itertools import batched | |||||||
| from django.db import transaction | 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 |  | ||||||
|  |  | ||||||
| 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 | ||||||
| @ -20,7 +19,12 @@ 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, PatchOperation, PatchRequest | from authentik.providers.scim.clients.schema import ( | ||||||
|  |     SCIM_GROUP_SCHEMA, | ||||||
|  |     PatchOp, | ||||||
|  |     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, | ||||||
|  | |||||||
| @ -1,5 +1,7 @@ | |||||||
| """Custom SCIM schemas""" | """Custom SCIM schemas""" | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
| 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 PatchOperation as BasePatchOperation | ||||||
| @ -65,6 +67,21 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PatchOp(str, Enum): | ||||||
|  |  | ||||||
|  |     replace = "replace" | ||||||
|  |     remove = "remove" | ||||||
|  |     add = "add" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _missing_(cls, value): | ||||||
|  |         value = value.lower() | ||||||
|  |         for member in cls: | ||||||
|  |             if member.lower() == value: | ||||||
|  |                 return member | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |  | ||||||
| class PatchRequest(BasePatchRequest): | class PatchRequest(BasePatchRequest): | ||||||
|     """PatchRequest which correctly sets schemas""" |     """PatchRequest which correctly sets schemas""" | ||||||
|  |  | ||||||
| @ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest): | |||||||
| class PatchOperation(BasePatchOperation): | class PatchOperation(BasePatchOperation): | ||||||
|     """PatchOperation with optional path""" |     """PatchOperation with optional path""" | ||||||
|  |  | ||||||
|  |     op: PatchOp | ||||||
|     path: str | None |     path: str | None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -27,7 +27,8 @@ from structlog.stdlib import get_logger | |||||||
| from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp | from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp | ||||||
|  |  | ||||||
| from authentik import get_full_version | from authentik import get_full_version | ||||||
| from authentik.lib.sentry import before_send | from authentik.lib.sentry import should_ignore_exception | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| # set the default Django settings module for the 'celery' program. | # set the default Django settings module for the 'celery' program. | ||||||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") | ||||||
| @ -80,10 +81,10 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar | |||||||
|  |  | ||||||
|     LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception) |     LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception) | ||||||
|     CTX_TASK_ID.set(...) |     CTX_TASK_ID.set(...) | ||||||
|     if before_send({}, {"exc_info": (None, exception, None)}) is not None: |     if not should_ignore_exception(exception): | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SYSTEM_EXCEPTION, message="Failed to execute task", task_id=task_id |             EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id | ||||||
|         ).with_exception(exception).save() |         ).save() | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_startup_tasks_default_tenant() -> list[Callable]: | def _get_startup_tasks_default_tenant() -> list[Callable]: | ||||||
|  | |||||||
| @ -1,13 +1,49 @@ | |||||||
| """authentik database backend""" | """authentik database backend""" | ||||||
|  |  | ||||||
|  | from django.core.checks import Warning | ||||||
|  | from django.db.backends.base.validation import BaseDatabaseValidation | ||||||
| from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper | from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper | ||||||
|  |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DatabaseValidation(BaseDatabaseValidation): | ||||||
|  |  | ||||||
|  |     def check(self, **kwargs): | ||||||
|  |         return self._check_encoding() | ||||||
|  |  | ||||||
|  |     def _check_encoding(self): | ||||||
|  |         """Throw a warning when the server_encoding is not UTF-8 or | ||||||
|  |         server_encoding and client_encoding are mismatched""" | ||||||
|  |         messages = [] | ||||||
|  |         with self.connection.cursor() as cursor: | ||||||
|  |             cursor.execute("SHOW server_encoding;") | ||||||
|  |             server_encoding = cursor.fetchone()[0] | ||||||
|  |             cursor.execute("SHOW client_encoding;") | ||||||
|  |             client_encoding = cursor.fetchone()[0] | ||||||
|  |             if server_encoding != client_encoding: | ||||||
|  |                 messages.append( | ||||||
|  |                     Warning( | ||||||
|  |                         "PostgreSQL Server and Client encoding are mismatched: Server: " | ||||||
|  |                         f"{server_encoding}, Client: {client_encoding}", | ||||||
|  |                         id="ak.db.W001", | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             if server_encoding != "UTF8": | ||||||
|  |                 messages.append( | ||||||
|  |                     Warning( | ||||||
|  |                         f"PostgreSQL Server encoding is not UTF8: {server_encoding}", | ||||||
|  |                         id="ak.db.W002", | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |         return messages | ||||||
|  |  | ||||||
|  |  | ||||||
| class DatabaseWrapper(BaseDatabaseWrapper): | class DatabaseWrapper(BaseDatabaseWrapper): | ||||||
|     """database backend which supports rotating credentials""" |     """database backend which supports rotating credentials""" | ||||||
|  |  | ||||||
|  |     validation_class = DatabaseValidation | ||||||
|  |  | ||||||
|     def get_connection_params(self): |     def get_connection_params(self): | ||||||
|         """Refresh DB credentials before getting connection params""" |         """Refresh DB credentials before getting connection params""" | ||||||
|         conn_params = super().get_connection_params() |         conn_params = super().get_connection_params() | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from authentik.events.models import TaskStatus | |||||||
| from authentik.events.system_tasks import SystemTask | from authentik.events.system_tasks import SystemTask | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.sync.outgoing.exceptions import StopSync | from authentik.lib.sync.outgoing.exceptions import StopSync | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.sources.kerberos.models import KerberosSource | from authentik.sources.kerberos.models import KerberosSource | ||||||
| from authentik.sources.kerberos.sync import KerberosSync | from authentik.sources.kerberos.sync import KerberosSync | ||||||
| @ -63,5 +64,5 @@ def kerberos_sync_single(self, source_pk: str): | |||||||
|             syncer.sync() |             syncer.sync() | ||||||
|             self.set_status(TaskStatus.SUCCESSFUL, *syncer.messages) |             self.set_status(TaskStatus.SUCCESSFUL, *syncer.messages) | ||||||
|     except StopSync as exc: |     except StopSync as exc: | ||||||
|         LOGGER.warning("Error syncing kerberos", exc=exc, source=source) |         LOGGER.warning(exception_to_string(exc)) | ||||||
|         self.set_error(exc) |         self.set_error(exc) | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ from authentik.events.models import TaskStatus | |||||||
| from authentik.events.system_tasks import SystemTask | from authentik.events.system_tasks import SystemTask | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.sync.outgoing.exceptions import StopSync | from authentik.lib.sync.outgoing.exceptions import StopSync | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.reflection import class_to_path, path_to_class | from authentik.lib.utils.reflection import class_to_path, path_to_class | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.sources.ldap.models import LDAPSource | from authentik.sources.ldap.models import LDAPSource | ||||||
| @ -148,5 +149,5 @@ def ldap_sync(self: SystemTask, source_pk: str, sync_class: str, page_cache_key: | |||||||
|         cache.delete(page_cache_key) |         cache.delete(page_cache_key) | ||||||
|     except (LDAPException, StopSync) as exc: |     except (LDAPException, StopSync) as exc: | ||||||
|         # No explicit event is created here as .set_status with an error will do that |         # No explicit event is created here as .set_status with an error will do that | ||||||
|         LOGGER.warning("Failed to sync LDAP", exc=exc, source=source) |         LOGGER.warning(exception_to_string(exc)) | ||||||
|         self.set_error(exc) |         self.set_error(exc) | ||||||
|  | |||||||
							
								
								
									
										277
									
								
								authentik/sources/scim/tests/test_groups.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								authentik/sources/scim/tests/test_groups.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,277 @@ | |||||||
|  | """Test SCIM Group""" | ||||||
|  |  | ||||||
|  | from json import dumps | ||||||
|  | from uuid import uuid4 | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Group | ||||||
|  | from authentik.core.tests.utils import create_test_user | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema | ||||||
|  | from authentik.sources.scim.models import ( | ||||||
|  |     SCIMSource, | ||||||
|  |     SCIMSourceGroup, | ||||||
|  | ) | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMGroups(APITestCase): | ||||||
|  |     """Test SCIM Group view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id()) | ||||||
|  |  | ||||||
|  |     def test_group_list(self): | ||||||
|  |         """Test full group list""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_group_list_single(self): | ||||||
|  |         """Test full group list (single group)""" | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         user = create_test_user() | ||||||
|  |         group.users.add(user) | ||||||
|  |         SCIMSourceGroup.objects.create( | ||||||
|  |             source=self.source, | ||||||
|  |             group=group, | ||||||
|  |             id=str(uuid4()), | ||||||
|  |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "group_id": str(group.pk), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=200) | ||||||
|  |         SCIMGroupSchema.model_validate_json(response.content, strict=True) | ||||||
|  |  | ||||||
|  |     def test_group_create(self): | ||||||
|  |         """Test group create""" | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps({"displayName": generate_id(), "externalId": ext_id}), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||||
|  |         self.assertTrue( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_group_create_members(self): | ||||||
|  |         """Test group create""" | ||||||
|  |         user = create_test_user() | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 { | ||||||
|  |                     "displayName": generate_id(), | ||||||
|  |                     "externalId": ext_id, | ||||||
|  |                     "members": [{"value": str(user.uuid)}], | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||||
|  |         self.assertTrue( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_group_create_members_empty(self): | ||||||
|  |         """Test group create""" | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||||
|  |         self.assertTrue( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_group_create_duplicate(self): | ||||||
|  |         """Test group create (duplicate)""" | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)} | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 409) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content, | ||||||
|  |             { | ||||||
|  |                 "detail": "Group with ID exists already.", | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], | ||||||
|  |                 "scimType": "uniqueness", | ||||||
|  |                 "status": 409, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_group_update(self): | ||||||
|  |         """Test group update""" | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.put( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)} | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=200) | ||||||
|  |  | ||||||
|  |     def test_group_update_non_existent(self): | ||||||
|  |         """Test group update""" | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.put( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "group_id": str(uuid4()), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=404) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content, | ||||||
|  |             { | ||||||
|  |                 "detail": "Group not found.", | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], | ||||||
|  |                 "status": 404, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_group_patch_add(self): | ||||||
|  |         """Test group patch""" | ||||||
|  |         user = create_test_user() | ||||||
|  |  | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||||
|  |         response = self.client.patch( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 { | ||||||
|  |                     "Operations": [ | ||||||
|  |                         { | ||||||
|  |                             "op": "Add", | ||||||
|  |                             "path": "members", | ||||||
|  |                             "value": {"value": str(user.uuid)}, | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=200) | ||||||
|  |         self.assertTrue(group.users.filter(pk=user.pk).exists()) | ||||||
|  |  | ||||||
|  |     def test_group_patch_remove(self): | ||||||
|  |         """Test group patch""" | ||||||
|  |         user = create_test_user() | ||||||
|  |  | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         group.users.add(user) | ||||||
|  |         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||||
|  |         response = self.client.patch( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 { | ||||||
|  |                     "Operations": [ | ||||||
|  |                         { | ||||||
|  |                             "op": "remove", | ||||||
|  |                             "path": "members", | ||||||
|  |                             "value": {"value": str(user.uuid)}, | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=200) | ||||||
|  |         self.assertFalse(group.users.filter(pk=user.pk).exists()) | ||||||
|  |  | ||||||
|  |     def test_group_delete(self): | ||||||
|  |         """Test group delete""" | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||||
|  |         response = self.client.delete( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-groups", | ||||||
|  |                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, second=204) | ||||||
| @ -177,3 +177,51 @@ class TestSCIMUsers(APITestCase): | |||||||
|             SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], |             SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], | ||||||
|             "0123456789", |             "0123456789", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_user_update(self): | ||||||
|  |         """Test user update""" | ||||||
|  |         user = create_test_user() | ||||||
|  |         existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.put( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-users", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "user_id": str(user.uuid), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 { | ||||||
|  |                     "id": str(existing.pk), | ||||||
|  |                     "userName": generate_id(), | ||||||
|  |                     "externalId": ext_id, | ||||||
|  |                     "emails": [ | ||||||
|  |                         { | ||||||
|  |                             "primary": True, | ||||||
|  |                             "value": user.email, | ||||||
|  |                         } | ||||||
|  |                     ], | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_user_delete(self): | ||||||
|  |         """Test user delete""" | ||||||
|  |         user = create_test_user() | ||||||
|  |         SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) | ||||||
|  |         response = self.client.delete( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-users", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "user_id": str(user.uuid), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 204) | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ | |||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
|  | from authentik.core.middleware import CTX_AUTH_VIA | ||||||
| from authentik.core.models import Token, TokenIntents, User | from authentik.core.models import Token, TokenIntents, User | ||||||
| from authentik.sources.scim.models import SCIMSource | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
| @ -26,6 +27,7 @@ class SCIMTokenAuth(BaseAuthentication): | |||||||
|         _username, _, password = b64decode(key.encode()).decode().partition(":") |         _username, _, password = b64decode(key.encode()).decode().partition(":") | ||||||
|         token = self.check_token(password, source_slug) |         token = self.check_token(password, source_slug) | ||||||
|         if token: |         if token: | ||||||
|  |             CTX_AUTH_VIA.set("scim_basic") | ||||||
|             return (token.user, token) |             return (token.user, token) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
| @ -52,4 +54,5 @@ class SCIMTokenAuth(BaseAuthentication): | |||||||
|         token = self.check_token(key, source_slug) |         token = self.check_token(key, source_slug) | ||||||
|         if not token: |         if not token: | ||||||
|             return None |             return None | ||||||
|  |         CTX_AUTH_VIA.set("scim_token") | ||||||
|         return (token.user, token) |         return (token.user, token) | ||||||
|  | |||||||
| @ -1,13 +1,11 @@ | |||||||
| """SCIM Utils""" | """SCIM Utils""" | ||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
| from urllib.parse import urlparse |  | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.paginator import Page, Paginator | from django.core.paginator import Page, Paginator | ||||||
| from django.db.models import Q, QuerySet | from django.db.models import Q, QuerySet | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.urls import resolve |  | ||||||
| from rest_framework.parsers import JSONParser | from rest_framework.parsers import JSONParser | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.renderers import JSONRenderer | from rest_framework.renderers import JSONRenderer | ||||||
| @ -46,7 +44,7 @@ class SCIMView(APIView): | |||||||
|     logger: BoundLogger |     logger: BoundLogger | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAuthenticated] | ||||||
|     parser_classes = [SCIMParser] |     parser_classes = [SCIMParser, JSONParser] | ||||||
|     renderer_classes = [SCIMRenderer] |     renderer_classes = [SCIMRenderer] | ||||||
|  |  | ||||||
|     def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: |     def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: | ||||||
| @ -56,28 +54,6 @@ class SCIMView(APIView): | |||||||
|     def get_authenticators(self): |     def get_authenticators(self): | ||||||
|         return [SCIMTokenAuth(self)] |         return [SCIMTokenAuth(self)] | ||||||
|  |  | ||||||
|     def patch_resolve_value(self, raw_value: dict) -> User | Group | None: |  | ||||||
|         """Attempt to resolve a raw `value` attribute of a patch operation into |  | ||||||
|         a database model""" |  | ||||||
|         model = User |  | ||||||
|         query = {} |  | ||||||
|         if "$ref" in raw_value: |  | ||||||
|             url = urlparse(raw_value["$ref"]) |  | ||||||
|             if match := resolve(url.path): |  | ||||||
|                 if match.url_name == "v2-users": |  | ||||||
|                     model = User |  | ||||||
|                     query = {"pk": int(match.kwargs["user_id"])} |  | ||||||
|         elif "type" in raw_value: |  | ||||||
|             match raw_value["type"]: |  | ||||||
|                 case "User": |  | ||||||
|                     model = User |  | ||||||
|                     query = {"pk": int(raw_value["value"])} |  | ||||||
|                 case "Group": |  | ||||||
|                     model = Group |  | ||||||
|         else: |  | ||||||
|             return None |  | ||||||
|         return model.objects.filter(**query).first() |  | ||||||
|  |  | ||||||
|     def filter_parse(self, request: Request): |     def filter_parse(self, request: Request): | ||||||
|         """Parse the path of a Patch Operation""" |         """Parse the path of a Patch Operation""" | ||||||
|         path = request.query_params.get("filter") |         path = request.query_params.get("filter") | ||||||
|  | |||||||
							
								
								
									
										58
									
								
								authentik/sources/scim/views/v2/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								authentik/sources/scim/views/v2/exceptions.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  | from pydanticscim.responses import SCIMError as BaseSCIMError | ||||||
|  | from rest_framework.exceptions import ValidationError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMErrorTypes(Enum): | ||||||
|  |     invalid_filter = "invalidFilter" | ||||||
|  |     too_many = "tooMany" | ||||||
|  |     uniqueness = "uniqueness" | ||||||
|  |     mutability = "mutability" | ||||||
|  |     invalid_syntax = "invalidSyntax" | ||||||
|  |     invalid_path = "invalidPath" | ||||||
|  |     no_target = "noTarget" | ||||||
|  |     invalid_value = "invalidValue" | ||||||
|  |     invalid_vers = "invalidVers" | ||||||
|  |     sensitive = "sensitive" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMError(BaseSCIMError): | ||||||
|  |     scimType: SCIMErrorTypes | None = None | ||||||
|  |     detail: str | None = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMValidationError(ValidationError): | ||||||
|  |     status_code = 400 | ||||||
|  |     default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400) | ||||||
|  |  | ||||||
|  |     def __init__(self, detail: SCIMError | None): | ||||||
|  |         if detail is None: | ||||||
|  |             detail = self.default_detail | ||||||
|  |         detail.status = self.status_code | ||||||
|  |         self.detail = detail.model_dump(mode="json", exclude_none=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMConflictError(SCIMValidationError): | ||||||
|  |     status_code = 409 | ||||||
|  |  | ||||||
|  |     def __init__(self, detail: str): | ||||||
|  |         super().__init__( | ||||||
|  |             SCIMError( | ||||||
|  |                 detail=detail, | ||||||
|  |                 scimType=SCIMErrorTypes.uniqueness, | ||||||
|  |                 status=self.status_code, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMNotFoundError(SCIMValidationError): | ||||||
|  |     status_code = 404 | ||||||
|  |  | ||||||
|  |     def __init__(self, detail: str): | ||||||
|  |         super().__init__( | ||||||
|  |             SCIMError( | ||||||
|  |                 detail=detail, | ||||||
|  |                 status=self.status_code, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
| @ -4,19 +4,25 @@ from uuid import uuid4 | |||||||
|  |  | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.http import Http404, QueryDict | from django.http import QueryDict | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from pydantic import ValidationError as PydanticValidationError | from pydantic import ValidationError as PydanticValidationError | ||||||
| from pydanticscim.group import GroupMember | from pydanticscim.group import GroupMember | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  | from scim2_filter_parser.attr_paths import AttrPath | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA | from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation | ||||||
| from authentik.providers.scim.clients.schema import Group as SCIMGroupModel | from authentik.providers.scim.clients.schema import Group as SCIMGroupModel | ||||||
| from authentik.sources.scim.models import SCIMSourceGroup | from authentik.sources.scim.models import SCIMSourceGroup | ||||||
| from authentik.sources.scim.views.v2.base import SCIMObjectView | from authentik.sources.scim.views.v2.base import SCIMObjectView | ||||||
|  | from authentik.sources.scim.views.v2.exceptions import ( | ||||||
|  |     SCIMConflictError, | ||||||
|  |     SCIMNotFoundError, | ||||||
|  |     SCIMValidationError, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupsView(SCIMObjectView): | class GroupsView(SCIMObjectView): | ||||||
| @ -27,7 +33,7 @@ class GroupsView(SCIMObjectView): | |||||||
|     def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: |     def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: | ||||||
|         """Convert Group to SCIM data""" |         """Convert Group to SCIM data""" | ||||||
|         payload = SCIMGroupModel( |         payload = SCIMGroupModel( | ||||||
|             schemas=[SCIM_USER_SCHEMA], |             schemas=[SCIM_GROUP_SCHEMA], | ||||||
|             id=str(scim_group.group.pk), |             id=str(scim_group.group.pk), | ||||||
|             externalId=scim_group.id, |             externalId=scim_group.id, | ||||||
|             displayName=scim_group.group.name, |             displayName=scim_group.group.name, | ||||||
| @ -58,7 +64,7 @@ class GroupsView(SCIMObjectView): | |||||||
|         if group_id: |         if group_id: | ||||||
|             connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() |             connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() | ||||||
|             if not connection: |             if not connection: | ||||||
|                 raise Http404 |                 raise SCIMNotFoundError("Group not found.") | ||||||
|             return Response(self.group_to_scim(connection)) |             return Response(self.group_to_scim(connection)) | ||||||
|         connections = ( |         connections = ( | ||||||
|             base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) |             base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) | ||||||
| @ -119,7 +125,7 @@ class GroupsView(SCIMObjectView): | |||||||
|         ).first() |         ).first() | ||||||
|         if connection: |         if connection: | ||||||
|             self.logger.debug("Found existing group") |             self.logger.debug("Found existing group") | ||||||
|             return Response(status=409) |             raise SCIMConflictError("Group with ID exists already.") | ||||||
|         connection = self.update_group(None, request.data) |         connection = self.update_group(None, request.data) | ||||||
|         return Response(self.group_to_scim(connection), status=201) |         return Response(self.group_to_scim(connection), status=201) | ||||||
|  |  | ||||||
| @ -129,10 +135,44 @@ class GroupsView(SCIMObjectView): | |||||||
|             source=self.source, group__group_uuid=group_id |             source=self.source, group__group_uuid=group_id | ||||||
|         ).first() |         ).first() | ||||||
|         if not connection: |         if not connection: | ||||||
|             raise Http404 |             raise SCIMNotFoundError("Group not found.") | ||||||
|         connection = self.update_group(connection, request.data) |         connection = self.update_group(connection, request.data) | ||||||
|         return Response(self.group_to_scim(connection), status=200) |         return Response(self.group_to_scim(connection), status=200) | ||||||
|  |  | ||||||
|  |     @atomic | ||||||
|  |     def patch(self, request: Request, group_id: str, **kwargs) -> Response: | ||||||
|  |         """Patch group handler""" | ||||||
|  |         connection = SCIMSourceGroup.objects.filter( | ||||||
|  |             source=self.source, group__group_uuid=group_id | ||||||
|  |         ).first() | ||||||
|  |         if not connection: | ||||||
|  |             raise SCIMNotFoundError("Group not found.") | ||||||
|  |  | ||||||
|  |         for _op in request.data.get("Operations", []): | ||||||
|  |             operation = PatchOperation.model_validate(_op) | ||||||
|  |             if operation.op.lower() not in ["add", "remove", "replace"]: | ||||||
|  |                 raise SCIMValidationError() | ||||||
|  |             attr_path = AttrPath(f'{operation.path} eq ""', {}) | ||||||
|  |             if attr_path.first_path == ("members", None, None): | ||||||
|  |                 # FIXME: this can probably be de-duplicated | ||||||
|  |                 if operation.op == PatchOp.add: | ||||||
|  |                     if not isinstance(operation.value, list): | ||||||
|  |                         operation.value = [operation.value] | ||||||
|  |                     query = Q() | ||||||
|  |                     for member in operation.value: | ||||||
|  |                         query |= Q(uuid=member["value"]) | ||||||
|  |                     if query: | ||||||
|  |                         connection.group.users.add(*User.objects.filter(query)) | ||||||
|  |                 elif operation.op == PatchOp.remove: | ||||||
|  |                     if not isinstance(operation.value, list): | ||||||
|  |                         operation.value = [operation.value] | ||||||
|  |                     query = Q() | ||||||
|  |                     for member in operation.value: | ||||||
|  |                         query |= Q(uuid=member["value"]) | ||||||
|  |                     if query: | ||||||
|  |                         connection.group.users.remove(*User.objects.filter(query)) | ||||||
|  |         return Response(self.group_to_scim(connection), status=200) | ||||||
|  |  | ||||||
|     @atomic |     @atomic | ||||||
|     def delete(self, request: Request, group_id: str, **kwargs) -> Response: |     def delete(self, request: Request, group_id: str, **kwargs) -> Response: | ||||||
|         """Delete group handler""" |         """Delete group handler""" | ||||||
| @ -140,7 +180,7 @@ class GroupsView(SCIMObjectView): | |||||||
|             source=self.source, group__group_uuid=group_id |             source=self.source, group__group_uuid=group_id | ||||||
|         ).first() |         ).first() | ||||||
|         if not connection: |         if not connection: | ||||||
|             raise Http404 |             raise SCIMNotFoundError("Group not found.") | ||||||
|         connection.group.delete() |         connection.group.delete() | ||||||
|         connection.delete() |         connection.delete() | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| """SCIM Meta views""" | """SCIM Meta views""" | ||||||
|  |  | ||||||
| from django.http import Http404 |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  |  | ||||||
| from authentik.sources.scim.views.v2.base import SCIMView | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  | from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError | ||||||
|  |  | ||||||
|  |  | ||||||
| class ResourceTypesView(SCIMView): | class ResourceTypesView(SCIMView): | ||||||
| @ -138,7 +138,7 @@ class ResourceTypesView(SCIMView): | |||||||
|             resource = [x for x in resource_types if x.get("id") == resource_type] |             resource = [x for x in resource_types if x.get("id") == resource_type] | ||||||
|             if resource: |             if resource: | ||||||
|                 return Response(resource[0]) |                 return Response(resource[0]) | ||||||
|             raise Http404 |             raise SCIMNotFoundError("Resource not found.") | ||||||
|         return Response( |         return Response( | ||||||
|             { |             { | ||||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  | |||||||
| @ -3,12 +3,12 @@ | |||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.http import Http404 |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  |  | ||||||
| from authentik.sources.scim.views.v2.base import SCIMView | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  | from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError | ||||||
|  |  | ||||||
| with open( | with open( | ||||||
|     settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", |     settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", | ||||||
| @ -44,7 +44,7 @@ class SchemaView(SCIMView): | |||||||
|             schema = [x for x in schemas if x.get("id") == schema_uri] |             schema = [x for x in schemas if x.get("id") == schema_uri] | ||||||
|             if schema: |             if schema: | ||||||
|                 return Response(schema[0]) |                 return Response(schema[0]) | ||||||
|             raise Http404 |             raise SCIMNotFoundError("Schema not found.") | ||||||
|         return Response( |         return Response( | ||||||
|             { |             { | ||||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  | |||||||
| @ -33,6 +33,8 @@ class ServiceProviderConfigView(SCIMView): | |||||||
|             { |             { | ||||||
|                 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], |                 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], | ||||||
|                 "authenticationSchemes": auth_schemas, |                 "authenticationSchemes": auth_schemas, | ||||||
|  |                 # We only support patch for groups currently, so don't broadly advertise it. | ||||||
|  |                 # Implementations that require Group patch will use it regardless of this flag. | ||||||
|                 "patch": {"supported": False}, |                 "patch": {"supported": False}, | ||||||
|                 "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, |                 "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, | ||||||
|                 "filter": { |                 "filter": { | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from uuid import uuid4 | |||||||
|  |  | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.http import Http404, QueryDict | from django.http import QueryDict | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from pydanticscim.user import Email, EmailKind, Name | from pydanticscim.user import Email, EmailKind, Name | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| @ -16,6 +16,7 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA | |||||||
| from authentik.providers.scim.clients.schema import User as SCIMUserModel | from authentik.providers.scim.clients.schema import User as SCIMUserModel | ||||||
| from authentik.sources.scim.models import SCIMSourceUser | from authentik.sources.scim.models import SCIMSourceUser | ||||||
| from authentik.sources.scim.views.v2.base import SCIMObjectView | from authentik.sources.scim.views.v2.base import SCIMObjectView | ||||||
|  | from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError | ||||||
|  |  | ||||||
|  |  | ||||||
| class UsersView(SCIMObjectView): | class UsersView(SCIMObjectView): | ||||||
| @ -69,7 +70,7 @@ class UsersView(SCIMObjectView): | |||||||
|                 .first() |                 .first() | ||||||
|             ) |             ) | ||||||
|             if not connection: |             if not connection: | ||||||
|                 raise Http404 |                 raise SCIMNotFoundError("User not found.") | ||||||
|             return Response(self.user_to_scim(connection)) |             return Response(self.user_to_scim(connection)) | ||||||
|         connections = ( |         connections = ( | ||||||
|             SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") |             SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") | ||||||
| @ -122,7 +123,7 @@ class UsersView(SCIMObjectView): | |||||||
|         ).first() |         ).first() | ||||||
|         if connection: |         if connection: | ||||||
|             self.logger.debug("Found existing user") |             self.logger.debug("Found existing user") | ||||||
|             return Response(status=409) |             raise SCIMConflictError("Group with ID exists already.") | ||||||
|         connection = self.update_user(None, request.data) |         connection = self.update_user(None, request.data) | ||||||
|         return Response(self.user_to_scim(connection), status=201) |         return Response(self.user_to_scim(connection), status=201) | ||||||
|  |  | ||||||
| @ -130,7 +131,7 @@ class UsersView(SCIMObjectView): | |||||||
|         """Update user handler""" |         """Update user handler""" | ||||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() |         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||||
|         if not connection: |         if not connection: | ||||||
|             raise Http404 |             raise SCIMNotFoundError("User not found.") | ||||||
|         self.update_user(connection, request.data) |         self.update_user(connection, request.data) | ||||||
|         return Response(self.user_to_scim(connection), status=200) |         return Response(self.user_to_scim(connection), status=200) | ||||||
|  |  | ||||||
| @ -139,7 +140,7 @@ class UsersView(SCIMObjectView): | |||||||
|         """Delete user handler""" |         """Delete user handler""" | ||||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() |         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||||
|         if not connection: |         if not connection: | ||||||
|             raise Http404 |             raise SCIMNotFoundError("User not found.") | ||||||
|         connection.user.delete() |         connection.user.delete() | ||||||
|         connection.delete() |         connection.delete() | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ from authentik.flows.exceptions import StageInvalidException | |||||||
| from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.time import timedelta_string_validator | from authentik.lib.utils.time import timedelta_string_validator | ||||||
| from authentik.stages.authenticator.models import SideChannelDevice | from authentik.stages.authenticator.models import SideChannelDevice | ||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
| @ -159,8 +160,9 @@ class EmailDevice(SerializerModel, SideChannelDevice): | |||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message=_("Exception occurred while rendering E-mail template"), |                 message=_("Exception occurred while rendering E-mail template"), | ||||||
|  |                 error=exception_to_string(exc), | ||||||
|                 template=stage.template, |                 template=stage.template, | ||||||
|             ).with_exception(exc).from_http(self.request) |             ).from_http(self.request) | ||||||
|             raise StageInvalidException from exc |             raise StageInvalidException from exc | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ from authentik.flows.challenge import ( | |||||||
| from authentik.flows.exceptions import StageInvalidException | from authentik.flows.exceptions import StageInvalidException | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.lib.utils.email import mask_email | from authentik.lib.utils.email import mask_email | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.stages.authenticator_email.models import ( | from authentik.stages.authenticator_email.models import ( | ||||||
|     AuthenticatorEmailStage, |     AuthenticatorEmailStage, | ||||||
| @ -99,8 +100,9 @@ class AuthenticatorEmailStageView(ChallengeStageView): | |||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message=_("Exception occurred while rendering E-mail template"), |                 message=_("Exception occurred while rendering E-mail template"), | ||||||
|  |                 error=exception_to_string(exc), | ||||||
|                 template=stage.template, |                 template=stage.template, | ||||||
|             ).with_exception(exc).from_http(self.request) |             ).from_http(self.request) | ||||||
|             raise StageInvalidException from exc |             raise StageInvalidException from exc | ||||||
|  |  | ||||||
|     def _has_email(self) -> str | None: |     def _has_email(self) -> str | None: | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ from authentik.events.models import Event, EventAction, NotificationWebhookMappi | |||||||
| from authentik.events.utils import sanitize_item | from authentik.events.utils import sanitize_item | ||||||
| from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.utils.http import get_http_session | ||||||
| from authentik.stages.authenticator.models import SideChannelDevice | from authentik.stages.authenticator.models import SideChannelDevice | ||||||
|  |  | ||||||
| @ -141,9 +142,10 @@ class AuthenticatorSMSStage(ConfigurableStage, FriendlyNamedStage, Stage): | |||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message="Error sending SMS", |                 message="Error sending SMS", | ||||||
|  |                 exc=exception_to_string(exc), | ||||||
|                 status_code=response.status_code, |                 status_code=response.status_code, | ||||||
|                 body=response.text, |                 body=response.text, | ||||||
|             ).with_exception(exc).set_user(device.user).save() |             ).set_user(device.user).save() | ||||||
|             if response.status_code >= HttpResponseBadRequest.status_code: |             if response.status_code >= HttpResponseBadRequest.status_code: | ||||||
|                 raise ValidationError(response.text) from None |                 raise ValidationError(response.text) from None | ||||||
|             raise |             raise | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """Validation stage challenge checking""" | """Validation stage challenge checking""" | ||||||
|  |  | ||||||
| from json import loads | from json import loads | ||||||
|  | from typing import TYPE_CHECKING | ||||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -36,10 +37,12 @@ from authentik.stages.authenticator_email.models import EmailDevice | |||||||
| from authentik.stages.authenticator_sms.models import SMSDevice | from authentik.stages.authenticator_sms.models import SMSDevice | ||||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||||
| from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | ||||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceChallenge(PassiveSerializer): | class DeviceChallenge(PassiveSerializer): | ||||||
| @ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer): | |||||||
|  |  | ||||||
|  |  | ||||||
| def get_challenge_for_device( | def get_challenge_for_device( | ||||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: Device |     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device | ||||||
| ) -> dict: | ) -> dict: | ||||||
|     """Generate challenge for a single device""" |     """Generate challenge for a single device""" | ||||||
|     if isinstance(device, WebAuthnDevice): |     if isinstance(device, WebAuthnDevice): | ||||||
|         return get_webauthn_challenge(request, stage, device) |         return get_webauthn_challenge(stage_view, stage, device) | ||||||
|     if isinstance(device, EmailDevice): |     if isinstance(device, EmailDevice): | ||||||
|         return {"email": mask_email(device.email)} |         return {"email": mask_email(device.email)} | ||||||
|     # Code-based challenges have no hints |     # Code-based challenges have no hints | ||||||
| @ -64,26 +67,30 @@ def get_challenge_for_device( | |||||||
|  |  | ||||||
|  |  | ||||||
| def get_webauthn_challenge_without_user( | def get_webauthn_challenge_without_user( | ||||||
|     request: HttpRequest, stage: AuthenticatorValidateStage |     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage | ||||||
| ) -> dict: | ) -> dict: | ||||||
|     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check |     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check | ||||||
|     who the device belongs to.""" |     who the device belongs to.""" | ||||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||||
|     authentication_options = generate_authentication_options( |     authentication_options = generate_authentication_options( | ||||||
|         rp_id=get_rp_id(request), |         rp_id=get_rp_id(stage_view.request), | ||||||
|         allow_credentials=[], |         allow_credentials=[], | ||||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), |         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||||
|     ) |     ) | ||||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge |     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||||
|  |         authentication_options.challenge | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     return loads(options_to_json(authentication_options)) |     return loads(options_to_json(authentication_options)) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_webauthn_challenge( | def get_webauthn_challenge( | ||||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None |     stage_view: "AuthenticatorValidateStageView", | ||||||
|  |     stage: AuthenticatorValidateStage, | ||||||
|  |     device: WebAuthnDevice | None = None, | ||||||
| ) -> dict: | ) -> dict: | ||||||
|     """Send the client a challenge that we'll check later""" |     """Send the client a challenge that we'll check later""" | ||||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||||
|  |  | ||||||
|     allowed_credentials = [] |     allowed_credentials = [] | ||||||
|  |  | ||||||
| @ -94,12 +101,14 @@ def get_webauthn_challenge( | |||||||
|             allowed_credentials.append(user_device.descriptor) |             allowed_credentials.append(user_device.descriptor) | ||||||
|  |  | ||||||
|     authentication_options = generate_authentication_options( |     authentication_options = generate_authentication_options( | ||||||
|         rp_id=get_rp_id(request), |         rp_id=get_rp_id(stage_view.request), | ||||||
|         allow_credentials=allowed_credentials, |         allow_credentials=allowed_credentials, | ||||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), |         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge |     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||||
|  |         authentication_options.challenge | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     return loads(options_to_json(authentication_options)) |     return loads(options_to_json(authentication_options)) | ||||||
|  |  | ||||||
| @ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev | |||||||
| def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: | def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: | ||||||
|     """Validate WebAuthn Challenge""" |     """Validate WebAuthn Challenge""" | ||||||
|     request = stage_view.request |     request = stage_view.request | ||||||
|     challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) |     challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE) | ||||||
|     stage: AuthenticatorValidateStage = stage_view.executor.current_stage |     stage: AuthenticatorValidateStage = stage_view.executor.current_stage | ||||||
|     try: |     try: | ||||||
|         credential = parse_authentication_credential_json(data) |         credential = parse_authentication_credential_json(data) | ||||||
|  | |||||||
| @ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|                 data={ |                 data={ | ||||||
|                     "device_class": device_class, |                     "device_class": device_class, | ||||||
|                     "device_uid": device.pk, |                     "device_uid": device.pk, | ||||||
|                     "challenge": get_challenge_for_device(self.request, stage, device), |                     "challenge": get_challenge_for_device(self, stage, device), | ||||||
|                     "last_used": device.last_used, |                     "last_used": device.last_used, | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
| @ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|                 "device_class": DeviceClasses.WEBAUTHN, |                 "device_class": DeviceClasses.WEBAUTHN, | ||||||
|                 "device_uid": -1, |                 "device_uid": -1, | ||||||
|                 "challenge": get_webauthn_challenge_without_user( |                 "challenge": get_webauthn_challenge_without_user( | ||||||
|                     self.request, |                     self, | ||||||
|                     self.executor.current_stage, |                     self.executor.current_stage, | ||||||
|                 ), |                 ), | ||||||
|                 "last_used": None, |                 "last_used": None, | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import ( | |||||||
|     WebAuthnDevice, |     WebAuthnDevice, | ||||||
|     WebAuthnDeviceType, |     WebAuthnDeviceType, | ||||||
| ) | ) | ||||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||||
| from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | ||||||
| from authentik.stages.identification.models import IdentificationStage, UserFields | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
| from authentik.stages.user_login.models import UserLoginStage | from authentik.stages.user_login.models import UserLoginStage | ||||||
| @ -103,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             device_classes=[DeviceClasses.WEBAUTHN], |             device_classes=[DeviceClasses.WEBAUTHN], | ||||||
|             webauthn_user_verification=UserVerification.PREFERRED, |             webauthn_user_verification=UserVerification.PREFERRED, | ||||||
|         ) |         ) | ||||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) |         plan = FlowPlan("") | ||||||
|  |         stage_view = AuthenticatorValidateStageView( | ||||||
|  |             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||||
|  |         ) | ||||||
|  |         challenge = get_challenge_for_device(stage_view, stage, webauthn_device) | ||||||
|         del challenge["challenge"] |         del challenge["challenge"] | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             challenge, |             challenge, | ||||||
| @ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|  |  | ||||||
|         with self.assertRaises(ValidationError): |         with self.assertRaises(ValidationError): | ||||||
|             validate_challenge_webauthn( |             validate_challenge_webauthn( | ||||||
|                 {}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user |                 {}, | ||||||
|  |                 StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request), | ||||||
|  |                 self.user, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|     def test_device_challenge_webauthn_restricted(self): |     def test_device_challenge_webauthn_restricted(self): | ||||||
| @ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             sign_count=0, |             sign_count=0, | ||||||
|             rp_id=generate_id(), |             rp_id=generate_id(), | ||||||
|         ) |         ) | ||||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) |         plan = FlowPlan("") | ||||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||||
|  |             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||||
|  |         ) | ||||||
|  |         stage_view = AuthenticatorValidateStageView( | ||||||
|  |             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||||
|  |         ) | ||||||
|  |         challenge = get_challenge_for_device(stage_view, stage, webauthn_device) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             challenge, |             challenge["allowCredentials"], | ||||||
|             { |             [ | ||||||
|                 "allowCredentials": [ |  | ||||||
|                 { |                 { | ||||||
|                     "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", |                     "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", | ||||||
|                     "type": "public-key", |                     "type": "public-key", | ||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|                 "challenge": bytes_to_base64url(webauthn_challenge), |         ) | ||||||
|                 "rpId": "testserver", |         self.assertIsNotNone(challenge["challenge"]) | ||||||
|                 "timeout": 60000, |         self.assertEqual( | ||||||
|                 "userVerification": "preferred", |             challenge["rpId"], | ||||||
|             }, |             "testserver", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual( | ||||||
|  |             challenge["timeout"], | ||||||
|  |             60000, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual( | ||||||
|  |             challenge["userVerification"], | ||||||
|  |             "preferred", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_get_challenge_userless(self): |     def test_get_challenge_userless(self): | ||||||
| @ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             sign_count=0, |             sign_count=0, | ||||||
|             rp_id=generate_id(), |             rp_id=generate_id(), | ||||||
|         ) |         ) | ||||||
|         challenge = get_webauthn_challenge_without_user(request, stage) |         plan = FlowPlan("") | ||||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] |         stage_view = AuthenticatorValidateStageView( | ||||||
|         self.assertEqual( |             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||||
|             challenge, |  | ||||||
|             { |  | ||||||
|                 "allowCredentials": [], |  | ||||||
|                 "challenge": bytes_to_base64url(webauthn_challenge), |  | ||||||
|                 "rpId": "testserver", |  | ||||||
|                 "timeout": 60000, |  | ||||||
|                 "userVerification": "preferred", |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |         challenge = get_webauthn_challenge_without_user(stage_view, stage) | ||||||
|  |         self.assertEqual(challenge["allowCredentials"], []) | ||||||
|  |         self.assertIsNotNone(challenge["challenge"]) | ||||||
|  |         self.assertEqual(challenge["rpId"], "testserver") | ||||||
|  |         self.assertEqual(challenge["timeout"], 60000) | ||||||
|  |         self.assertEqual(challenge["userVerification"], "preferred") | ||||||
|  |  | ||||||
|     def test_validate_challenge_unrestricted(self): |     def test_validate_challenge_unrestricted(self): | ||||||
|         """Test webauthn authentication (unrestricted webauthn device)""" |         """Test webauthn authentication (unrestricted webauthn device)""" | ||||||
| @ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|                 "last_used": None, |                 "last_used": None, | ||||||
|             } |             } | ||||||
|         ] |         ] | ||||||
|         session[SESSION_KEY_PLAN] = plan |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( |  | ||||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" |             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||||
|         ) |         ) | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
| @ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|                 "last_used": None, |                 "last_used": None, | ||||||
|             } |             } | ||||||
|         ] |         ] | ||||||
|         session[SESSION_KEY_PLAN] = plan |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( |  | ||||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" |             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||||
|         ) |         ) | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
| @ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|                 "last_used": None, |                 "last_used": None, | ||||||
|             } |             } | ||||||
|         ] |         ] | ||||||
|         session[SESSION_KEY_PLAN] = plan |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( |  | ||||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" |             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||||
|         ) |         ) | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
| @ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|             device_classes=[DeviceClasses.WEBAUTHN], |             device_classes=[DeviceClasses.WEBAUTHN], | ||||||
|         ) |         ) | ||||||
|         stage_view = AuthenticatorValidateStageView( |         plan = FlowPlan(flow.pk.hex) | ||||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||||
|         ) |  | ||||||
|         request = get_request("/") |  | ||||||
|         request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( |  | ||||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" |             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||||
|         ) |         ) | ||||||
|         request.session.save() |         request = get_request("/") | ||||||
|  |  | ||||||
|         stage_view = AuthenticatorValidateStageView( |         stage_view = AuthenticatorValidateStageView( | ||||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request |             FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request | ||||||
|         ) |         ) | ||||||
|         request.META["SERVER_NAME"] = "localhost" |         request.META["SERVER_NAME"] = "localhost" | ||||||
|         request.META["SERVER_PORT"] = "9000" |         request.META["SERVER_PORT"] = "9000" | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer): | |||||||
|             "resident_key_requirement", |             "resident_key_requirement", | ||||||
|             "device_type_restrictions", |             "device_type_restrictions", | ||||||
|             "device_type_restrictions_obj", |             "device_type_restrictions_obj", | ||||||
|  |             "max_attempts", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 5.1.11 on 2025-06-13 22:41 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ( | ||||||
|  |             "authentik_stages_authenticator_webauthn", | ||||||
|  |             "0012_webauthndevice_created_webauthndevice_last_updated_and_more", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="authenticatorwebauthnstage", | ||||||
|  |             name="max_attempts", | ||||||
|  |             field=models.PositiveIntegerField(default=0), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage): | |||||||
|  |  | ||||||
|     device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True) |     device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True) | ||||||
|  |  | ||||||
|  |     max_attempts = models.PositiveIntegerField(default=0) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[BaseSerializer]: |     def serializer(self) -> type[BaseSerializer]: | ||||||
|         from authentik.stages.authenticator_webauthn.api.stages import ( |         from authentik.stages.authenticator_webauthn.api.stages import ( | ||||||
|  | |||||||
| @ -5,12 +5,13 @@ from uuid import UUID | |||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
|  | from django.utils.translation import gettext as __ | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from webauthn import options_to_json | from webauthn import options_to_json | ||||||
| from webauthn.helpers.bytes_to_base64url import bytes_to_base64url | from webauthn.helpers.bytes_to_base64url import bytes_to_base64url | ||||||
| from webauthn.helpers.exceptions import InvalidRegistrationResponse | from webauthn.helpers.exceptions import WebAuthnException | ||||||
| from webauthn.helpers.structs import ( | from webauthn.helpers.structs import ( | ||||||
|     AttestationConveyancePreference, |     AttestationConveyancePreference, | ||||||
|     AuthenticatorAttachment, |     AuthenticatorAttachment, | ||||||
| @ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import ( | |||||||
| ) | ) | ||||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||||
|  |  | ||||||
| SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge" | PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge" | ||||||
|  | PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt" | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): | class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): | ||||||
| @ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | |||||||
|  |  | ||||||
|     def validate_response(self, response: dict) -> dict: |     def validate_response(self, response: dict) -> dict: | ||||||
|         """Validate webauthn challenge response""" |         """Validate webauthn challenge response""" | ||||||
|         challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] |         challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             registration: VerifiedRegistration = verify_registration_response( |             registration: VerifiedRegistration = verify_registration_response( | ||||||
| @ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | |||||||
|                 expected_rp_id=get_rp_id(self.request), |                 expected_rp_id=get_rp_id(self.request), | ||||||
|                 expected_origin=get_origin(self.request), |                 expected_origin=get_origin(self.request), | ||||||
|             ) |             ) | ||||||
|         except InvalidRegistrationResponse as exc: |         except WebAuthnException as exc: | ||||||
|             self.stage.logger.warning("registration failed", exc=exc) |             self.stage.logger.warning("registration failed", exc=exc) | ||||||
|             raise ValidationError(f"Registration failed. Error: {exc}") from None |             raise ValidationError(f"Registration failed. Error: {exc}") from None | ||||||
|  |  | ||||||
| @ -114,9 +116,10 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|     response_class = AuthenticatorWebAuthnChallengeResponse |     response_class = AuthenticatorWebAuthnChallengeResponse | ||||||
|  |  | ||||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: |     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         # clear session variables prior to starting a new registration |  | ||||||
|         self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |  | ||||||
|         stage: AuthenticatorWebAuthnStage = self.executor.current_stage |         stage: AuthenticatorWebAuthnStage = self.executor.current_stage | ||||||
|  |         self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0) | ||||||
|  |         # clear flow variables prior to starting a new registration | ||||||
|  |         self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||||
|         user = self.get_pending_user() |         user = self.get_pending_user() | ||||||
|  |  | ||||||
|         # library accepts none so we store null in the database, but if there is a value |         # library accepts none so we store null in the database, but if there is a value | ||||||
| @ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|             attestation=AttestationConveyancePreference.DIRECT, |             attestation=AttestationConveyancePreference.DIRECT, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge |         self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge | ||||||
|         self.request.session.save() |  | ||||||
|         return AuthenticatorWebAuthnChallenge( |         return AuthenticatorWebAuthnChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "registration": loads(options_to_json(registration_options)), |                 "registration": loads(options_to_json(registration_options)), | ||||||
| @ -153,6 +155,24 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|         response.user = self.get_pending_user() |         response.user = self.get_pending_user() | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|  |     def challenge_invalid(self, response): | ||||||
|  |         stage: AuthenticatorWebAuthnStage = self.executor.current_stage | ||||||
|  |         self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0) | ||||||
|  |         self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1 | ||||||
|  |         if ( | ||||||
|  |             stage.max_attempts > 0 | ||||||
|  |             and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts | ||||||
|  |         ): | ||||||
|  |             return self.executor.stage_invalid( | ||||||
|  |                 __( | ||||||
|  |                     "Exceeded maximum attempts. " | ||||||
|  |                     "Contact your {brand} administrator for help.".format( | ||||||
|  |                         brand=self.request.brand.branding_title | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         return super().challenge_invalid(response) | ||||||
|  |  | ||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|         # Webauthn Challenge has already been validated |         # Webauthn Challenge has already been validated | ||||||
|         webauthn_credential: VerifiedRegistration = response.validated_data["response"] |         webauthn_credential: VerifiedRegistration = response.validated_data["response"] | ||||||
| @ -179,6 +199,3 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|         else: |         else: | ||||||
|             return self.executor.stage_invalid("Device with Credential ID already exists.") |             return self.executor.stage_invalid("Device with Credential ID already exists.") | ||||||
|         return self.executor.stage_ok() |         return self.executor.stage_ok() | ||||||
|  |  | ||||||
|     def cleanup(self): |  | ||||||
|         self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |  | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import ( | |||||||
|     WebAuthnDevice, |     WebAuthnDevice, | ||||||
|     WebAuthnDeviceType, |     WebAuthnDeviceType, | ||||||
| ) | ) | ||||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||||
| from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -57,6 +57,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         self.assertStageResponse( |         self.assertStageResponse( | ||||||
| @ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|                     "name": self.user.username, |                     "name": self.user.username, | ||||||
|                     "displayName": self.user.name, |                     "displayName": self.user.name, | ||||||
|                 }, |                 }, | ||||||
|                 "challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]), |                 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), | ||||||
|                 "pubKeyCredParams": [ |                 "pubKeyCredParams": [ | ||||||
|                     {"type": "public-key", "alg": -7}, |                     {"type": "public-key", "alg": -7}, | ||||||
|                     {"type": "public-key", "alg": -8}, |                     {"type": "public-key", "alg": -8}, | ||||||
| @ -97,11 +100,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|         """Test registration""" |         """Test registration""" | ||||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||||
|         session[SESSION_KEY_PLAN] = plan |  | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( |  | ||||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" |             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||||
|         ) |         ) | ||||||
|  |         session = self.client.session | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
| @ -146,11 +149,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|  |  | ||||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||||
|         session[SESSION_KEY_PLAN] = plan |  | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( |  | ||||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" |             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||||
|         ) |         ) | ||||||
|  |         session = self.client.session | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
| @ -209,11 +212,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|  |  | ||||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||||
|         session[SESSION_KEY_PLAN] = plan |  | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( |  | ||||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" |             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||||
|         ) |         ) | ||||||
|  |         session = self.client.session | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
| @ -259,11 +262,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|  |  | ||||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||||
|         session[SESSION_KEY_PLAN] = plan |  | ||||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( |  | ||||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" |             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||||
|         ) |         ) | ||||||
|  |         session = self.client.session | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
| @ -298,3 +301,109 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) |         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||||
|         self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) |         self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||||
|  |  | ||||||
|  |     def test_register_max_retries(self): | ||||||
|  |         """Test registration (exceeding max retries)""" | ||||||
|  |         self.stage.max_attempts = 2 | ||||||
|  |         self.stage.save() | ||||||
|  |  | ||||||
|  |         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||||
|  |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|  |         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||||
|  |             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||||
|  |         ) | ||||||
|  |         session = self.client.session | ||||||
|  |         session[SESSION_KEY_PLAN] = plan | ||||||
|  |         session.save() | ||||||
|  |  | ||||||
|  |         # first failed request | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|  |             data={ | ||||||
|  |                 "component": "ak-stage-authenticator-webauthn", | ||||||
|  |                 "response": { | ||||||
|  |                     "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||||
|  |                     "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||||
|  |                     "type": "public-key", | ||||||
|  |                     "registrationClientExtensions": "{}", | ||||||
|  |                     "response": { | ||||||
|  |                         "clientDataJSON": ( | ||||||
|  |                             "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" | ||||||
|  |                             "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" | ||||||
|  |                             "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" | ||||||
|  |                             "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" | ||||||
|  |                             "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" | ||||||
|  |                         ), | ||||||
|  |                         "attestationObject": ( | ||||||
|  |                             "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" | ||||||
|  |                             "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" | ||||||
|  |                             "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" | ||||||
|  |                             "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" | ||||||
|  |                             "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" | ||||||
|  |                         ), | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             SERVER_NAME="localhost", | ||||||
|  |             SERVER_PORT="9000", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertStageResponse( | ||||||
|  |             response, | ||||||
|  |             flow=self.flow, | ||||||
|  |             component="ak-stage-authenticator-webauthn", | ||||||
|  |             response_errors={ | ||||||
|  |                 "response": [ | ||||||
|  |                     { | ||||||
|  |                         "string": ( | ||||||
|  |                             "Registration failed. Error: Unable to decode " | ||||||
|  |                             "client_data_json bytes as JSON" | ||||||
|  |                         ), | ||||||
|  |                         "code": "invalid", | ||||||
|  |                     } | ||||||
|  |                 ] | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||||
|  |  | ||||||
|  |         # Second failed request | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|  |             data={ | ||||||
|  |                 "component": "ak-stage-authenticator-webauthn", | ||||||
|  |                 "response": { | ||||||
|  |                     "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||||
|  |                     "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||||
|  |                     "type": "public-key", | ||||||
|  |                     "registrationClientExtensions": "{}", | ||||||
|  |                     "response": { | ||||||
|  |                         "clientDataJSON": ( | ||||||
|  |                             "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" | ||||||
|  |                             "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" | ||||||
|  |                             "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" | ||||||
|  |                             "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" | ||||||
|  |                             "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" | ||||||
|  |                         ), | ||||||
|  |                         "attestationObject": ( | ||||||
|  |                             "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" | ||||||
|  |                             "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" | ||||||
|  |                             "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" | ||||||
|  |                             "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" | ||||||
|  |                             "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" | ||||||
|  |                         ), | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             SERVER_NAME="localhost", | ||||||
|  |             SERVER_PORT="9000", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertStageResponse( | ||||||
|  |             response, | ||||||
|  |             flow=self.flow, | ||||||
|  |             component="ak-stage-access-denied", | ||||||
|  |             error_message=( | ||||||
|  |                 "Exceeded maximum attempts. Contact your authentik administrator for help." | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ from authentik.flows.models import FlowDesignation, FlowToken | |||||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY | from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.stages.email.flow import pickle_flow_token_for_email | from authentik.stages.email.flow import pickle_flow_token_for_email | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| @ -128,8 +129,9 @@ class EmailStageView(ChallengeStageView): | |||||||
|             Event.new( |             Event.new( | ||||||
|                 EventAction.CONFIGURATION_ERROR, |                 EventAction.CONFIGURATION_ERROR, | ||||||
|                 message=_("Exception occurred while rendering E-mail template"), |                 message=_("Exception occurred while rendering E-mail template"), | ||||||
|  |                 error=exception_to_string(exc), | ||||||
|                 template=current_stage.template, |                 template=current_stage.template, | ||||||
|             ).with_exception(exc).from_http(self.request) |             ).from_http(self.request) | ||||||
|             raise StageInvalidException from exc |             raise StageInvalidException from exc | ||||||
|  |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|  | |||||||
| @ -27,7 +27,6 @@ | |||||||
|     </table> |     </table> | ||||||
|   </td> |   </td> | ||||||
| </tr> | </tr> | ||||||
| <td> |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block sub_content %} | {% block sub_content %} | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """Serializer for tenants models""" | """Serializer for tenants models""" | ||||||
|  |  | ||||||
| from django_tenants.utils import get_public_schema_name | from django_tenants.utils import get_public_schema_name | ||||||
|  | from rest_framework.fields import JSONField | ||||||
| from rest_framework.generics import RetrieveUpdateAPIView | from rest_framework.generics import RetrieveUpdateAPIView | ||||||
| from rest_framework.permissions import SAFE_METHODS | from rest_framework.permissions import SAFE_METHODS | ||||||
|  |  | ||||||
| @ -12,6 +13,8 @@ from authentik.tenants.models import Tenant | |||||||
| class SettingsSerializer(ModelSerializer): | class SettingsSerializer(ModelSerializer): | ||||||
|     """Settings Serializer""" |     """Settings Serializer""" | ||||||
|  |  | ||||||
|  |     footer_links = JSONField(required=False) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Tenant |         model = Tenant | ||||||
|         fields = [ |         fields = [ | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ def check_embedded_outpost_disabled(app_configs, **kwargs): | |||||||
|                 "Embedded outpost must be disabled when tenants API is enabled.", |                 "Embedded outpost must be disabled when tenants API is enabled.", | ||||||
|                 hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to " |                 hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to " | ||||||
|                 "True, or disable the tenants API by setting tenants.enabled to False", |                 "True, or disable the tenants API by setting tenants.enabled to False", | ||||||
|  |                 id="ak.tenants.E001", | ||||||
|             ) |             ) | ||||||
|         ] |         ] | ||||||
|     return [] |     return [] | ||||||
|  | |||||||
| @ -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 2025.6.2 Blueprint schema", |     "title": "authentik 2025.6.3 Blueprint schema", | ||||||
|     "required": [ |     "required": [ | ||||||
|         "version", |         "version", | ||||||
|         "entries" |         "entries" | ||||||
| @ -8953,6 +8953,11 @@ | |||||||
|                     "type": "boolean", |                     "type": "boolean", | ||||||
|                     "title": "MFA Support", |                     "title": "MFA Support", | ||||||
|                     "description": "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon." |                     "description": "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon." | ||||||
|  |                 }, | ||||||
|  |                 "certificate": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "format": "uuid", | ||||||
|  |                     "title": "Certificate" | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             "required": [] |             "required": [] | ||||||
| @ -13310,6 +13315,12 @@ | |||||||
|                         "format": "uuid" |                         "format": "uuid" | ||||||
|                     }, |                     }, | ||||||
|                     "title": "Device type restrictions" |                     "title": "Device type restrictions" | ||||||
|  |                 }, | ||||||
|  |                 "max_attempts": { | ||||||
|  |                     "type": "integer", | ||||||
|  |                     "minimum": 0, | ||||||
|  |                     "maximum": 2147483647, | ||||||
|  |                     "title": "Max attempts" | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             "required": [] |             "required": [] | ||||||
|  | |||||||
| @ -1,6 +1,8 @@ | |||||||
| version: 1 | version: 1 | ||||||
| metadata: | metadata: | ||||||
|   name: OIDC conformance testing |   name: OpenID Conformance testing | ||||||
|  |   labels: | ||||||
|  |     blueprints.goauthentik.io/instantiate: "false" | ||||||
| entries: | entries: | ||||||
|   - identifiers: |   - identifiers: | ||||||
|       managed: goauthentik.io/providers/oauth2/scope-address |       managed: goauthentik.io/providers/oauth2/scope-address | ||||||
| @ -21,38 +23,72 @@ entries: | |||||||
|     attrs: |     attrs: | ||||||
|       name: "authentik default OAuth Mapping: OpenID 'phone'" |       name: "authentik default OAuth Mapping: OpenID 'phone'" | ||||||
|       scope_name: phone |       scope_name: phone | ||||||
|       description: "General phone Information" |       description: "General phone information" | ||||||
|       expression: | |       expression: | | ||||||
|         return { |         return { | ||||||
|             "phone_number": "+1234", |             "phone_number": "+1234", | ||||||
|             "phone_number_verified": True, |             "phone_number_verified": True, | ||||||
|         } |         } | ||||||
|  |   - identifiers: | ||||||
|  |       managed: goauthentik.io/providers/oauth2/scope-profile-oidc-standard | ||||||
|  |     model: authentik_providers_oauth2.scopemapping | ||||||
|  |     attrs: | ||||||
|  |       name: "OIDC conformance profile" | ||||||
|  |       scope_name: profile | ||||||
|  |       description: "General profile information" | ||||||
|  |       expression: | | ||||||
|  |         return { | ||||||
|  |             # Because authentik only saves the user's full name, and has no concept of first and last names, | ||||||
|  |             # the full name is used as given name. | ||||||
|  |             # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")` | ||||||
|  |             "name": request.user.name, | ||||||
|  |             "given_name": request.user.name, | ||||||
|  |             "preferred_username": request.user.username, | ||||||
|  |             "nickname": request.user.username, | ||||||
|  |             "groups": [group.name for group in request.user.ak_groups.all()], | ||||||
|  |             "website" : "foo", | ||||||
|  |             "zoneinfo" : "foo", | ||||||
|  |             "birthdate" : "2000", | ||||||
|  |             "gender" : "foo", | ||||||
|  |             "profile" : "foo", | ||||||
|  |             "middle_name" : "foo", | ||||||
|  |             "locale" : "foo", | ||||||
|  |             "picture" : "foo", | ||||||
|  |             "updated_at" : 1748557810, | ||||||
|  |             "family_name" : "foo", | ||||||
|  |         } | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|   - model: authentik_providers_oauth2.oauth2provider |   - model: authentik_providers_oauth2.oauth2provider | ||||||
|     id: provider |     id: oidc-conformance-1 | ||||||
|     identifiers: |     identifiers: | ||||||
|       name: provider |       name: oidc-conformance-1 | ||||||
|     attrs: |     attrs: | ||||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] |       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||||
|  |       invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] | ||||||
|  |       # Required as OIDC Conformance test requires issues to be the same across multiple clients | ||||||
|       issuer_mode: global |       issuer_mode: global | ||||||
|       client_id: 4054d882aff59755f2f279968b97ce8806a926e1 |       client_id: 4054d882aff59755f2f279968b97ce8806a926e1 | ||||||
|       client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 |       client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 | ||||||
|       redirect_uris: | |       redirect_uris: | ||||||
|         https://localhost:8443/test/a/authentik/callback |         - matching_mode: strict | ||||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback |           url: https://localhost:8443/test/a/authentik/callback | ||||||
|  |         - matching_mode: strict | ||||||
|  |           url: https://host.docker.internal:8443/test/a/authentik/callback | ||||||
|       property_mappings: |       property_mappings: | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||||
|  |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] | ||||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] |       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||||
|   - model: authentik_core.application |   - model: authentik_core.application | ||||||
|     identifiers: |     identifiers: | ||||||
|       slug: conformance |       slug: oidc-conformance-1 | ||||||
|     attrs: |     attrs: | ||||||
|       provider: !KeyOf provider |       provider: !KeyOf oidc-conformance-1 | ||||||
|       name: Conformance |       name: OIDC Conformance (1) | ||||||
| 
 | 
 | ||||||
|   - model: authentik_providers_oauth2.oauth2provider |   - model: authentik_providers_oauth2.oauth2provider | ||||||
|     id: oidc-conformance-2 |     id: oidc-conformance-2 | ||||||
| @ -60,22 +96,27 @@ entries: | |||||||
|       name: oidc-conformance-2 |       name: oidc-conformance-2 | ||||||
|     attrs: |     attrs: | ||||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] |       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||||
|  |       invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] | ||||||
|  |       # Required as OIDC Conformance test requires issues to be the same across multiple clients | ||||||
|       issuer_mode: global |       issuer_mode: global | ||||||
|       client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 |       client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 | ||||||
|       client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 |       client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 | ||||||
|       redirect_uris: | |       redirect_uris: | ||||||
|         https://localhost:8443/test/a/authentik/callback |         - matching_mode: strict | ||||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback |           url: https://localhost:8443/test/a/authentik/callback | ||||||
|  |         - matching_mode: strict | ||||||
|  |           url: https://host.docker.internal:8443/test/a/authentik/callback | ||||||
|       property_mappings: |       property_mappings: | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||||
|  |         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] | ||||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] |       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||||
|   - model: authentik_core.application |   - model: authentik_core.application | ||||||
|     identifiers: |     identifiers: | ||||||
|       slug: oidc-conformance-2 |       slug: oidc-conformance-2 | ||||||
|     attrs: |     attrs: | ||||||
|       provider: !KeyOf oidc-conformance-2 |       provider: !KeyOf oidc-conformance-2 | ||||||
|       name: OIDC Conformance |       name: OIDC Conformance (2) | ||||||
| @ -31,7 +31,7 @@ services: | |||||||
|     volumes: |     volumes: | ||||||
|       - redis:/data |       - redis:/data | ||||||
|   server: |   server: | ||||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} |     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     command: server |     command: server | ||||||
|     environment: |     environment: | ||||||
| @ -55,7 +55,7 @@ services: | |||||||
|       redis: |       redis: | ||||||
|         condition: service_healthy |         condition: service_healthy | ||||||
|   worker: |   worker: | ||||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} |     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     command: worker |     command: worker | ||||||
|     environment: |     environment: | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.mod
									
									
									
									
									
								
							| @ -6,7 +6,7 @@ require ( | |||||||
| 	beryju.io/ldap v0.1.0 | 	beryju.io/ldap v0.1.0 | ||||||
| 	github.com/avast/retry-go/v4 v4.6.1 | 	github.com/avast/retry-go/v4 v4.6.1 | ||||||
| 	github.com/coreos/go-oidc/v3 v3.14.1 | 	github.com/coreos/go-oidc/v3 v3.14.1 | ||||||
| 	github.com/getsentry/sentry-go v0.33.0 | 	github.com/getsentry/sentry-go v0.34.0 | ||||||
| 	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 | 	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 | ||||||
| 	github.com/go-ldap/ldap/v3 v3.4.11 | 	github.com/go-ldap/ldap/v3 v3.4.11 | ||||||
| 	github.com/go-openapi/runtime v0.28.0 | 	github.com/go-openapi/runtime v0.28.0 | ||||||
| @ -23,13 +23,13 @@ require ( | |||||||
| 	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 | 	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 | ||||||
| 	github.com/pires/go-proxyproto v0.8.1 | 	github.com/pires/go-proxyproto v0.8.1 | ||||||
| 	github.com/prometheus/client_golang v1.22.0 | 	github.com/prometheus/client_golang v1.22.0 | ||||||
| 	github.com/redis/go-redis/v9 v9.10.0 | 	github.com/redis/go-redis/v9 v9.11.0 | ||||||
| 	github.com/sethvargo/go-envconfig v1.3.0 | 	github.com/sethvargo/go-envconfig v1.3.0 | ||||||
| 	github.com/sirupsen/logrus v1.9.3 | 	github.com/sirupsen/logrus v1.9.3 | ||||||
| 	github.com/spf13/cobra v1.9.1 | 	github.com/spf13/cobra v1.9.1 | ||||||
| 	github.com/stretchr/testify v1.10.0 | 	github.com/stretchr/testify v1.10.0 | ||||||
| 	github.com/wwt/guac v1.3.2 | 	github.com/wwt/guac v1.3.2 | ||||||
| 	goauthentik.io/api/v3 v3.2025062.3 | 	goauthentik.io/api/v3 v3.2025063.1 | ||||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||||
| 	golang.org/x/oauth2 v0.30.0 | 	golang.org/x/oauth2 v0.30.0 | ||||||
| 	golang.org/x/sync v0.15.0 | 	golang.org/x/sync v0.15.0 | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.sum
									
									
									
									
									
								
							| @ -71,8 +71,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m | |||||||
| github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||||
| github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= | github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= | ||||||
| github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||||
| github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= | github.com/getsentry/sentry-go v0.34.0 h1:1FCHBVp8TfSc8L10zqSwXUZNiOSF+10qw4czjarTiY4= | ||||||
| github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | github.com/getsentry/sentry-go v0.34.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | ||||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | ||||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||||
| github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | ||||||
| @ -251,8 +251,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ | |||||||
| github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= | ||||||
| github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= | ||||||
| github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= | ||||||
| github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= | github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= | ||||||
| github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= | github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= | ||||||
| github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||||||
| github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= | ||||||
| github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= | ||||||
| @ -298,8 +298,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y | |||||||
| go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||||
| go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||||
| goauthentik.io/api/v3 v3.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA= | goauthentik.io/api/v3 v3.2025063.1 h1:zvKhZTESgMY/SNiLuTs7G0YleBnev1v7+S9Xd6PZ9bc= | ||||||
| goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | goauthentik.io/api/v3 v3.2025063.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||||
| golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
|  | |||||||
| @ -33,4 +33,4 @@ func UserAgent() string { | |||||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||||
| } | } | ||||||
|  |  | ||||||
| const VERSION = "2025.6.2" | const VERSION = "2025.6.3" | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ type FlowExecutor struct { | |||||||
| 	Params            url.Values | 	Params            url.Values | ||||||
| 	Answers           map[StageComponent]string | 	Answers           map[StageComponent]string | ||||||
| 	Context           context.Context | 	Context           context.Context | ||||||
|  | 	InteractiveSolver SolverFunction | ||||||
|  |  | ||||||
| 	solvers map[StageComponent]SolverFunction | 	solvers map[StageComponent]SolverFunction | ||||||
|  |  | ||||||
| @ -94,6 +95,10 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config | |||||||
| 	return fe | 	return fe | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (fe *FlowExecutor) AddHeader(name string, value string) { | ||||||
|  | 	fe.api.GetConfig().AddDefaultHeader(name, value) | ||||||
|  | } | ||||||
|  |  | ||||||
| func (fe *FlowExecutor) RoundTrip(req *http.Request) (*http.Response, error) { | func (fe *FlowExecutor) RoundTrip(req *http.Request) (*http.Response, error) { | ||||||
| 	res, err := fe.transport.RoundTrip(req) | 	res, err := fe.transport.RoundTrip(req) | ||||||
| 	if res != nil { | 	if res != nil { | ||||||
| @ -110,7 +115,7 @@ func (fe *FlowExecutor) ApiClient() *api.APIClient { | |||||||
| 	return fe.api | 	return fe.api | ||||||
| } | } | ||||||
|  |  | ||||||
| type challengeCommon interface { | type ChallengeCommon interface { | ||||||
| 	GetComponent() string | 	GetComponent() string | ||||||
| 	GetResponseErrors() map[string][]api.ErrorDetail | 	GetResponseErrors() map[string][]api.ErrorDetail | ||||||
| } | } | ||||||
| @ -165,7 +170,7 @@ func (fe *FlowExecutor) getInitialChallenge() (*api.ChallengeTypes, error) { | |||||||
| 	if i == nil { | 	if i == nil { | ||||||
| 		return nil, errors.New("response instance was null") | 		return nil, errors.New("response instance was null") | ||||||
| 	} | 	} | ||||||
| 	ch := i.(challengeCommon) | 	ch := i.(ChallengeCommon) | ||||||
| 	fe.log.WithField("component", ch.GetComponent()).Debug("Got challenge") | 	fe.log.WithField("component", ch.GetComponent()).Debug("Got challenge") | ||||||
| 	gcsp.SetTag("authentik.flow.component", ch.GetComponent()) | 	gcsp.SetTag("authentik.flow.component", ch.GetComponent()) | ||||||
| 	gcsp.Finish() | 	gcsp.Finish() | ||||||
| @ -184,7 +189,7 @@ func (fe *FlowExecutor) solveFlowChallenge(challenge *api.ChallengeTypes, depth | |||||||
| 	if i == nil { | 	if i == nil { | ||||||
| 		return false, errors.New("response request instance was null") | 		return false, errors.New("response request instance was null") | ||||||
| 	} | 	} | ||||||
| 	ch := i.(challengeCommon) | 	ch := i.(ChallengeCommon) | ||||||
|  |  | ||||||
| 	// Check for any validation errors that we might've gotten | 	// Check for any validation errors that we might've gotten | ||||||
| 	if len(ch.GetResponseErrors()) > 0 { | 	if len(ch.GetResponseErrors()) > 0 { | ||||||
| @ -201,11 +206,17 @@ func (fe *FlowExecutor) solveFlowChallenge(challenge *api.ChallengeTypes, depth | |||||||
| 	case string(StageRedirect): | 	case string(StageRedirect): | ||||||
| 		return true, nil | 		return true, nil | ||||||
| 	default: | 	default: | ||||||
|  | 		var err error | ||||||
|  | 		var rr api.FlowChallengeResponseRequest | ||||||
|  | 		if fe.InteractiveSolver != nil { | ||||||
|  | 			rr, err = fe.InteractiveSolver(challenge, responseReq) | ||||||
|  | 		} else { | ||||||
| 			solver, ok := fe.solvers[StageComponent(ch.GetComponent())] | 			solver, ok := fe.solvers[StageComponent(ch.GetComponent())] | ||||||
| 			if !ok { | 			if !ok { | ||||||
| 				return false, fmt.Errorf("unsupported challenge type %s", ch.GetComponent()) | 				return false, fmt.Errorf("unsupported challenge type %s", ch.GetComponent()) | ||||||
| 			} | 			} | ||||||
| 		rr, err := solver(challenge, responseReq) | 			rr, err = solver(challenge, responseReq) | ||||||
|  | 		} | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, err | 			return false, err | ||||||
| 		} | 		} | ||||||
| @ -220,7 +231,7 @@ func (fe *FlowExecutor) solveFlowChallenge(challenge *api.ChallengeTypes, depth | |||||||
| 	if i == nil { | 	if i == nil { | ||||||
| 		return false, errors.New("response instance was null") | 		return false, errors.New("response instance was null") | ||||||
| 	} | 	} | ||||||
| 	ch = i.(challengeCommon) | 	ch = i.(ChallengeCommon) | ||||||
| 	fe.log.WithField("component", ch.GetComponent()).Debug("Got response") | 	fe.log.WithField("component", ch.GetComponent()).Debug("Got response") | ||||||
| 	scsp.SetTag("authentik.flow.component", ch.GetComponent()) | 	scsp.SetTag("authentik.flow.component", ch.GetComponent()) | ||||||
| 	scsp.Finish() | 	scsp.Finish() | ||||||
|  | |||||||
| @ -8,6 +8,6 @@ import ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| func TestConvert(t *testing.T) { | func TestConvert(t *testing.T) { | ||||||
| 	var a challengeCommon = api.NewIdentificationChallengeWithDefaults() | 	var a ChallengeCommon = api.NewIdentificationChallengeWithDefaults() | ||||||
| 	assert.NotNil(t, a) | 	assert.NotNil(t, a) | ||||||
| } | } | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ import ( | |||||||
|  |  | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
| 	"goauthentik.io/internal/outpost/ak" | 	"goauthentik.io/internal/outpost/ak" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func parseCIDRs(raw string) []*net.IPNet { | func parseCIDRs(raw string) []*net.IPNet { | ||||||
| @ -41,26 +42,28 @@ func (rs *RadiusServer) Refresh() error { | |||||||
| 	if len(apiProviders) < 1 { | 	if len(apiProviders) < 1 { | ||||||
| 		return errors.New("no radius provider defined") | 		return errors.New("no radius provider defined") | ||||||
| 	} | 	} | ||||||
| 	providers := make([]*ProviderInstance, len(apiProviders)) | 	providers := make(map[int32]*ProviderInstance) | ||||||
| 	for idx, provider := range apiProviders { | 	for _, provider := range apiProviders { | ||||||
|  | 		existing, ok := rs.providers[provider.Pk] | ||||||
|  | 		state := map[string]*protocol.State{} | ||||||
|  | 		if ok { | ||||||
|  | 			state = existing.eapState | ||||||
|  | 		} | ||||||
| 		logger := log.WithField("logger", "authentik.outpost.radius").WithField("provider", provider.Name) | 		logger := log.WithField("logger", "authentik.outpost.radius").WithField("provider", provider.Name) | ||||||
| 		providers[idx] = &ProviderInstance{ | 		providers[provider.Pk] = &ProviderInstance{ | ||||||
| 			SharedSecret:   []byte(provider.GetSharedSecret()), | 			SharedSecret:   []byte(provider.GetSharedSecret()), | ||||||
| 			ClientNetworks: parseCIDRs(provider.GetClientNetworks()), | 			ClientNetworks: parseCIDRs(provider.GetClientNetworks()), | ||||||
| 			MFASupport:     provider.GetMfaSupport(), | 			MFASupport:     provider.GetMfaSupport(), | ||||||
| 			appSlug:        provider.ApplicationSlug, | 			appSlug:        provider.ApplicationSlug, | ||||||
| 			flowSlug:       provider.AuthFlowSlug, | 			flowSlug:       provider.AuthFlowSlug, | ||||||
|  | 			certId:         provider.GetCertificate(), | ||||||
| 			providerId:     provider.Pk, | 			providerId:     provider.Pk, | ||||||
| 			s:              rs, | 			s:              rs, | ||||||
| 			log:            logger, | 			log:            logger, | ||||||
|  | 			eapState:       state, | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	rs.providers = providers | 	rs.providers = providers | ||||||
| 	rs.log.Info("Update providers") | 	rs.log.Info("Update providers") | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| func (rs *RadiusServer) StartRadiusServer() error { |  | ||||||
| 	rs.log.WithField("listen", rs.s.Addr).Info("Starting radius server") |  | ||||||
| 	return rs.s.ListenAndServe() |  | ||||||
| } |  | ||||||
|  | |||||||
							
								
								
									
										44
									
								
								internal/outpost/radius/eap/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								internal/outpost/radius/eap/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,44 @@ | |||||||
|  | # EAP protocol implementation | ||||||
|  |  | ||||||
|  | Install `eapol_test` (`sudo apt install eapoltest`) | ||||||
|  |  | ||||||
|  | Both PEAP and EAP-TLS require a minimal PKI setup. A CA, a certificate for the server and for EAP-TLS a client certificate need to be provided. | ||||||
|  |  | ||||||
|  | Save either of the config files below and run eapoltest like so: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | # peap.conf is the config file under the PEAP testing section | ||||||
|  | # foo is the shared RADIUS secret | ||||||
|  | # 1.2.3.4 is the IP of the RADIUS server | ||||||
|  | eapol_test -c peap.conf -s foo -a 1.2.3.4 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### PEAP testing | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | network={ | ||||||
|  |     ssid="DoesNotMatterForThisTest" | ||||||
|  |     key_mgmt=WPA-EAP | ||||||
|  |     eap=PEAP | ||||||
|  |     identity="foo" | ||||||
|  |     password="bar" | ||||||
|  |     ca_cert="ca.pem" | ||||||
|  |     phase2="auth=MSCHAPV2" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### EAP-TLS testing | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | network={ | ||||||
|  |     ssid="DoesNotMatterForThisTest" | ||||||
|  |     key_mgmt=WPA-EAP | ||||||
|  |     eap=TLS | ||||||
|  |     identity="foo" | ||||||
|  |     ca_cert="ca.pem" | ||||||
|  |     client_cert="cert_client.pem" | ||||||
|  |     private_key="cert_client.key" | ||||||
|  |     eapol_flags=3 | ||||||
|  |     eap_workaround=0 | ||||||
|  | } | ||||||
|  | ``` | ||||||
							
								
								
									
										55
									
								
								internal/outpost/radius/eap/context.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								internal/outpost/radius/eap/context.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | |||||||
|  | package eap | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  |  | ||||||
|  | 	log "github.com/sirupsen/logrus" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol" | ||||||
|  | 	"layeh.com/radius" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type context struct { | ||||||
|  | 	req         *radius.Request | ||||||
|  | 	rootPayload protocol.Payload | ||||||
|  | 	typeState   map[protocol.Type]any | ||||||
|  | 	log         *log.Entry | ||||||
|  | 	settings    interface{} | ||||||
|  | 	parent      *context | ||||||
|  | 	endStatus   protocol.Status | ||||||
|  | 	handleInner func(protocol.Payload, protocol.StateManager, protocol.Context) (protocol.Payload, error) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (ctx *context) RootPayload() protocol.Payload            { return ctx.rootPayload } | ||||||
|  | func (ctx *context) Packet() *radius.Request                  { return ctx.req } | ||||||
|  | func (ctx *context) ProtocolSettings() any                    { return ctx.settings } | ||||||
|  | func (ctx *context) GetProtocolState(p protocol.Type) any     { return ctx.typeState[p] } | ||||||
|  | func (ctx *context) SetProtocolState(p protocol.Type, st any) { ctx.typeState[p] = st } | ||||||
|  | func (ctx *context) IsProtocolStart(p protocol.Type) bool     { return ctx.typeState[p] == nil } | ||||||
|  | func (ctx *context) Log() *log.Entry                          { return ctx.log } | ||||||
|  | func (ctx *context) HandleInnerEAP(p protocol.Payload, st protocol.StateManager) (protocol.Payload, error) { | ||||||
|  | 	return ctx.handleInner(p, st, ctx) | ||||||
|  | } | ||||||
|  | func (ctx *context) Inner(p protocol.Payload, t protocol.Type) protocol.Context { | ||||||
|  | 	nctx := &context{ | ||||||
|  | 		req:         ctx.req, | ||||||
|  | 		rootPayload: ctx.rootPayload, | ||||||
|  | 		typeState:   ctx.typeState, | ||||||
|  | 		log:         ctx.log.WithField("type", fmt.Sprintf("%T", p)).WithField("code", t), | ||||||
|  | 		settings:    ctx.settings, | ||||||
|  | 		parent:      ctx, | ||||||
|  | 		handleInner: ctx.handleInner, | ||||||
|  | 	} | ||||||
|  | 	nctx.log.Debug("Creating inner context") | ||||||
|  | 	return nctx | ||||||
|  | } | ||||||
|  | func (ctx *context) EndInnerProtocol(st protocol.Status) { | ||||||
|  | 	ctx.log.Info("Ending protocol") | ||||||
|  | 	if ctx.parent != nil { | ||||||
|  | 		ctx.parent.EndInnerProtocol(st) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if ctx.endStatus != protocol.StatusUnknown { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	ctx.endStatus = st | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								internal/outpost/radius/eap/debug/debug.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								internal/outpost/radius/eap/debug/debug.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | package debug | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func FormatBytes(d []byte) string { | ||||||
|  | 	b := d | ||||||
|  | 	if len(b) > 32 { | ||||||
|  | 		b = b[:32] | ||||||
|  | 	} | ||||||
|  | 	return fmt.Sprintf("% x", b) | ||||||
|  | } | ||||||
							
								
								
									
										182
									
								
								internal/outpost/radius/eap/handler.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								internal/outpost/radius/eap/handler.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,182 @@ | |||||||
|  | package eap | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"crypto/hmac" | ||||||
|  | 	"crypto/md5" | ||||||
|  | 	"encoding/base64" | ||||||
|  | 	"fmt" | ||||||
|  | 	"reflect" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/securecookie" | ||||||
|  | 	log "github.com/sirupsen/logrus" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol/eap" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol/legacy_nak" | ||||||
|  | 	"layeh.com/radius" | ||||||
|  | 	"layeh.com/radius/rfc2865" | ||||||
|  | 	"layeh.com/radius/rfc2869" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func sendErrorResponse(w radius.ResponseWriter, r *radius.Request) { | ||||||
|  | 	rres := r.Response(radius.CodeAccessReject) | ||||||
|  | 	err := w.Write(rres) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.WithError(err).Warning("failed to send response") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (p *Packet) HandleRadiusPacket(w radius.ResponseWriter, r *radius.Request) { | ||||||
|  | 	p.r = r | ||||||
|  | 	rst := rfc2865.State_GetString(r.Packet) | ||||||
|  | 	if rst == "" { | ||||||
|  | 		rst = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(12)) | ||||||
|  | 	} | ||||||
|  | 	p.state = rst | ||||||
|  |  | ||||||
|  | 	rp := &Packet{r: r} | ||||||
|  | 	rep, err := p.handleEAP(p.eap, p.stm, nil) | ||||||
|  | 	rp.eap = rep | ||||||
|  |  | ||||||
|  | 	rres := r.Response(radius.CodeAccessReject) | ||||||
|  | 	if err == nil { | ||||||
|  | 		switch rp.eap.Code { | ||||||
|  | 		case protocol.CodeRequest: | ||||||
|  | 			rres.Code = radius.CodeAccessChallenge | ||||||
|  | 		case protocol.CodeFailure: | ||||||
|  | 			rres.Code = radius.CodeAccessReject | ||||||
|  | 		case protocol.CodeSuccess: | ||||||
|  | 			rres.Code = radius.CodeAccessAccept | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		rres.Code = radius.CodeAccessReject | ||||||
|  | 		log.WithError(err).Debug("Rejecting request") | ||||||
|  | 	} | ||||||
|  | 	for _, mod := range p.responseModifiers { | ||||||
|  | 		err := mod.ModifyRADIUSResponse(rres, r.Packet) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.WithError(err).Warning("Root-EAP: failed to modify response packet") | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	rfc2865.State_SetString(rres, p.state) | ||||||
|  | 	eapEncoded, err := rp.Encode() | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.WithError(err).Warning("failed to encode response") | ||||||
|  | 		sendErrorResponse(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	log.WithField("length", len(eapEncoded)).WithField("type", fmt.Sprintf("%T", rp.eap.Payload)).Debug("Root-EAP: encapsulated challenge") | ||||||
|  | 	rfc2869.EAPMessage_Set(rres, eapEncoded) | ||||||
|  | 	err = p.setMessageAuthenticator(rres) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.WithError(err).Warning("failed to send message authenticator") | ||||||
|  | 		sendErrorResponse(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	err = w.Write(rres) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.WithError(err).Warning("failed to send response") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (p *Packet) handleEAP(pp protocol.Payload, stm protocol.StateManager, parentContext *context) (*eap.Payload, error) { | ||||||
|  | 	st := stm.GetEAPState(p.state) | ||||||
|  | 	if st == nil { | ||||||
|  | 		log.Debug("Root-EAP: blank state") | ||||||
|  | 		st = protocol.BlankState(stm.GetEAPSettings()) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	nextChallengeToOffer, err := st.GetNextProtocol() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return &eap.Payload{ | ||||||
|  | 			Code: protocol.CodeFailure, | ||||||
|  | 			ID:   p.eap.ID, | ||||||
|  | 		}, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	next := func() (*eap.Payload, error) { | ||||||
|  | 		st.ProtocolIndex += 1 | ||||||
|  | 		st.TypeState = map[protocol.Type]any{} | ||||||
|  | 		stm.SetEAPState(p.state, st) | ||||||
|  | 		return p.handleEAP(pp, stm, nil) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if n, ok := pp.(*eap.Payload).Payload.(*legacy_nak.Payload); ok { | ||||||
|  | 		log.WithField("desired", n.DesiredType).Debug("Root-EAP: received NAK, trying next protocol") | ||||||
|  | 		pp.(*eap.Payload).Payload = nil | ||||||
|  | 		return next() | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	np, t, _ := eap.EmptyPayload(stm.GetEAPSettings(), nextChallengeToOffer) | ||||||
|  |  | ||||||
|  | 	var ctx *context | ||||||
|  | 	if parentContext != nil { | ||||||
|  | 		ctx = parentContext.Inner(np, t).(*context) | ||||||
|  | 		ctx.settings = stm.GetEAPSettings().ProtocolSettings[np.Type()] | ||||||
|  | 	} else { | ||||||
|  | 		ctx = &context{ | ||||||
|  | 			req:         p.r, | ||||||
|  | 			rootPayload: p.eap, | ||||||
|  | 			typeState:   st.TypeState, | ||||||
|  | 			log:         log.WithField("type", fmt.Sprintf("%T", np)).WithField("code", t), | ||||||
|  | 			settings:    stm.GetEAPSettings().ProtocolSettings[t], | ||||||
|  | 		} | ||||||
|  | 		ctx.handleInner = func(pp protocol.Payload, sm protocol.StateManager, ctx protocol.Context) (protocol.Payload, error) { | ||||||
|  | 			// cctx := ctx.Inner(np, np.Type(), nil).(*context) | ||||||
|  | 			return p.handleEAP(pp, sm, ctx.(*context)) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if !np.Offerable() { | ||||||
|  | 		ctx.Log().Debug("Root-EAP: protocol not offerable, skipping") | ||||||
|  | 		return next() | ||||||
|  | 	} | ||||||
|  | 	ctx.Log().Debug("Root-EAP: Passing to protocol") | ||||||
|  |  | ||||||
|  | 	res := &eap.Payload{ | ||||||
|  | 		Code:    protocol.CodeRequest, | ||||||
|  | 		ID:      p.eap.ID + 1, | ||||||
|  | 		MsgType: t, | ||||||
|  | 	} | ||||||
|  | 	var payload any | ||||||
|  | 	if reflect.TypeOf(pp.(*eap.Payload).Payload) == reflect.TypeOf(np) { | ||||||
|  | 		np.Decode(pp.(*eap.Payload).RawPayload) | ||||||
|  | 	} | ||||||
|  | 	payload = np.Handle(ctx) | ||||||
|  | 	if payload != nil { | ||||||
|  | 		res.Payload = payload.(protocol.Payload) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	stm.SetEAPState(p.state, st) | ||||||
|  |  | ||||||
|  | 	if rm, ok := np.(protocol.ResponseModifier); ok { | ||||||
|  | 		ctx.log.Debug("Root-EAP: Registered response modifier") | ||||||
|  | 		p.responseModifiers = append(p.responseModifiers, rm) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	switch ctx.endStatus { | ||||||
|  | 	case protocol.StatusSuccess: | ||||||
|  | 		res.Code = protocol.CodeSuccess | ||||||
|  | 		res.ID -= 1 | ||||||
|  | 	case protocol.StatusError: | ||||||
|  | 		res.Code = protocol.CodeFailure | ||||||
|  | 		res.ID -= 1 | ||||||
|  | 	case protocol.StatusNextProtocol: | ||||||
|  | 		ctx.log.Debug("Root-EAP: Protocol ended, starting next protocol") | ||||||
|  | 		return next() | ||||||
|  | 	case protocol.StatusUnknown: | ||||||
|  | 	} | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (p *Packet) setMessageAuthenticator(rp *radius.Packet) error { | ||||||
|  | 	_ = rfc2869.MessageAuthenticator_Set(rp, make([]byte, 16)) | ||||||
|  | 	hash := hmac.New(md5.New, rp.Secret) | ||||||
|  | 	encode, err := rp.MarshalBinary() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	hash.Write(encode) | ||||||
|  | 	_ = rfc2869.MessageAuthenticator_Set(rp, hash.Sum(nil)) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										34
									
								
								internal/outpost/radius/eap/packet.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								internal/outpost/radius/eap/packet.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | package eap | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol" | ||||||
|  | 	"goauthentik.io/internal/outpost/radius/eap/protocol/eap" | ||||||
|  | 	"layeh.com/radius" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Packet struct { | ||||||
|  | 	r                 *radius.Request | ||||||
|  | 	eap               *eap.Payload | ||||||
|  | 	stm               protocol.StateManager | ||||||
|  | 	state             string | ||||||
|  | 	responseModifiers []protocol.ResponseModifier | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func Decode(stm protocol.StateManager, raw []byte) (*Packet, error) { | ||||||
|  | 	packet := &Packet{ | ||||||
|  | 		eap: &eap.Payload{ | ||||||
|  | 			Settings: stm.GetEAPSettings(), | ||||||
|  | 		}, | ||||||
|  | 		stm:               stm, | ||||||
|  | 		responseModifiers: []protocol.ResponseModifier{}, | ||||||
|  | 	} | ||||||
|  | 	err := packet.eap.Decode(raw) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return packet, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (p *Packet) Encode() ([]byte, error) { | ||||||
|  | 	return p.eap.Encode() | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	