Compare commits
	
		
			64 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0edd7531a1 | |||
| 5a2c914d19 | |||
| f21062581a | |||
| 676e7885e8 | |||
| 80441d2277 | |||
| e760f73518 | |||
| 948f80d7ae | |||
| 0e4b153e7f | |||
| efac5ce7bd | |||
| d9fbe1d467 | |||
| 527e584699 | |||
| 80dfe371e6 | |||
| a3d1491aee | |||
| 1b98792637 | |||
| 111e120220 | |||
| 20642d49c3 | |||
| a9776a83d3 | |||
| b9faae83b4 | |||
| afc2998697 | |||
| fabacc56c4 | |||
| 11b013d3b8 | |||
| e10c47d8b8 | |||
| d2b194f6b7 | |||
| 780a59c908 | |||
| f8015fccd8 | |||
| 05f4e738a1 | |||
| f535a23c03 | |||
| 91905530c7 | |||
| 40a970e321 | |||
| b51d8d0ba3 | |||
| 7e8891338f | |||
| 3ae0001bb5 | |||
| 66a4970014 | |||
| 7ab9300761 | |||
| a2eccd5022 | |||
| 31aeaa247f | |||
| f49008bbb6 | |||
| feb13c8ee5 | |||
| d5ef831718 | |||
| 64676819ec | |||
| 7ed268fef4 | |||
| f6526d1be9 | |||
| 12f8b4566b | |||
| 665de8ef22 | |||
| 9eaa723bf8 | |||
| b2ca9c8cbc | |||
| 7927392100 | |||
| d8d07e32cb | |||
| f7c5d329eb | |||
| 92dec32547 | |||
| 510feccd31 | |||
| 364a9a1f02 | |||
| 40cbb7567b | |||
| 8ad0f63994 | |||
| 6ce33ab912 | |||
| d96b577abd | |||
| 8c547589f6 | |||
| 3775e5b84f | |||
| fa30339f65 | |||
| e825eda106 | |||
| 246cae3dfa | |||
| 6cfd2bd1af | |||
| f0e4f93fe6 | |||
| 434aa57ba7 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2024.12.0-rc1 | current_version = 2024.10.5 | ||||||
| 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*))? | ||||||
| @ -30,5 +30,3 @@ optional_value = final | |||||||
| [bumpversion:file:internal/constants/constants.go] | [bumpversion:file:internal/constants/constants.go] | ||||||
|  |  | ||||||
| [bumpversion:file:web/src/common/constants.ts] | [bumpversion:file:web/src/common/constants.ts] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/install-config/install/aws/template.yaml] |  | ||||||
|  | |||||||
| @ -11,9 +11,9 @@ inputs: | |||||||
|     description: "Docker image arch" |     description: "Docker image arch" | ||||||
|  |  | ||||||
| outputs: | outputs: | ||||||
|   shouldPush: |   shouldBuild: | ||||||
|     description: "Whether to push the image or not" |     description: "Whether to build image or not" | ||||||
|     value: ${{ steps.ev.outputs.shouldPush }} |     value: ${{ steps.ev.outputs.shouldBuild }} | ||||||
|  |  | ||||||
|   sha: |   sha: | ||||||
|     description: "sha" |     description: "sha" | ||||||
|  | |||||||
| @ -7,14 +7,7 @@ from time import time | |||||||
| parser = configparser.ConfigParser() | parser = configparser.ConfigParser() | ||||||
| parser.read(".bumpversion.cfg") | parser.read(".bumpversion.cfg") | ||||||
|  |  | ||||||
| # Decide if we should push the image or not | should_build = str(len(os.environ.get("DOCKER_USERNAME", "")) > 0).lower() | ||||||
| should_push = True |  | ||||||
| if len(os.environ.get("DOCKER_USERNAME", "")) < 1: |  | ||||||
|     # Don't push if we don't have DOCKER_USERNAME, i.e. no secrets are available |  | ||||||
|     should_push = False |  | ||||||
| if os.environ.get("GITHUB_REPOSITORY").lower() == "goauthentik/authentik-internal": |  | ||||||
|     # Don't push on the internal repo |  | ||||||
|     should_push = False |  | ||||||
|  |  | ||||||
| branch_name = os.environ["GITHUB_REF"] | branch_name = os.environ["GITHUB_REF"] | ||||||
| if os.environ.get("GITHUB_HEAD_REF", "") != "": | if os.environ.get("GITHUB_HEAD_REF", "") != "": | ||||||
| @ -71,7 +64,7 @@ def get_attest_image_names(image_with_tags: list[str]): | |||||||
|  |  | ||||||
|  |  | ||||||
| with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: | with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: | ||||||
|     print(f"shouldPush={str(should_push).lower()}", file=_output) |     print(f"shouldBuild={should_build}", file=_output) | ||||||
|     print(f"sha={sha}", file=_output) |     print(f"sha={sha}", file=_output) | ||||||
|     print(f"version={version}", file=_output) |     print(f"version={version}", file=_output) | ||||||
|     print(f"prerelease={prerelease}", file=_output) |     print(f"prerelease={prerelease}", file=_output) | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -35,7 +35,7 @@ runs: | |||||||
|       run: | |       run: | | ||||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} |         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d |         docker compose -f .github/actions/setup/docker-compose.yml up -d | ||||||
|         poetry install --sync |         poetry install | ||||||
|         cd web && npm ci |         cd web && npm ci | ||||||
|     - name: Generate config |     - name: Generate config | ||||||
|       shell: poetry run python {0} |       shell: poetry run python {0} | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,6 @@ on: | |||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     permissions: |     permissions: | ||||||
|       id-token: write |       id-token: write | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,6 @@ on: | |||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - id: generate_token |       - id: generate_token | ||||||
|  | |||||||
							
								
								
									
										46
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										46
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,46 +0,0 @@ | |||||||
| name: authentik-ci-aws-cfn |  | ||||||
|  |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: |  | ||||||
|       - main |  | ||||||
|       - next |  | ||||||
|       - version-* |  | ||||||
|   pull_request: |  | ||||||
|     branches: |  | ||||||
|       - main |  | ||||||
|       - version-* |  | ||||||
|  |  | ||||||
| env: |  | ||||||
|   POSTGRES_DB: authentik |  | ||||||
|   POSTGRES_USER: authentik |  | ||||||
|   POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" |  | ||||||
|  |  | ||||||
| jobs: |  | ||||||
|   check-changes-applied: |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|       - name: Setup authentik env |  | ||||||
|         uses: ./.github/actions/setup |  | ||||||
|       - uses: actions/setup-node@v4 |  | ||||||
|         with: |  | ||||||
|           node-version-file: website/package.json |  | ||||||
|           cache: "npm" |  | ||||||
|           cache-dependency-path: website/package-lock.json |  | ||||||
|       - working-directory: website/ |  | ||||||
|         run: | |  | ||||||
|           npm ci |  | ||||||
|       - name: Check changes have been applied |  | ||||||
|         run: | |  | ||||||
|           poetry run make aws-cfn |  | ||||||
|           git diff --exit-code |  | ||||||
|   ci-aws-cfn-mark: |  | ||||||
|     if: always() |  | ||||||
|     needs: |  | ||||||
|       - check-changes-applied |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - uses: re-actors/alls-green@release/v1 |  | ||||||
|         with: |  | ||||||
|           jobs: ${{ toJSON(needs) }} |  | ||||||
							
								
								
									
										25
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -116,7 +116,7 @@ jobs: | |||||||
|           poetry run make test |           poetry run make test | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v4 | ||||||
|         with: |         with: | ||||||
|           flags: unit |           flags: unit | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
| @ -134,13 +134,13 @@ jobs: | |||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - name: Create k8s Kind Cluster |       - name: Create k8s Kind Cluster | ||||||
|         uses: helm/kind-action@v1.11.0 |         uses: helm/kind-action@v1.10.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           poetry run coverage run manage.py test tests/integration |           poetry run coverage run manage.py test tests/integration | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v4 | ||||||
|         with: |         with: | ||||||
|           flags: integration |           flags: integration | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
| @ -198,7 +198,7 @@ jobs: | |||||||
|           poetry run coverage run manage.py test ${{ matrix.job.glob }} |           poetry run coverage run manage.py test ${{ matrix.job.glob }} | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v4 | ||||||
|         with: |         with: | ||||||
|           flags: e2e |           flags: e2e | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
| @ -209,7 +209,6 @@ jobs: | |||||||
|           file: unittest.xml |           file: unittest.xml | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
|   ci-core-mark: |   ci-core-mark: | ||||||
|     if: always() |  | ||||||
|     needs: |     needs: | ||||||
|       - lint |       - lint | ||||||
|       - test-migrations |       - test-migrations | ||||||
| @ -219,9 +218,7 @@ jobs: | |||||||
|       - test-e2e |       - test-e2e | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: re-actors/alls-green@release/v1 |       - run: echo mark | ||||||
|         with: |  | ||||||
|           jobs: ${{ toJSON(needs) }} |  | ||||||
|   build: |   build: | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
| @ -255,7 +252,7 @@ jobs: | |||||||
|           image-name: ghcr.io/goauthentik/dev-server |           image-name: ghcr.io/goauthentik/dev-server | ||||||
|           image-arch: ${{ matrix.arch }} |           image-arch: ${{ matrix.arch }} | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
| @ -272,15 +269,15 @@ jobs: | |||||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} |             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} |             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |           tags: ${{ steps.ev.outputs.imageTags }} | ||||||
|           push: ${{ steps.ev.outputs.shouldPush == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache |           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache | ||||||
|           cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }} |           cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }} | ||||||
|           platforms: linux/${{ matrix.arch }} |           platforms: linux/${{ matrix.arch }} | ||||||
|       - uses: actions/attest-build-provenance@v2 |       - uses: actions/attest-build-provenance@v1 | ||||||
|         id: attest |         id: attest | ||||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         with: |         with: | ||||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} |           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||||
|           subject-digest: ${{ steps.push.outputs.digest }} |           subject-digest: ${{ steps.push.outputs.digest }} | ||||||
| @ -306,7 +303,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           image-name: ghcr.io/goauthentik/dev-server |           image-name: ghcr.io/goauthentik/dev-server | ||||||
|       - name: Comment on PR |       - name: Comment on PR | ||||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         uses: ./.github/actions/comment-pr-instructions |         uses: ./.github/actions/comment-pr-instructions | ||||||
|         with: |         with: | ||||||
|           tag: ${{ steps.ev.outputs.imageMainTag }} |           tag: ${{ steps.ev.outputs.imageMainTag }} | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -49,15 +49,12 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... |           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | ||||||
|   ci-outpost-mark: |   ci-outpost-mark: | ||||||
|     if: always() |  | ||||||
|     needs: |     needs: | ||||||
|       - lint-golint |       - lint-golint | ||||||
|       - test-unittest |       - test-unittest | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: re-actors/alls-green@release/v1 |       - run: echo mark | ||||||
|         with: |  | ||||||
|           jobs: ${{ toJSON(needs) }} |  | ||||||
|   build-container: |   build-container: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     needs: |     needs: | ||||||
| @ -93,7 +90,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           image-name: ghcr.io/goauthentik/dev-${{ matrix.type }} |           image-name: ghcr.io/goauthentik/dev-${{ matrix.type }} | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
| @ -107,16 +104,16 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |           tags: ${{ steps.ev.outputs.imageTags }} | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           push: ${{ steps.ev.outputs.shouldPush == 'true' }} |           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache |           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache | ||||||
|           cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }} |           cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }} | ||||||
|       - uses: actions/attest-build-provenance@v2 |       - uses: actions/attest-build-provenance@v1 | ||||||
|         id: attest |         id: attest | ||||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
|         with: |         with: | ||||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} |           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||||
|           subject-digest: ${{ steps.push.outputs.digest }} |           subject-digest: ${{ steps.push.outputs.digest }} | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -61,15 +61,12 @@ jobs: | |||||||
|         working-directory: web/ |         working-directory: web/ | ||||||
|         run: npm run build |         run: npm run build | ||||||
|   ci-web-mark: |   ci-web-mark: | ||||||
|     if: always() |  | ||||||
|     needs: |     needs: | ||||||
|       - build |       - build | ||||||
|       - lint |       - lint | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: re-actors/alls-green@release/v1 |       - run: echo mark | ||||||
|         with: |  | ||||||
|           jobs: ${{ toJSON(needs) }} |  | ||||||
|   test: |   test: | ||||||
|     needs: |     needs: | ||||||
|       - ci-web-mark |       - ci-web-mark | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -62,13 +62,10 @@ jobs: | |||||||
|         working-directory: website/ |         working-directory: website/ | ||||||
|         run: npm run ${{ matrix.job }} |         run: npm run ${{ matrix.job }} | ||||||
|   ci-website-mark: |   ci-website-mark: | ||||||
|     if: always() |  | ||||||
|     needs: |     needs: | ||||||
|       - lint |       - lint | ||||||
|       - test |       - test | ||||||
|       - build |       - build | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: re-actors/alls-green@release/v1 |       - run: echo mark | ||||||
|         with: |  | ||||||
|           jobs: ${{ toJSON(needs) }} |  | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ env: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - id: generate_token |       - id: generate_token | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,6 @@ on: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   clean-ghcr: |   clean-ghcr: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     name: Delete old unused container images |     name: Delete old unused container images | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							| @ -12,7 +12,6 @@ env: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   publish-source-docs: |   publish-source-docs: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     steps: |     steps: | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,6 @@ permissions: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   update-next: |   update-next: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     environment: internal-production |     environment: internal-production | ||||||
|     steps: |     steps: | ||||||
|  | |||||||
							
								
								
									
										25
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -55,7 +55,7 @@ jobs: | |||||||
|             VERSION=${{ github.ref }} |             VERSION=${{ github.ref }} | ||||||
|           tags: ${{ steps.ev.outputs.imageTags }} |           tags: ${{ steps.ev.outputs.imageTags }} | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - uses: actions/attest-build-provenance@v2 |       - uses: actions/attest-build-provenance@v1 | ||||||
|         id: attest |         id: attest | ||||||
|         with: |         with: | ||||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} |           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||||
| @ -119,7 +119,7 @@ jobs: | |||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|       - uses: actions/attest-build-provenance@v2 |       - uses: actions/attest-build-provenance@v1 | ||||||
|         id: attest |         id: attest | ||||||
|         with: |         with: | ||||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} |           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||||
| @ -169,27 +169,6 @@ jobs: | |||||||
|           file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} |           file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} | ||||||
|           asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} |           asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} | ||||||
|           tag: ${{ github.ref }} |           tag: ${{ github.ref }} | ||||||
|   upload-aws-cfn-template: |  | ||||||
|     permissions: |  | ||||||
|       # Needed for AWS login |  | ||||||
|       id-token: write |  | ||||||
|       contents: read |  | ||||||
|     needs: |  | ||||||
|       - build-server |  | ||||||
|       - build-outpost |  | ||||||
|     env: |  | ||||||
|       AWS_REGION: eu-central-1 |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|       - uses: aws-actions/configure-aws-credentials@v4 |  | ||||||
|         with: |  | ||||||
|           role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik" |  | ||||||
|           aws-region: ${{ env.AWS_REGION }} |  | ||||||
|       - name: Upload template |  | ||||||
|         run: | |  | ||||||
|           aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml |  | ||||||
|           aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml |  | ||||||
|   test-release: |   test-release: | ||||||
|     needs: |     needs: | ||||||
|       - build-server |       - build-server | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -18,7 +18,7 @@ jobs: | |||||||
|           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env |           echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env | ||||||
|           docker buildx install |           docker buildx install | ||||||
|           mkdir -p ./gen-ts-api |           mkdir -p ./gen-ts-api | ||||||
|           docker build -t testing:latest . |           docker build --no-cache -t testing:latest . | ||||||
|           echo "AUTHENTIK_IMAGE=testing" >> .env |           echo "AUTHENTIK_IMAGE=testing" >> .env | ||||||
|           echo "AUTHENTIK_TAG=latest" >> .env |           echo "AUTHENTIK_TAG=latest" >> .env | ||||||
|           docker compose up --no-start |           docker compose up --no-start | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,21 +0,0 @@ | |||||||
| name: "authentik-repo-mirror" |  | ||||||
|  |  | ||||||
| on: [push, delete] |  | ||||||
|  |  | ||||||
| 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: pixta-dev/repository-mirroring-action@v1 |  | ||||||
|         with: |  | ||||||
|           target_repo_url: |  | ||||||
|             git@github.com:goauthentik/authentik-internal.git |  | ||||||
|           ssh_private_key: |  | ||||||
|             ${{ secrets.GH_MIRROR_KEY }} |  | ||||||
|         env: |  | ||||||
|           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} |  | ||||||
							
								
								
									
										1
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,6 @@ permissions: | |||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   stale: |   stale: | ||||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - id: generate_token |       - id: generate_token | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -33,8 +33,7 @@ | |||||||
|         "!If sequence", |         "!If sequence", | ||||||
|         "!Index scalar", |         "!Index scalar", | ||||||
|         "!KeyOf scalar", |         "!KeyOf scalar", | ||||||
|         "!Value scalar", |         "!Value scalar" | ||||||
|         "!AtIndex scalar" |  | ||||||
|     ], |     ], | ||||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", |     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", |     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							| @ -19,18 +19,10 @@ Dockerfile                      @goauthentik/infrastructure | |||||||
| *Dockerfile                     @goauthentik/infrastructure | *Dockerfile                     @goauthentik/infrastructure | ||||||
| .dockerignore                   @goauthentik/infrastructure | .dockerignore                   @goauthentik/infrastructure | ||||||
| docker-compose.yml              @goauthentik/infrastructure | docker-compose.yml              @goauthentik/infrastructure | ||||||
| Makefile                        @goauthentik/infrastructure |  | ||||||
| .editorconfig                   @goauthentik/infrastructure |  | ||||||
| CODEOWNERS                      @goauthentik/infrastructure |  | ||||||
| # Web | # Web | ||||||
| web/                            @goauthentik/frontend | web/                            @goauthentik/frontend | ||||||
| tests/wdio/                     @goauthentik/frontend | tests/wdio/                     @goauthentik/frontend | ||||||
| # Locale |  | ||||||
| locale/                         @goauthentik/backend @goauthentik/frontend |  | ||||||
| web/xliff/                      @goauthentik/backend @goauthentik/frontend |  | ||||||
| # Docs & Website | # Docs & Website | ||||||
| website/                        @goauthentik/docs | website/                        @goauthentik/docs | ||||||
| CODE_OF_CONDUCT.md              @goauthentik/docs |  | ||||||
| # Security | # Security | ||||||
| SECURITY.md                     @goauthentik/security @goauthentik/docs | website/docs/security/          @goauthentik/security | ||||||
| website/docs/security/          @goauthentik/security @goauthentik/docs |  | ||||||
|  | |||||||
| @ -1 +1 @@ | |||||||
| website/docs/developer-docs/index.md | website/developer-docs/index.md | ||||||
| @ -80,7 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | |||||||
|     go build -o /go/authentik ./cmd/server |     go build -o /go/authentik ./cmd/server | ||||||
|  |  | ||||||
| # Stage 4: MaxMind GeoIP | # Stage 4: MaxMind GeoIP | ||||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip | FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 AS geoip | ||||||
|  |  | ||||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ||||||
| ENV GEOIPUPDATE_VERBOSE="1" | ENV GEOIPUPDATE_VERBOSE="1" | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Makefile
									
									
									
									
									
								
							| @ -5,7 +5,7 @@ PWD = $(shell pwd) | |||||||
| UID = $(shell id -u) | UID = $(shell id -u) | ||||||
| GID = $(shell id -g) | GID = $(shell id -g) | ||||||
| NPM_VERSION = $(shell python -m scripts.npm_version) | NPM_VERSION = $(shell python -m scripts.npm_version) | ||||||
| PY_SOURCES = authentik tests scripts lifecycle .github website/docs/install-config/install/aws | PY_SOURCES = authentik tests scripts lifecycle .github | ||||||
| DOCKER_IMAGE ?= "authentik:test" | DOCKER_IMAGE ?= "authentik:test" | ||||||
|  |  | ||||||
| GEN_API_TS = "gen-ts-api" | GEN_API_TS = "gen-ts-api" | ||||||
| @ -252,9 +252,6 @@ website-build: | |||||||
| website-watch:  ## Build and watch the documentation website, updating automatically | website-watch:  ## Build and watch the documentation website, updating automatically | ||||||
| 	cd website && npm run watch | 	cd website && npm run watch | ||||||
|  |  | ||||||
| aws-cfn: |  | ||||||
| 	cd website && npm run aws-cfn |  | ||||||
|  |  | ||||||
| ######################### | ######################### | ||||||
| ## Docker | ## Docker | ||||||
| ######################### | ######################### | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di | |||||||
|  |  | ||||||
| ## Independent audits and pentests | ## Independent audits and pentests | ||||||
|  |  | ||||||
| We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specfic audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security). | In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53). | ||||||
|  |  | ||||||
| ## What authentik classifies as a CVE | ## What authentik classifies as a CVE | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2024.12.0" | __version__ = "2024.10.5" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -146,10 +146,6 @@ entries: | |||||||
|                   ] |                   ] | ||||||
|               ] |               ] | ||||||
|               nested_context: !Context context2 |               nested_context: !Context context2 | ||||||
|               at_index_sequence: !AtIndex [!Context sequence, 0] |  | ||||||
|               at_index_sequence_default: !AtIndex [!Context sequence, 100, "non existent"] |  | ||||||
|               at_index_mapping: !AtIndex [!Context mapping, "key2"] |  | ||||||
|               at_index_mapping_default: !AtIndex [!Context mapping, "invalid", "non existent"] |  | ||||||
|       identifiers: |       identifiers: | ||||||
|           name: test |           name: test | ||||||
|       conditions: |       conditions: | ||||||
|  | |||||||
| @ -215,10 +215,6 @@ class TestBlueprintsV1(TransactionTestCase): | |||||||
|                     }, |                     }, | ||||||
|                     "nested_context": "context-nested-value", |                     "nested_context": "context-nested-value", | ||||||
|                     "env_null": None, |                     "env_null": None, | ||||||
|                     "at_index_sequence": "foo", |  | ||||||
|                     "at_index_sequence_default": "non existent", |  | ||||||
|                     "at_index_mapping": 2, |  | ||||||
|                     "at_index_mapping_default": "non existent", |  | ||||||
|                 } |                 } | ||||||
|             ).exists() |             ).exists() | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -24,10 +24,6 @@ from authentik.lib.sentry import SentryIgnoredException | |||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
|  |  | ||||||
|  |  | ||||||
| class UNSET: |  | ||||||
|     """Used to test whether a key has not been set.""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_attrs(obj: SerializerModel) -> dict[str, Any]: | def get_attrs(obj: SerializerModel) -> dict[str, Any]: | ||||||
|     """Get object's attributes via their serializer, and convert it to a normal dict""" |     """Get object's attributes via their serializer, and convert it to a normal dict""" | ||||||
|     serializer: Serializer = obj.serializer(obj) |     serializer: Serializer = obj.serializer(obj) | ||||||
| @ -560,53 +556,6 @@ class Value(EnumeratedItem): | |||||||
|             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc |             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc | ||||||
|  |  | ||||||
|  |  | ||||||
| class AtIndex(YAMLTag): |  | ||||||
|     """Get value at index of a sequence or mapping""" |  | ||||||
|  |  | ||||||
|     obj: YAMLTag | dict | list | tuple |  | ||||||
|     attribute: int | str | YAMLTag |  | ||||||
|     default: Any | UNSET |  | ||||||
|  |  | ||||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: |  | ||||||
|         super().__init__() |  | ||||||
|         self.obj = loader.construct_object(node.value[0]) |  | ||||||
|         self.attribute = loader.construct_object(node.value[1]) |  | ||||||
|         if len(node.value) == 2:  # noqa: PLR2004 |  | ||||||
|             self.default = UNSET |  | ||||||
|         else: |  | ||||||
|             self.default = loader.construct_object(node.value[2]) |  | ||||||
|  |  | ||||||
|     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: |  | ||||||
|         if isinstance(self.obj, YAMLTag): |  | ||||||
|             obj = self.obj.resolve(entry, blueprint) |  | ||||||
|         else: |  | ||||||
|             obj = self.obj |  | ||||||
|         if isinstance(self.attribute, YAMLTag): |  | ||||||
|             attribute = self.attribute.resolve(entry, blueprint) |  | ||||||
|         else: |  | ||||||
|             attribute = self.attribute |  | ||||||
|  |  | ||||||
|         if isinstance(obj, list | tuple): |  | ||||||
|             try: |  | ||||||
|                 return obj[attribute] |  | ||||||
|             except TypeError as exc: |  | ||||||
|                 raise EntryInvalidError.from_entry( |  | ||||||
|                     f"Invalid index for list: {attribute}", entry |  | ||||||
|                 ) from exc |  | ||||||
|             except IndexError as exc: |  | ||||||
|                 if self.default is UNSET: |  | ||||||
|                     raise EntryInvalidError.from_entry( |  | ||||||
|                         f"Index out of range: {attribute}", entry |  | ||||||
|                     ) from exc |  | ||||||
|                 return self.default |  | ||||||
|         if attribute in obj: |  | ||||||
|             return obj[attribute] |  | ||||||
|         else: |  | ||||||
|             if self.default is UNSET: |  | ||||||
|                 raise EntryInvalidError.from_entry(f"Key does not exist: {attribute}", entry) |  | ||||||
|             return self.default |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BlueprintDumper(SafeDumper): | class BlueprintDumper(SafeDumper): | ||||||
|     """Dump dataclasses to yaml""" |     """Dump dataclasses to yaml""" | ||||||
|  |  | ||||||
| @ -657,7 +606,6 @@ class BlueprintLoader(SafeLoader): | |||||||
|         self.add_constructor("!Enumerate", Enumerate) |         self.add_constructor("!Enumerate", Enumerate) | ||||||
|         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) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class EntryInvalidError(SentryIgnoredException): | class EntryInvalidError(SentryIgnoredException): | ||||||
|  | |||||||
| @ -65,12 +65,7 @@ from authentik.lib.utils.reflection import get_apps | |||||||
| from authentik.outposts.models import OutpostServiceConnection | from authentik.outposts.models import OutpostServiceConnection | ||||||
| from authentik.policies.models import Policy, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBindingModel | ||||||
| from authentik.policies.reputation.models import Reputation | from authentik.policies.reputation.models import Reputation | ||||||
| from authentik.providers.oauth2.models import ( | from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||||
|     AccessToken, |  | ||||||
|     AuthorizationCode, |  | ||||||
|     DeviceToken, |  | ||||||
|     RefreshToken, |  | ||||||
| ) |  | ||||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | ||||||
| from authentik.rbac.models import Role | from authentik.rbac.models import Role | ||||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||||
| @ -130,7 +125,6 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         MicrosoftEntraProviderGroup, |         MicrosoftEntraProviderGroup, | ||||||
|         EndpointDevice, |         EndpointDevice, | ||||||
|         EndpointDeviceConnection, |         EndpointDeviceConnection, | ||||||
|         DeviceToken, |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -299,11 +293,7 @@ class Importer: | |||||||
|  |  | ||||||
|         serializer_kwargs = {} |         serializer_kwargs = {} | ||||||
|         model_instance = existing_models.first() |         model_instance = existing_models.first() | ||||||
|         if ( |         if not isinstance(model(), BaseMetaModel) and model_instance: | ||||||
|             not isinstance(model(), BaseMetaModel) |  | ||||||
|             and model_instance |  | ||||||
|             and entry.state != BlueprintEntryDesiredState.MUST_CREATED |  | ||||||
|         ): |  | ||||||
|             self.logger.debug( |             self.logger.debug( | ||||||
|                 "Initialise serializer with instance", |                 "Initialise serializer with instance", | ||||||
|                 model=model, |                 model=model, | ||||||
| @ -313,12 +303,11 @@ class Importer: | |||||||
|             serializer_kwargs["instance"] = model_instance |             serializer_kwargs["instance"] = model_instance | ||||||
|             serializer_kwargs["partial"] = True |             serializer_kwargs["partial"] = True | ||||||
|         elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: |         elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: | ||||||
|             msg = ( |  | ||||||
|                 f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} " |  | ||||||
|                 "and object exists already", |  | ||||||
|             ) |  | ||||||
|             raise EntryInvalidError.from_entry( |             raise EntryInvalidError.from_entry( | ||||||
|                 ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"), |                 ( | ||||||
|  |                     f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} " | ||||||
|  |                     "and object exists already", | ||||||
|  |                 ), | ||||||
|                 entry, |                 entry, | ||||||
|             ) |             ) | ||||||
|         else: |         else: | ||||||
|  | |||||||
| @ -159,7 +159,7 @@ def blueprints_discovery(self: SystemTask, path: str | None = None): | |||||||
|         check_blueprint_v1_file(blueprint) |         check_blueprint_v1_file(blueprint) | ||||||
|         count += 1 |         count += 1 | ||||||
|     self.set_status( |     self.set_status( | ||||||
|         TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count)) |         TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": count}) | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -84,8 +84,8 @@ class CurrentBrandSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     matched_domain = CharField(source="domain") |     matched_domain = CharField(source="domain") | ||||||
|     branding_title = CharField() |     branding_title = CharField() | ||||||
|     branding_logo = CharField(source="branding_logo_url") |     branding_logo = CharField() | ||||||
|     branding_favicon = CharField(source="branding_favicon_url") |     branding_favicon = CharField() | ||||||
|     ui_footer_links = ListField( |     ui_footer_links = ListField( | ||||||
|         child=FooterLinkSerializer(), |         child=FooterLinkSerializer(), | ||||||
|         read_only=True, |         read_only=True, | ||||||
|  | |||||||
| @ -25,7 +25,5 @@ class BrandMiddleware: | |||||||
|             locale = brand.default_locale |             locale = brand.default_locale | ||||||
|             if locale != "": |             if locale != "": | ||||||
|                 locale_to_set = locale |                 locale_to_set = locale | ||||||
|         if locale_to_set: |         with override(locale_to_set): | ||||||
|             with override(locale_to_set): |             return self.get_response(request) | ||||||
|                 return self.get_response(request) |  | ||||||
|         return self.get_response(request) |  | ||||||
|  | |||||||
| @ -10,7 +10,6 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -72,18 +71,6 @@ class Brand(SerializerModel): | |||||||
|     ) |     ) | ||||||
|     attributes = models.JSONField(default=dict, blank=True) |     attributes = models.JSONField(default=dict, blank=True) | ||||||
|  |  | ||||||
|     def branding_logo_url(self) -> str: |  | ||||||
|         """Get branding_logo with the correct prefix""" |  | ||||||
|         if self.branding_logo.startswith("/static"): |  | ||||||
|             return CONFIG.get("web.path", "/")[:-1] + self.branding_logo |  | ||||||
|         return self.branding_logo |  | ||||||
|  |  | ||||||
|     def branding_favicon_url(self) -> str: |  | ||||||
|         """Get branding_favicon with the correct prefix""" |  | ||||||
|         if self.branding_favicon.startswith("/static"): |  | ||||||
|             return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon |  | ||||||
|         return self.branding_favicon |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Serializer: |     def serializer(self) -> Serializer: | ||||||
|         from authentik.brands.api import BrandSerializer |         from authentik.brands.api import BrandSerializer | ||||||
|  | |||||||
| @ -1,54 +0,0 @@ | |||||||
| """Application Roles API Viewset""" |  | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
| from rest_framework.exceptions import ValidationError |  | ||||||
| from rest_framework.viewsets import ModelViewSet |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import ModelSerializer |  | ||||||
| from authentik.core.models import ( |  | ||||||
|     Application, |  | ||||||
|     ApplicationEntitlement, |  | ||||||
|     User, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationEntitlementSerializer(ModelSerializer): |  | ||||||
|     """ApplicationEntitlement Serializer""" |  | ||||||
|  |  | ||||||
|     def validate_app(self, app: Application) -> Application: |  | ||||||
|         """Ensure user has permission to view""" |  | ||||||
|         user: User = self._context["request"].user |  | ||||||
|         if user.has_perm("view_application", app) or user.has_perm( |  | ||||||
|             "authentik_core.view_application" |  | ||||||
|         ): |  | ||||||
|             return app |  | ||||||
|         raise ValidationError(_("User does not have access to application."), code="invalid") |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = ApplicationEntitlement |  | ||||||
|         fields = [ |  | ||||||
|             "pbm_uuid", |  | ||||||
|             "name", |  | ||||||
|             "app", |  | ||||||
|             "attributes", |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet): |  | ||||||
|     """ApplicationEntitlement Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = ApplicationEntitlement.objects.all() |  | ||||||
|     serializer_class = ApplicationEntitlementSerializer |  | ||||||
|     search_fields = [ |  | ||||||
|         "pbm_uuid", |  | ||||||
|         "name", |  | ||||||
|         "app", |  | ||||||
|         "attributes", |  | ||||||
|     ] |  | ||||||
|     filterset_fields = [ |  | ||||||
|         "pbm_uuid", |  | ||||||
|         "name", |  | ||||||
|         "app", |  | ||||||
|     ] |  | ||||||
|     ordering = ["name"] |  | ||||||
| @ -159,9 +159,9 @@ class SourceViewSet( | |||||||
|  |  | ||||||
|  |  | ||||||
| class UserSourceConnectionSerializer(SourceSerializer): | class UserSourceConnectionSerializer(SourceSerializer): | ||||||
|     """User source connection""" |     """OAuth Source Serializer""" | ||||||
|  |  | ||||||
|     source_obj = SourceSerializer(read_only=True, source="source") |     source = SourceSerializer(read_only=True) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = UserSourceConnection |         model = UserSourceConnection | ||||||
| @ -169,10 +169,10 @@ class UserSourceConnectionSerializer(SourceSerializer): | |||||||
|             "pk", |             "pk", | ||||||
|             "user", |             "user", | ||||||
|             "source", |             "source", | ||||||
|             "source_obj", |  | ||||||
|             "created", |             "created", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|  |             "user": {"read_only": True}, | ||||||
|             "created": {"read_only": True}, |             "created": {"read_only": True}, | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @ -197,9 +197,9 @@ class UserSourceConnectionViewSet( | |||||||
|  |  | ||||||
|  |  | ||||||
| class GroupSourceConnectionSerializer(SourceSerializer): | class GroupSourceConnectionSerializer(SourceSerializer): | ||||||
|     """Group Source Connection""" |     """Group Source Connection Serializer""" | ||||||
|  |  | ||||||
|     source_obj = SourceSerializer(read_only=True) |     source = SourceSerializer(read_only=True) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = GroupSourceConnection |         model = GroupSourceConnection | ||||||
| @ -207,11 +207,12 @@ class GroupSourceConnectionSerializer(SourceSerializer): | |||||||
|             "pk", |             "pk", | ||||||
|             "group", |             "group", | ||||||
|             "source", |             "source", | ||||||
|             "source_obj", |  | ||||||
|             "identifier", |             "identifier", | ||||||
|             "created", |             "created", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|  |             "group": {"read_only": True}, | ||||||
|  |             "identifier": {"read_only": True}, | ||||||
|             "created": {"read_only": True}, |             "created": {"read_only": True}, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,12 +1,10 @@ | |||||||
| """transactional application and provider creation""" | """transactional application and provider creation""" | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.db.models import Model |  | ||||||
| from django.utils.translation import gettext as _ |  | ||||||
| from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field | from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field | ||||||
| from rest_framework.exceptions import PermissionDenied, ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField | from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| @ -22,9 +20,8 @@ from authentik.blueprints.v1.common import ( | |||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.core.api.applications import ApplicationSerializer | from authentik.core.api.applications import ApplicationSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Application, Provider | from authentik.core.models import Provider | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
| from authentik.policies.api.bindings import PolicyBindingSerializer |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_provider_serializer_mapping(): | def get_provider_serializer_mapping(): | ||||||
| @ -48,20 +45,6 @@ class TransactionProviderField(DictField): | |||||||
|     """Dictionary field which can hold provider creation data""" |     """Dictionary field which can hold provider creation data""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class TransactionPolicyBindingSerializer(PolicyBindingSerializer): |  | ||||||
|     """PolicyBindingSerializer which does not require target as target is set implicitly""" |  | ||||||
|  |  | ||||||
|     def validate(self, attrs): |  | ||||||
|         # As the PolicyBindingSerializer checks that the correct things can be bound to a target |  | ||||||
|         # but we don't have a target here as that's set by the blueprint, pass in an empty app |  | ||||||
|         # which will have the correct allowed combination of group/user/policy. |  | ||||||
|         attrs["target"] = Application() |  | ||||||
|         return super().validate(attrs) |  | ||||||
|  |  | ||||||
|     class Meta(PolicyBindingSerializer.Meta): |  | ||||||
|         fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TransactionApplicationSerializer(PassiveSerializer): | class TransactionApplicationSerializer(PassiveSerializer): | ||||||
|     """Serializer for creating a provider and an application in one transaction""" |     """Serializer for creating a provider and an application in one transaction""" | ||||||
|  |  | ||||||
| @ -69,8 +52,6 @@ class TransactionApplicationSerializer(PassiveSerializer): | |||||||
|     provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys())) |     provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys())) | ||||||
|     provider = TransactionProviderField() |     provider = TransactionProviderField() | ||||||
|  |  | ||||||
|     policy_bindings = TransactionPolicyBindingSerializer(many=True, required=False) |  | ||||||
|  |  | ||||||
|     _provider_model: type[Provider] = None |     _provider_model: type[Provider] = None | ||||||
|  |  | ||||||
|     def validate_provider_model(self, fq_model_name: str) -> str: |     def validate_provider_model(self, fq_model_name: str) -> str: | ||||||
| @ -115,19 +96,6 @@ class TransactionApplicationSerializer(PassiveSerializer): | |||||||
|                 id="app", |                 id="app", | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         for binding in attrs.get("policy_bindings", []): |  | ||||||
|             binding["target"] = KeyOf(None, ScalarNode(tag="", value="app")) |  | ||||||
|             for key, value in binding.items(): |  | ||||||
|                 if not isinstance(value, Model): |  | ||||||
|                     continue |  | ||||||
|                 binding[key] = value.pk |  | ||||||
|             blueprint.entries.append( |  | ||||||
|                 BlueprintEntry( |  | ||||||
|                     model="authentik_policies.policybinding", |  | ||||||
|                     state=BlueprintEntryDesiredState.MUST_CREATED, |  | ||||||
|                     identifiers=binding, |  | ||||||
|                 ) |  | ||||||
|             ) |  | ||||||
|         importer = Importer(blueprint, {}) |         importer = Importer(blueprint, {}) | ||||||
|         try: |         try: | ||||||
|             valid, _ = importer.validate(raise_validation_errors=True) |             valid, _ = importer.validate(raise_validation_errors=True) | ||||||
| @ -152,7 +120,8 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): | |||||||
| class TransactionalApplicationView(APIView): | class TransactionalApplicationView(APIView): | ||||||
|     """Create provider and application and attach them in a single transaction""" |     """Create provider and application and attach them in a single transaction""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     # TODO: Migrate to a more specific permission | ||||||
|  |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=TransactionApplicationSerializer(), |         request=TransactionApplicationSerializer(), | ||||||
| @ -164,23 +133,8 @@ class TransactionalApplicationView(APIView): | |||||||
|         """Convert data into a blueprint, validate it and apply it""" |         """Convert data into a blueprint, validate it and apply it""" | ||||||
|         data = TransactionApplicationSerializer(data=request.data) |         data = TransactionApplicationSerializer(data=request.data) | ||||||
|         data.is_valid(raise_exception=True) |         data.is_valid(raise_exception=True) | ||||||
|         blueprint: Blueprint = data.validated_data |  | ||||||
|         for entry in blueprint.entries: |         importer = Importer(data.validated_data, {}) | ||||||
|             full_model = entry.get_model(blueprint) |  | ||||||
|             app, __, model = full_model.partition(".") |  | ||||||
|             if not request.user.has_perm(f"{app}.add_{model}"): |  | ||||||
|                 raise PermissionDenied( |  | ||||||
|                     { |  | ||||||
|                         entry.id: _( |  | ||||||
|                             "User lacks permission to create {model}".format_map( |  | ||||||
|                                 { |  | ||||||
|                                     "model": full_model, |  | ||||||
|                                 } |  | ||||||
|                             ) |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|                 ) |  | ||||||
|         importer = Importer(blueprint, {}) |  | ||||||
|         applied = importer.apply() |         applied = importer.apply() | ||||||
|         response = {"applied": False, "logs": []} |         response = {"applied": False, "logs": []} | ||||||
|         response["applied"] = applied |         response["applied"] = applied | ||||||
|  | |||||||
| @ -666,12 +666,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     @permission_required("authentik_core.impersonate") |     @permission_required("authentik_core.impersonate") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=inline_serializer( |         request=OpenApiTypes.NONE, | ||||||
|             "ImpersonationSerializer", |  | ||||||
|             { |  | ||||||
|                 "reason": CharField(required=True), |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         responses={ |         responses={ | ||||||
|             "204": OpenApiResponse(description="Successfully started impersonation"), |             "204": OpenApiResponse(description="Successfully started impersonation"), | ||||||
|             "401": OpenApiResponse(description="Access denied"), |             "401": OpenApiResponse(description="Access denied"), | ||||||
| @ -684,7 +679,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             LOGGER.debug("User attempted to impersonate", user=request.user) |             LOGGER.debug("User attempted to impersonate", user=request.user) | ||||||
|             return Response(status=401) |             return Response(status=401) | ||||||
|         user_to_be = self.get_object() |         user_to_be = self.get_object() | ||||||
|         reason = request.data.get("reason", "") |  | ||||||
|         # Check both object-level perms and global perms |         # Check both object-level perms and global perms | ||||||
|         if not request.user.has_perm( |         if not request.user.has_perm( | ||||||
|             "authentik_core.impersonate", user_to_be |             "authentik_core.impersonate", user_to_be | ||||||
| @ -694,16 +688,11 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         if user_to_be.pk == self.request.user.pk: |         if user_to_be.pk == self.request.user.pk: | ||||||
|             LOGGER.debug("User attempted to impersonate themselves", user=request.user) |             LOGGER.debug("User attempted to impersonate themselves", user=request.user) | ||||||
|             return Response(status=401) |             return Response(status=401) | ||||||
|         if not reason and request.tenant.impersonation_require_reason: |  | ||||||
|             LOGGER.debug( |  | ||||||
|                 "User attempted to impersonate without providing a reason", user=request.user |  | ||||||
|             ) |  | ||||||
|             return Response(status=401) |  | ||||||
|  |  | ||||||
|         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user |         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user | ||||||
|         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be |         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be | ||||||
|  |  | ||||||
|         Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be) |         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) | ||||||
|  |  | ||||||
|         return Response(status=201) |         return Response(status=201) | ||||||
|  |  | ||||||
|  | |||||||
| @ -42,10 +42,8 @@ class ImpersonateMiddleware: | |||||||
|             # Ensure that the user is active, otherwise nothing will work |             # Ensure that the user is active, otherwise nothing will work | ||||||
|             request.user.is_active = True |             request.user.is_active = True | ||||||
|  |  | ||||||
|         if locale_to_set: |         with override(locale_to_set): | ||||||
|             with override(locale_to_set): |             return self.get_response(request) | ||||||
|                 return self.get_response(request) |  | ||||||
|         return self.get_response(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class RequestIDMiddleware: | class RequestIDMiddleware: | ||||||
|  | |||||||
| @ -1,45 +0,0 @@ | |||||||
| # Generated by Django 5.0.9 on 2024-11-20 15:16 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0040_provider_invalidation_flow"), |  | ||||||
|         ("authentik_policies", "0011_policybinding_failure_result_and_more"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="ApplicationEntitlement", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "policybindingmodel_ptr", |  | ||||||
|                     models.OneToOneField( |  | ||||||
|                         auto_created=True, |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         parent_link=True, |  | ||||||
|                         primary_key=True, |  | ||||||
|                         serialize=False, |  | ||||||
|                         to="authentik_policies.policybindingmodel", |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("attributes", models.JSONField(blank=True, default=dict)), |  | ||||||
|                 ("name", models.TextField()), |  | ||||||
|                 ( |  | ||||||
|                     "app", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.application" |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "verbose_name": "Application Entitlement", |  | ||||||
|                 "verbose_name_plural": "Application Entitlements", |  | ||||||
|                 "unique_together": {("app", "name")}, |  | ||||||
|             }, |  | ||||||
|             bases=("authentik_policies.policybindingmodel", models.Model), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -314,32 +314,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): | |||||||
|         always_merger.merge(final_attributes, self.attributes) |         always_merger.merge(final_attributes, self.attributes) | ||||||
|         return final_attributes |         return final_attributes | ||||||
|  |  | ||||||
|     def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]: |  | ||||||
|         """Get all entitlements this user has for `app`.""" |  | ||||||
|         if not app: |  | ||||||
|             return [] |  | ||||||
|         all_groups = self.all_groups() |  | ||||||
|         qs = app.applicationentitlement_set.filter( |  | ||||||
|             Q( |  | ||||||
|                 Q(bindings__user=self) | Q(bindings__group__in=all_groups), |  | ||||||
|                 bindings__negate=False, |  | ||||||
|             ) |  | ||||||
|             | Q( |  | ||||||
|                 Q(~Q(bindings__user=self), bindings__user__isnull=False) |  | ||||||
|                 | Q(~Q(bindings__group__in=all_groups), bindings__group__isnull=False), |  | ||||||
|                 bindings__negate=True, |  | ||||||
|             ), |  | ||||||
|             bindings__enabled=True, |  | ||||||
|         ).order_by("name") |  | ||||||
|         return qs |  | ||||||
|  |  | ||||||
|     def app_entitlements_attributes(self, app: "Application | None") -> dict: |  | ||||||
|         """Get a dictionary containing all merged attributes from app entitlements for `app`.""" |  | ||||||
|         final_attributes = {} |  | ||||||
|         for attrs in self.app_entitlements(app).values_list("attributes", flat=True): |  | ||||||
|             always_merger.merge(final_attributes, attrs) |  | ||||||
|         return final_attributes |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Serializer: |     def serializer(self) -> Serializer: | ||||||
|         from authentik.core.api.users import UserSerializer |         from authentik.core.api.users import UserSerializer | ||||||
| @ -607,31 +581,6 @@ class Application(SerializerModel, PolicyBindingModel): | |||||||
|         verbose_name_plural = _("Applications") |         verbose_name_plural = _("Applications") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationEntitlement(AttributesMixin, SerializerModel, PolicyBindingModel): |  | ||||||
|     """Application-scoped entitlement to control authorization in an application""" |  | ||||||
|  |  | ||||||
|     name = models.TextField() |  | ||||||
|  |  | ||||||
|     app = models.ForeignKey(Application, on_delete=models.CASCADE) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         verbose_name = _("Application Entitlement") |  | ||||||
|         verbose_name_plural = _("Application Entitlements") |  | ||||||
|         unique_together = (("app", "name"),) |  | ||||||
|  |  | ||||||
|     def __str__(self): |  | ||||||
|         return f"Application Entitlement {self.name} for app {self.app_id}" |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def serializer(self) -> type[Serializer]: |  | ||||||
|         from authentik.core.api.application_entitlements import ApplicationEntitlementSerializer |  | ||||||
|  |  | ||||||
|         return ApplicationEntitlementSerializer |  | ||||||
|  |  | ||||||
|     def supported_policy_binding_targets(self): |  | ||||||
|         return ["group", "user"] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SourceUserMatchingModes(models.TextChoices): | class SourceUserMatchingModes(models.TextChoices): | ||||||
|     """Different modes a source can handle new/returning users""" |     """Different modes a source can handle new/returning users""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -238,7 +238,13 @@ class SourceFlowManager: | |||||||
|                 self.request.GET, |                 self.request.GET, | ||||||
|                 flow_slug=flow_slug, |                 flow_slug=flow_slug, | ||||||
|             ) |             ) | ||||||
|         flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) |         # Ensure redirect is carried through when user was trying to | ||||||
|  |         # authorize application | ||||||
|  |         final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||||
|  |             NEXT_ARG_NAME, "authentik_core:if-user" | ||||||
|  |         ) | ||||||
|  |         if PLAN_CONTEXT_REDIRECT not in flow_context: | ||||||
|  |             flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect | ||||||
|  |  | ||||||
|         if not flow: |         if not flow: | ||||||
|             return bad_request_message( |             return bad_request_message( | ||||||
| @ -259,7 +265,12 @@ class SourceFlowManager: | |||||||
|         if stages: |         if stages: | ||||||
|             for stage in stages: |             for stage in stages: | ||||||
|                 plan.append_stage(stage) |                 plan.append_stage(stage) | ||||||
|         return plan.to_redirect(self.request, flow) |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             self.request.GET, | ||||||
|  |             flow_slug=flow.slug, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def handle_auth( |     def handle_auth( | ||||||
|         self, |         self, | ||||||
|  | |||||||
| @ -9,9 +9,6 @@ | |||||||
|         versionFamily: "{{ version_family }}", |         versionFamily: "{{ version_family }}", | ||||||
|         versionSubdomain: "{{ version_subdomain }}", |         versionSubdomain: "{{ version_subdomain }}", | ||||||
|         build: "{{ build }}", |         build: "{{ build }}", | ||||||
|         api: { |  | ||||||
|             base: "{{ base_url }}", |  | ||||||
|         }, |  | ||||||
|     }; |     }; | ||||||
|     window.addEventListener("DOMContentLoaded", function () { |     window.addEventListener("DOMContentLoaded", function () { | ||||||
|         {% for message in messages %} |         {% for message in messages %} | ||||||
|  | |||||||
| @ -9,8 +9,8 @@ | |||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||||
|         {% block head_before %} |         {% block head_before %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
| <link rel="prefetch" href="{% static 'dist/assets/images/flow_background.jpg' %}" /> | <link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" /> | ||||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> | <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> | ||||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)"> | <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
| @ -13,7 +13,7 @@ | |||||||
| {% block head %} | {% block head %} | ||||||
| <style> | <style> | ||||||
| :root { | :root { | ||||||
|     --ak-flow-background: url("{% static 'dist/assets/images/flow_background.jpg' %}"); |     --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg"); | ||||||
|     --pf-c-background-image--BackgroundImage: var(--ak-flow-background); |     --pf-c-background-image--BackgroundImage: var(--ak-flow-background); | ||||||
|     --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); |     --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); | ||||||
|     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); |     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); | ||||||
| @ -50,7 +50,7 @@ | |||||||
|     <div class="ak-login-container"> |     <div class="ak-login-container"> | ||||||
|         <main class="pf-c-login__main"> |         <main class="pf-c-login__main"> | ||||||
|             <div class="pf-c-login__main-header pf-c-brand ak-brand"> |             <div class="pf-c-login__main-header pf-c-brand ak-brand"> | ||||||
|                 <img src="{{ brand.branding_logo_url }}" alt="authentik Logo" /> |                 <img src="{{ brand.branding_logo }}" alt="authentik Logo" /> | ||||||
|             </div> |             </div> | ||||||
|             <header class="pf-c-login__main-header"> |             <header class="pf-c-login__main-header"> | ||||||
|                 <h1 class="pf-c-title pf-m-3xl"> |                 <h1 class="pf-c-title pf-m-3xl"> | ||||||
|  | |||||||
| @ -1,153 +0,0 @@ | |||||||
| """Test Application Entitlements API""" |  | ||||||
|  |  | ||||||
| from django.urls import reverse |  | ||||||
| from guardian.shortcuts import assign_perm |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application, ApplicationEntitlement, Group |  | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user |  | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
| from authentik.policies.dummy.models import DummyPolicy |  | ||||||
| from authentik.policies.models import PolicyBinding |  | ||||||
| from authentik.providers.oauth2.models import OAuth2Provider |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestApplicationEntitlements(APITestCase): |  | ||||||
|     """Test application entitlements""" |  | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         self.user = create_test_user() |  | ||||||
|         self.other_user = create_test_user() |  | ||||||
|         self.provider = OAuth2Provider.objects.create( |  | ||||||
|             name="test", |  | ||||||
|             authorization_flow=create_test_flow(), |  | ||||||
|         ) |  | ||||||
|         self.app: Application = Application.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             slug=generate_id(), |  | ||||||
|             provider=self.provider, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_user(self): |  | ||||||
|         """Test user-direct assignment""" |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         PolicyBinding.objects.create(target=ent, user=self.user, order=0) |  | ||||||
|         ents = self.user.app_entitlements(self.app) |  | ||||||
|         self.assertEqual(len(ents), 1) |  | ||||||
|         self.assertEqual(ents[0].name, ent.name) |  | ||||||
|  |  | ||||||
|     def test_group(self): |  | ||||||
|         """Test direct group""" |  | ||||||
|         group = Group.objects.create(name=generate_id()) |  | ||||||
|         self.user.ak_groups.add(group) |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         PolicyBinding.objects.create(target=ent, group=group, order=0) |  | ||||||
|         ents = self.user.app_entitlements(self.app) |  | ||||||
|         self.assertEqual(len(ents), 1) |  | ||||||
|         self.assertEqual(ents[0].name, ent.name) |  | ||||||
|  |  | ||||||
|     def test_group_indirect(self): |  | ||||||
|         """Test indirect group""" |  | ||||||
|         parent = Group.objects.create(name=generate_id()) |  | ||||||
|         group = Group.objects.create(name=generate_id(), parent=parent) |  | ||||||
|         self.user.ak_groups.add(group) |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         PolicyBinding.objects.create(target=ent, group=parent, order=0) |  | ||||||
|         ents = self.user.app_entitlements(self.app) |  | ||||||
|         self.assertEqual(len(ents), 1) |  | ||||||
|         self.assertEqual(ents[0].name, ent.name) |  | ||||||
|  |  | ||||||
|     def test_negate_user(self): |  | ||||||
|         """Test with negate flag""" |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         PolicyBinding.objects.create(target=ent, user=self.other_user, order=0, negate=True) |  | ||||||
|         ents = self.user.app_entitlements(self.app) |  | ||||||
|         self.assertEqual(len(ents), 1) |  | ||||||
|         self.assertEqual(ents[0].name, ent.name) |  | ||||||
|  |  | ||||||
|     def test_negate_group(self): |  | ||||||
|         """Test with negate flag""" |  | ||||||
|         other_group = Group.objects.create(name=generate_id()) |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         PolicyBinding.objects.create(target=ent, group=other_group, order=0, negate=True) |  | ||||||
|         ents = self.user.app_entitlements(self.app) |  | ||||||
|         self.assertEqual(len(ents), 1) |  | ||||||
|         self.assertEqual(ents[0].name, ent.name) |  | ||||||
|  |  | ||||||
|     def test_api_perms_global(self): |  | ||||||
|         """Test API creation with global permissions""" |  | ||||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) |  | ||||||
|         assign_perm("authentik_core.view_application", self.user) |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         res = self.client.post( |  | ||||||
|             reverse("authentik_api:applicationentitlement-list"), |  | ||||||
|             data={ |  | ||||||
|                 "name": generate_id(), |  | ||||||
|                 "app": self.app.pk, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 201) |  | ||||||
|  |  | ||||||
|     def test_api_perms_scoped(self): |  | ||||||
|         """Test API creation with scoped permissions""" |  | ||||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) |  | ||||||
|         assign_perm("authentik_core.view_application", self.user, self.app) |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         res = self.client.post( |  | ||||||
|             reverse("authentik_api:applicationentitlement-list"), |  | ||||||
|             data={ |  | ||||||
|                 "name": generate_id(), |  | ||||||
|                 "app": self.app.pk, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 201) |  | ||||||
|  |  | ||||||
|     def test_api_perms_missing(self): |  | ||||||
|         """Test API creation with no permissions""" |  | ||||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         res = self.client.post( |  | ||||||
|             reverse("authentik_api:applicationentitlement-list"), |  | ||||||
|             data={ |  | ||||||
|                 "name": generate_id(), |  | ||||||
|                 "app": self.app.pk, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(res.status_code, 400) |  | ||||||
|         self.assertJSONEqual(res.content, {"app": ["User does not have access to application."]}) |  | ||||||
|  |  | ||||||
|     def test_api_bindings_policy(self): |  | ||||||
|         """Test that API doesn't allow policies to be bound to this""" |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         policy = DummyPolicy.objects.create(name=generate_id()) |  | ||||||
|         admin = create_test_admin_user() |  | ||||||
|         self.client.force_login(admin) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_api:policybinding-list"), |  | ||||||
|             data={ |  | ||||||
|                 "target": ent.pbm_uuid, |  | ||||||
|                 "policy": policy.pk, |  | ||||||
|                 "order": 0, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             {"non_field_errors": ["One of 'group', 'user' must be set."]}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_api_bindings_group(self): |  | ||||||
|         """Test that API doesn't allow policies to be bound to this""" |  | ||||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) |  | ||||||
|         group = Group.objects.create(name=generate_id()) |  | ||||||
|         admin = create_test_admin_user() |  | ||||||
|         self.client.force_login(admin) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_api:policybinding-list"), |  | ||||||
|             data={ |  | ||||||
|                 "target": ent.pbm_uuid, |  | ||||||
|                 "group": group.pk, |  | ||||||
|                 "order": 0, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 201) |  | ||||||
|         self.assertTrue(PolicyBinding.objects.filter(target=ent.pbm_uuid).exists()) |  | ||||||
| @ -29,8 +29,7 @@ class TestImpersonation(APITestCase): | |||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:user-impersonate", |                 "authentik_api:user-impersonate", | ||||||
|                 kwargs={"pk": self.other_user.pk}, |                 kwargs={"pk": self.other_user.pk}, | ||||||
|             ), |             ) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         response = self.client.get(reverse("authentik_api:user-me")) |         response = self.client.get(reverse("authentik_api:user-me")) | ||||||
| @ -56,8 +55,7 @@ class TestImpersonation(APITestCase): | |||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:user-impersonate", |                 "authentik_api:user-impersonate", | ||||||
|                 kwargs={"pk": self.other_user.pk}, |                 kwargs={"pk": self.other_user.pk}, | ||||||
|             ), |             ) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 201) |         self.assertEqual(response.status_code, 201) | ||||||
|  |  | ||||||
| @ -77,8 +75,7 @@ class TestImpersonation(APITestCase): | |||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:user-impersonate", |                 "authentik_api:user-impersonate", | ||||||
|                 kwargs={"pk": self.other_user.pk}, |                 kwargs={"pk": self.other_user.pk}, | ||||||
|             ), |             ) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 201) |         self.assertEqual(response.status_code, 201) | ||||||
|  |  | ||||||
| @ -92,8 +89,7 @@ class TestImpersonation(APITestCase): | |||||||
|         self.client.force_login(self.other_user) |         self.client.force_login(self.other_user) | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), |             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 403) |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
| @ -109,8 +105,7 @@ class TestImpersonation(APITestCase): | |||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}), |             reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 401) |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
| @ -123,22 +118,7 @@ class TestImpersonation(APITestCase): | |||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), |             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) | ||||||
|             data={"reason": "some reason"}, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 401) |  | ||||||
|  |  | ||||||
|         response = self.client.get(reverse("authentik_api:user-me")) |  | ||||||
|         response_body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(response_body["user"]["username"], self.user.username) |  | ||||||
|  |  | ||||||
|     def test_impersonate_reason_required(self): |  | ||||||
|         """test impersonation that user must provide reason""" |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|  |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), |  | ||||||
|             data={"reason": ""}, |  | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 401) |         self.assertEqual(response.status_code, 401) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,13 +1,11 @@ | |||||||
| """Test Transactional API""" | """Test Transactional API""" | ||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from guardian.shortcuts import assign_perm |  | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Application, Group | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.models import PolicyBinding |  | ||||||
| from authentik.providers.oauth2.models import OAuth2Provider | from authentik.providers.oauth2.models import OAuth2Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -15,9 +13,7 @@ class TestTransactionalApplicationsAPI(APITestCase): | |||||||
|     """Test Transactional API""" |     """Test Transactional API""" | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         self.user = create_test_user() |         self.user = create_test_admin_user() | ||||||
|         assign_perm("authentik_core.add_application", self.user) |  | ||||||
|         assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user) |  | ||||||
|  |  | ||||||
|     def test_create_transactional(self): |     def test_create_transactional(self): | ||||||
|         """Test transactional Application + provider creation""" |         """Test transactional Application + provider creation""" | ||||||
| @ -46,66 +42,6 @@ class TestTransactionalApplicationsAPI(APITestCase): | |||||||
|         self.assertIsNotNone(app) |         self.assertIsNotNone(app) | ||||||
|         self.assertEqual(app.provider.pk, provider.pk) |         self.assertEqual(app.provider.pk, provider.pk) | ||||||
|  |  | ||||||
|     def test_create_transactional_permission_denied(self): |  | ||||||
|         """Test transactional Application + provider creation (missing permissions)""" |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         uid = generate_id() |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:core-transactional-application"), |  | ||||||
|             data={ |  | ||||||
|                 "app": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "slug": uid, |  | ||||||
|                 }, |  | ||||||
|                 "provider_model": "authentik_providers_saml.samlprovider", |  | ||||||
|                 "provider": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "authorization_flow": str(create_test_flow().pk), |  | ||||||
|                     "invalidation_flow": str(create_test_flow().pk), |  | ||||||
|                     "acs_url": "https://goauthentik.io", |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             {"provider": "User lacks permission to create authentik_providers_saml.samlprovider"}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_create_transactional_bindings(self): |  | ||||||
|         """Test transactional Application + provider creation""" |  | ||||||
|         assign_perm("authentik_policies.add_policybinding", self.user) |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         uid = generate_id() |  | ||||||
|         group = Group.objects.create(name=generate_id()) |  | ||||||
|         authorization_flow = create_test_flow() |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:core-transactional-application"), |  | ||||||
|             data={ |  | ||||||
|                 "app": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "slug": uid, |  | ||||||
|                 }, |  | ||||||
|                 "provider_model": "authentik_providers_oauth2.oauth2provider", |  | ||||||
|                 "provider": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "authorization_flow": str(authorization_flow.pk), |  | ||||||
|                     "invalidation_flow": str(authorization_flow.pk), |  | ||||||
|                     "redirect_uris": [], |  | ||||||
|                 }, |  | ||||||
|                 "policy_bindings": [{"group": group.pk, "order": 0}], |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []}) |  | ||||||
|         provider = OAuth2Provider.objects.filter(name=uid).first() |  | ||||||
|         self.assertIsNotNone(provider) |  | ||||||
|         app = Application.objects.filter(slug=uid).first() |  | ||||||
|         self.assertIsNotNone(app) |  | ||||||
|         self.assertEqual(app.provider.pk, provider.pk) |  | ||||||
|         binding = PolicyBinding.objects.filter(target=app).first() |  | ||||||
|         self.assertIsNotNone(binding) |  | ||||||
|         self.assertEqual(binding.target, app) |  | ||||||
|         self.assertEqual(binding.group, group) |  | ||||||
|  |  | ||||||
|     def test_create_transactional_invalid(self): |     def test_create_transactional_invalid(self): | ||||||
|         """Test transactional Application + provider creation""" |         """Test transactional Application + provider creation""" | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
| @ -135,32 +71,3 @@ class TestTransactionalApplicationsAPI(APITestCase): | |||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_create_transactional_duplicate_name_provider(self): |  | ||||||
|         """Test transactional Application + provider creation""" |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         uid = generate_id() |  | ||||||
|         OAuth2Provider.objects.create( |  | ||||||
|             name=uid, |  | ||||||
|             authorization_flow=create_test_flow(), |  | ||||||
|             invalidation_flow=create_test_flow(), |  | ||||||
|         ) |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:core-transactional-application"), |  | ||||||
|             data={ |  | ||||||
|                 "app": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "slug": uid, |  | ||||||
|                 }, |  | ||||||
|                 "provider_model": "authentik_providers_oauth2.oauth2provider", |  | ||||||
|                 "provider": { |  | ||||||
|                     "name": uid, |  | ||||||
|                     "authorization_flow": str(create_test_flow().pk), |  | ||||||
|                     "invalidation_flow": str(create_test_flow().pk), |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             {"provider": {"name": ["State is set to must_created and object exists already"]}}, |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -6,7 +6,6 @@ from django.conf import settings | |||||||
| from django.contrib.auth.decorators import login_required | from django.contrib.auth.decorators import login_required | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet |  | ||||||
| from authentik.core.api.applications import ApplicationViewSet | from authentik.core.api.applications import ApplicationViewSet | ||||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||||
| from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | ||||||
| @ -70,7 +69,6 @@ urlpatterns = [ | |||||||
| api_urlpatterns = [ | api_urlpatterns = [ | ||||||
|     ("core/authenticated_sessions", AuthenticatedSessionViewSet), |     ("core/authenticated_sessions", AuthenticatedSessionViewSet), | ||||||
|     ("core/applications", ApplicationViewSet), |     ("core/applications", ApplicationViewSet), | ||||||
|     ("core/application_entitlements", ApplicationEntitlementViewSet), |  | ||||||
|     path( |     path( | ||||||
|         "core/transactional/applications/", |         "core/transactional/applications/", | ||||||
|         TransactionalApplicationView.as_view(), |         TransactionalApplicationView.as_view(), | ||||||
|  | |||||||
| @ -17,8 +17,10 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | |||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.flows.views.executor import ( | from authentik.flows.views.executor import ( | ||||||
|     SESSION_KEY_APPLICATION_PRE, |     SESSION_KEY_APPLICATION_PRE, | ||||||
|  |     SESSION_KEY_PLAN, | ||||||
|     ToDefaultFlow, |     ToDefaultFlow, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.stages.consent.stage import ( | from authentik.stages.consent.stage import ( | ||||||
|     PLAN_CONTEXT_CONSENT_HEADER, |     PLAN_CONTEXT_CONSENT_HEADER, | ||||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, |     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||||
| @ -56,7 +58,8 @@ class RedirectToAppLaunch(View): | |||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             raise Http404 from None |             raise Http404 from None | ||||||
|         plan.insert_stage(in_memory_stage(RedirectToAppStage)) |         plan.insert_stage(in_memory_stage(RedirectToAppStage)) | ||||||
|         return plan.to_redirect(request, flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) | ||||||
|  |  | ||||||
|  |  | ||||||
| class RedirectToAppStage(ChallengeStageView): | class RedirectToAppStage(ChallengeStageView): | ||||||
|  | |||||||
| @ -16,7 +16,6 @@ from authentik.api.v3.config import ConfigView | |||||||
| from authentik.brands.api import CurrentBrandSerializer | from authentik.brands.api import CurrentBrandSerializer | ||||||
| from authentik.brands.models import Brand | from authentik.brands.models import Brand | ||||||
| from authentik.core.models import UserTypes | from authentik.core.models import UserTypes | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.policies.denied import AccessDeniedResponse | from authentik.policies.denied import AccessDeniedResponse | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -52,7 +51,6 @@ class InterfaceView(TemplateView): | |||||||
|         kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" |         kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" | ||||||
|         kwargs["build"] = get_build_hash() |         kwargs["build"] = get_build_hash() | ||||||
|         kwargs["url_kwargs"] = self.kwargs |         kwargs["url_kwargs"] = self.kwargs | ||||||
|         kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) |  | ||||||
|         return super().get_context_data(**kwargs) |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -85,5 +85,5 @@ def certificate_discovery(self: SystemTask): | |||||||
|         if dirty: |         if dirty: | ||||||
|             cert.save() |             cert.save() | ||||||
|     self.set_status( |     self.set_status( | ||||||
|         TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=discovered)) |         TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": discovered}) | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -6,8 +6,8 @@ | |||||||
| <script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script> | <script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script> | ||||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | <link rel="icon" href="{{ tenant.branding_favicon }}"> | ||||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}"> | <link rel="shortcut icon" href="{{ tenant.branding_favicon }}"> | ||||||
| {% include "base/header_js.html" %} | {% include "base/header_js.html" %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  | |||||||
| @ -18,7 +18,9 @@ from authentik.flows.exceptions import FlowNonApplicableException | |||||||
| from authentik.flows.models import in_memory_stage | from authentik.flows.models import in_memory_stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||||
| from authentik.flows.stage import RedirectStage | from authentik.flows.stage import RedirectStage | ||||||
|  | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -54,7 +56,12 @@ class RACStartView(EnterprisePolicyAccessView): | |||||||
|                 provider=self.provider, |                 provider=self.provider, | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         return plan.to_redirect(request, self.provider.authorization_flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             request.GET, | ||||||
|  |             flow_slug=self.provider.authorization_flow.slug, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class RACInterface(InterfaceView): | class RACInterface(InterfaceView): | ||||||
|  | |||||||
| @ -60,7 +60,7 @@ def default_event_duration(): | |||||||
|     """Default duration an Event is saved. |     """Default duration an Event is saved. | ||||||
|     This is used as a fallback when no brand is available""" |     This is used as a fallback when no brand is available""" | ||||||
|     try: |     try: | ||||||
|         tenant = get_current_tenant(only=["event_retention"]) |         tenant = get_current_tenant() | ||||||
|         return now() + timedelta_from_string(tenant.event_retention) |         return now() + timedelta_from_string(tenant.event_retention) | ||||||
|     except Tenant.DoesNotExist: |     except Tenant.DoesNotExist: | ||||||
|         return now() + timedelta(days=365) |         return now() + timedelta(days=365) | ||||||
|  | |||||||
| @ -40,7 +40,6 @@ class Migration(migrations.Migration): | |||||||
|                     ("require_authenticated", "Require Authenticated"), |                     ("require_authenticated", "Require Authenticated"), | ||||||
|                     ("require_unauthenticated", "Require Unauthenticated"), |                     ("require_unauthenticated", "Require Unauthenticated"), | ||||||
|                     ("require_superuser", "Require Superuser"), |                     ("require_superuser", "Require Superuser"), | ||||||
|                     ("require_redirect", "Require Redirect"), |  | ||||||
|                     ("require_outpost", "Require Outpost"), |                     ("require_outpost", "Require Outpost"), | ||||||
|                 ], |                 ], | ||||||
|                 default="none", |                 default="none", | ||||||
|  | |||||||
| @ -14,7 +14,6 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.core.models import Token | from authentik.core.models import Token | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.flows.challenge import FlowLayout | from authentik.flows.challenge import FlowLayout | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
| @ -33,7 +32,6 @@ class FlowAuthenticationRequirement(models.TextChoices): | |||||||
|     REQUIRE_AUTHENTICATED = "require_authenticated" |     REQUIRE_AUTHENTICATED = "require_authenticated" | ||||||
|     REQUIRE_UNAUTHENTICATED = "require_unauthenticated" |     REQUIRE_UNAUTHENTICATED = "require_unauthenticated" | ||||||
|     REQUIRE_SUPERUSER = "require_superuser" |     REQUIRE_SUPERUSER = "require_superuser" | ||||||
|     REQUIRE_REDIRECT = "require_redirect" |  | ||||||
|     REQUIRE_OUTPOST = "require_outpost" |     REQUIRE_OUTPOST = "require_outpost" | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -179,13 +177,9 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|         """Get the URL to the background image. If the name is /static or starts with http |         """Get the URL to the background image. If the name is /static or starts with http | ||||||
|         it is returned as-is""" |         it is returned as-is""" | ||||||
|         if not self.background: |         if not self.background: | ||||||
|             return ( |             return "/static/dist/assets/images/flow_background.jpg" | ||||||
|                 CONFIG.get("web.path", "/")[:-1] + "/static/dist/assets/images/flow_background.jpg" |         if self.background.name.startswith("http") or self.background.name.startswith("/static"): | ||||||
|             ) |  | ||||||
|         if self.background.name.startswith("http"): |  | ||||||
|             return self.background.name |             return self.background.name | ||||||
|         if self.background.name.startswith("/static"): |  | ||||||
|             return CONFIG.get("web.path", "/")[:-1] + self.background.name |  | ||||||
|         return self.background.url |         return self.background.url | ||||||
|  |  | ||||||
|     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) |     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) | ||||||
|  | |||||||
| @ -1,10 +1,10 @@ | |||||||
| """Flows Planner""" | """Flows Planner""" | ||||||
|  |  | ||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
| from typing import TYPE_CHECKING, Any | from typing import Any | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest | ||||||
| from sentry_sdk import start_span | from sentry_sdk import start_span | ||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| @ -23,15 +23,10 @@ from authentik.flows.models import ( | |||||||
|     in_memory_stage, |     in_memory_stage, | ||||||
| ) | ) | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.urls import redirect_with_qs |  | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.root.middleware import ClientIPMiddleware | from authentik.root.middleware import ClientIPMiddleware | ||||||
|  |  | ||||||
| if TYPE_CHECKING: |  | ||||||
|     from authentik.flows.stage import StageView |  | ||||||
|  |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||||
| PLAN_CONTEXT_SSO = "is_sso" | PLAN_CONTEXT_SSO = "is_sso" | ||||||
| @ -42,8 +37,6 @@ PLAN_CONTEXT_OUTPOST = "outpost" | |||||||
| # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan | # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan | ||||||
| # was restored. | # was restored. | ||||||
| PLAN_CONTEXT_IS_RESTORED = "is_restored" | PLAN_CONTEXT_IS_RESTORED = "is_restored" | ||||||
| PLAN_CONTEXT_IS_REDIRECTED = "is_redirected" |  | ||||||
| PLAN_CONTEXT_REDIRECT_STAGE_TARGET = "redirect_stage_target" |  | ||||||
| CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows") | CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows") | ||||||
| CACHE_PREFIX = "goauthentik.io/flows/planner/" | CACHE_PREFIX = "goauthentik.io/flows/planner/" | ||||||
|  |  | ||||||
| @ -117,54 +110,6 @@ class FlowPlan: | |||||||
|         """Check if there are any stages left in this plan""" |         """Check if there are any stages left in this plan""" | ||||||
|         return len(self.markers) + len(self.bindings) > 0 |         return len(self.markers) + len(self.bindings) > 0 | ||||||
|  |  | ||||||
|     def requires_flow_executor( |  | ||||||
|         self, |  | ||||||
|         allowed_silent_types: list["StageView"] | None = None, |  | ||||||
|     ): |  | ||||||
|         # Check if we actually need to show the Flow executor, or if we can jump straight to the end |  | ||||||
|         found_unskippable = True |  | ||||||
|         if allowed_silent_types: |  | ||||||
|             LOGGER.debug("Checking if we can skip the flow executor...") |  | ||||||
|             # Policies applied to the flow have already been evaluated, so we're checking for stages |  | ||||||
|             # allow-listed or bindings that require a policy re-eval |  | ||||||
|             found_unskippable = False |  | ||||||
|             for binding, marker in zip(self.bindings, self.markers, strict=True): |  | ||||||
|                 if binding.stage.view not in allowed_silent_types: |  | ||||||
|                     found_unskippable = True |  | ||||||
|                 if marker and isinstance(marker, ReevaluateMarker): |  | ||||||
|                     found_unskippable = True |  | ||||||
|         LOGGER.debug("Required flow executor status", status=found_unskippable) |  | ||||||
|         return found_unskippable |  | ||||||
|  |  | ||||||
|     def to_redirect( |  | ||||||
|         self, |  | ||||||
|         request: HttpRequest, |  | ||||||
|         flow: Flow, |  | ||||||
|         allowed_silent_types: list["StageView"] | None = None, |  | ||||||
|     ) -> HttpResponse: |  | ||||||
|         """Redirect to the flow executor for this flow plan""" |  | ||||||
|         from authentik.flows.views.executor import ( |  | ||||||
|             SESSION_KEY_PLAN, |  | ||||||
|             FlowExecutorView, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         request.session[SESSION_KEY_PLAN] = self |  | ||||||
|         requires_flow_executor = self.requires_flow_executor(allowed_silent_types) |  | ||||||
|  |  | ||||||
|         if not requires_flow_executor: |  | ||||||
|             # No unskippable stages found, so we can directly return the response of the last stage |  | ||||||
|             final_stage: type[StageView] = self.bindings[-1].stage.view |  | ||||||
|             temp_exec = FlowExecutorView(flow=flow, request=request, plan=self) |  | ||||||
|             temp_exec.current_stage = self.bindings[-1].stage |  | ||||||
|             stage = final_stage(request=request, executor=temp_exec) |  | ||||||
|             return stage.dispatch(request) |  | ||||||
|  |  | ||||||
|         return redirect_with_qs( |  | ||||||
|             "authentik_core:if-flow", |  | ||||||
|             request.GET, |  | ||||||
|             flow_slug=flow.slug, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowPlanner: | class FlowPlanner: | ||||||
|     """Execute all policies to plan out a flat list of all Stages |     """Execute all policies to plan out a flat list of all Stages | ||||||
| @ -183,7 +128,7 @@ class FlowPlanner: | |||||||
|         self.flow = flow |         self.flow = flow | ||||||
|         self._logger = get_logger().bind(flow_slug=flow.slug) |         self._logger = get_logger().bind(flow_slug=flow.slug) | ||||||
|  |  | ||||||
|     def _check_authentication(self, request: HttpRequest, context: dict[str, Any]): |     def _check_authentication(self, request: HttpRequest): | ||||||
|         """Check the flow's authentication level is matched by `request`""" |         """Check the flow's authentication level is matched by `request`""" | ||||||
|         if ( |         if ( | ||||||
|             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED |             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED | ||||||
| @ -200,11 +145,6 @@ class FlowPlanner: | |||||||
|             and not request.user.is_superuser |             and not request.user.is_superuser | ||||||
|         ): |         ): | ||||||
|             raise FlowNonApplicableException() |             raise FlowNonApplicableException() | ||||||
|         if ( |  | ||||||
|             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_REDIRECT |  | ||||||
|             and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None |  | ||||||
|         ): |  | ||||||
|             raise FlowNonApplicableException() |  | ||||||
|         outpost_user = ClientIPMiddleware.get_outpost_user(request) |         outpost_user = ClientIPMiddleware.get_outpost_user(request) | ||||||
|         if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: |         if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: | ||||||
|             if not outpost_user: |             if not outpost_user: | ||||||
| @ -236,13 +176,18 @@ class FlowPlanner: | |||||||
|             ) |             ) | ||||||
|             context = default_context or {} |             context = default_context or {} | ||||||
|             # Bit of a workaround here, if there is a pending user set in the default context |             # Bit of a workaround here, if there is a pending user set in the default context | ||||||
|             # we use that user for our cache key to make sure they don't get the generic response |             # we use that user for our cache key | ||||||
|  |             # to make sure they don't get the generic response | ||||||
|             if context and PLAN_CONTEXT_PENDING_USER in context: |             if context and PLAN_CONTEXT_PENDING_USER in context: | ||||||
|                 user = context[PLAN_CONTEXT_PENDING_USER] |                 user = context[PLAN_CONTEXT_PENDING_USER] | ||||||
|             else: |             else: | ||||||
|                 user = request.user |                 user = request.user | ||||||
|  |                 # We only need to check the flow authentication if it's planned without a user | ||||||
|             context.update(self._check_authentication(request, context)) |                 # in the context, as a user in the context can only be set via the explicit code API | ||||||
|  |                 # or if a flow is restarted due to `invalid_response_action` being set to | ||||||
|  |                 # `restart_with_context`, which can only happen if the user was already authorized | ||||||
|  |                 # to use the flow | ||||||
|  |                 context.update(self._check_authentication(request)) | ||||||
|             # First off, check the flow's direct policy bindings |             # First off, check the flow's direct policy bindings | ||||||
|             # to make sure the user even has access to the flow |             # to make sure the user even has access to the flow | ||||||
|             engine = PolicyEngine(self.flow, user, request) |             engine = PolicyEngine(self.flow, user, request) | ||||||
|  | |||||||
| @ -93,11 +93,7 @@ class ChallengeStageView(StageView): | |||||||
|  |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """Return a challenge for the frontend to solve""" |         """Return a challenge for the frontend to solve""" | ||||||
|         try: |         challenge = self._get_challenge(*args, **kwargs) | ||||||
|             challenge = self._get_challenge(*args, **kwargs) |  | ||||||
|         except StageInvalidException as exc: |  | ||||||
|             self.logger.debug("Got StageInvalidException", exc=exc) |  | ||||||
|             return self.executor.stage_invalid() |  | ||||||
|         if not challenge.is_valid(): |         if not challenge.is_valid(): | ||||||
|             self.logger.warning( |             self.logger.warning( | ||||||
|                 "f(ch): Invalid challenge", |                 "f(ch): Invalid challenge", | ||||||
| @ -173,7 +169,11 @@ class ChallengeStageView(StageView): | |||||||
|                 stage_type=self.__class__.__name__, method="get_challenge" |                 stage_type=self.__class__.__name__, method="get_challenge" | ||||||
|             ).time(), |             ).time(), | ||||||
|         ): |         ): | ||||||
|             challenge = self.get_challenge(*args, **kwargs) |             try: | ||||||
|  |                 challenge = self.get_challenge(*args, **kwargs) | ||||||
|  |             except StageInvalidException as exc: | ||||||
|  |                 self.logger.debug("Got StageInvalidException", exc=exc) | ||||||
|  |                 return self.executor.stage_invalid() | ||||||
|         with start_span( |         with start_span( | ||||||
|             op="authentik.flow.stage._get_challenge", |             op="authentik.flow.stage._get_challenge", | ||||||
|             name=self.__class__.__name__, |             name=self.__class__.__name__, | ||||||
|  | |||||||
| @ -9,8 +9,8 @@ | |||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> |         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||||
|         {% block head_before %} |         {% block head_before %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> | ||||||
|  | |||||||
| @ -5,8 +5,6 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch | |||||||
| from django.contrib.auth.models import AnonymousUser | from django.contrib.auth.models import AnonymousUser | ||||||
| from django.contrib.sessions.middleware import SessionMiddleware | from django.contrib.sessions.middleware import SessionMiddleware | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest |  | ||||||
| from django.shortcuts import redirect |  | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| @ -16,19 +14,8 @@ from authentik.core.models import User | |||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import ( | from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding | ||||||
|     FlowAuthenticationRequirement, | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key | ||||||
|     FlowDesignation, |  | ||||||
|     FlowStageBinding, |  | ||||||
|     in_memory_stage, |  | ||||||
| ) |  | ||||||
| from authentik.flows.planner import ( |  | ||||||
|     PLAN_CONTEXT_IS_REDIRECTED, |  | ||||||
|     PLAN_CONTEXT_PENDING_USER, |  | ||||||
|     FlowPlanner, |  | ||||||
|     cache_key, |  | ||||||
| ) |  | ||||||
| from authentik.flows.stage import StageView |  | ||||||
| from authentik.lib.tests.utils import dummy_get_response | from authentik.lib.tests.utils import dummy_get_response | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST | from authentik.outposts.apps import MANAGED_OUTPOST | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
| @ -86,24 +73,6 @@ class TestFlowPlanner(TestCase): | |||||||
|         planner.allow_empty_flows = True |         planner.allow_empty_flows = True | ||||||
|         planner.plan(request) |         planner.plan(request) | ||||||
|  |  | ||||||
|     def test_authentication_redirect_required(self): |  | ||||||
|         """Test flow authentication (redirect required)""" |  | ||||||
|         flow = create_test_flow() |  | ||||||
|         flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT |  | ||||||
|         request = self.request_factory.get( |  | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|         request.user = AnonymousUser() |  | ||||||
|         planner = FlowPlanner(flow) |  | ||||||
|         planner.allow_empty_flows = True |  | ||||||
|  |  | ||||||
|         with self.assertRaises(FlowNonApplicableException): |  | ||||||
|             planner.plan(request) |  | ||||||
|  |  | ||||||
|         context = {} |  | ||||||
|         context[PLAN_CONTEXT_IS_REDIRECTED] = create_test_flow() |  | ||||||
|         planner.plan(request, context) |  | ||||||
|  |  | ||||||
|     @reconcile_app("authentik_outposts") |     @reconcile_app("authentik_outposts") | ||||||
|     def test_authentication_outpost(self): |     def test_authentication_outpost(self): | ||||||
|         """Test flow authentication (outpost)""" |         """Test flow authentication (outpost)""" | ||||||
| @ -242,99 +211,3 @@ class TestFlowPlanner(TestCase): | |||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
|  |  | ||||||
|     def test_to_redirect(self): |  | ||||||
|         """Test to_redirect and skipping the flow executor""" |  | ||||||
|         flow = create_test_flow() |  | ||||||
|         flow.authentication = FlowAuthenticationRequirement.NONE |  | ||||||
|         request = self.request_factory.get( |  | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|         middleware = SessionMiddleware(dummy_get_response) |  | ||||||
|         middleware.process_request(request) |  | ||||||
|         request.session.save() |  | ||||||
|  |  | ||||||
|         request.user = AnonymousUser() |  | ||||||
|         planner = FlowPlanner(flow) |  | ||||||
|         planner.allow_empty_flows = True |  | ||||||
|         plan = planner.plan(request) |  | ||||||
|         self.assertTrue(plan.requires_flow_executor()) |  | ||||||
|         self.assertEqual( |  | ||||||
|             plan.to_redirect(request, flow).url, |  | ||||||
|             reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_to_redirect_skip_simple(self): |  | ||||||
|         """Test to_redirect and skipping the flow executor""" |  | ||||||
|         flow = create_test_flow() |  | ||||||
|         flow.authentication = FlowAuthenticationRequirement.NONE |  | ||||||
|         request = self.request_factory.get( |  | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|         middleware = SessionMiddleware(dummy_get_response) |  | ||||||
|         middleware.process_request(request) |  | ||||||
|         request.session.save() |  | ||||||
|         request.user = AnonymousUser() |  | ||||||
|         planner = FlowPlanner(flow) |  | ||||||
|         planner.allow_empty_flows = True |  | ||||||
|         plan = planner.plan(request) |  | ||||||
|  |  | ||||||
|         class TStageView(StageView): |  | ||||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): |  | ||||||
|                 return redirect("https://authentik.company") |  | ||||||
|  |  | ||||||
|         plan.append_stage(in_memory_stage(TStageView)) |  | ||||||
|         self.assertFalse(plan.requires_flow_executor(allowed_silent_types=[TStageView])) |  | ||||||
|         self.assertEqual( |  | ||||||
|             plan.to_redirect(request, flow, allowed_silent_types=[TStageView]).url, |  | ||||||
|             "https://authentik.company", |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_to_redirect_skip_stage(self): |  | ||||||
|         """Test to_redirect and skipping the flow executor |  | ||||||
|         (with a stage bound that cannot be skipped)""" |  | ||||||
|         flow = create_test_flow() |  | ||||||
|         flow.authentication = FlowAuthenticationRequirement.NONE |  | ||||||
|  |  | ||||||
|         FlowStageBinding.objects.create( |  | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         request = self.request_factory.get( |  | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|         request.user = AnonymousUser() |  | ||||||
|         planner = FlowPlanner(flow) |  | ||||||
|         planner.allow_empty_flows = True |  | ||||||
|         plan = planner.plan(request) |  | ||||||
|  |  | ||||||
|         class TStageView(StageView): |  | ||||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): |  | ||||||
|                 return redirect("https://authentik.company") |  | ||||||
|  |  | ||||||
|         plan.append_stage(in_memory_stage(TStageView)) |  | ||||||
|         self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView])) |  | ||||||
|  |  | ||||||
|     def test_to_redirect_skip_policies(self): |  | ||||||
|         """Test to_redirect and skipping the flow executor |  | ||||||
|         (with a marker on the stage view type that can be skipped) |  | ||||||
|  |  | ||||||
|         Note that this is not actually used anywhere in the code, all stages that are dynamically |  | ||||||
|         added are statically added""" |  | ||||||
|         flow = create_test_flow() |  | ||||||
|         flow.authentication = FlowAuthenticationRequirement.NONE |  | ||||||
|  |  | ||||||
|         request = self.request_factory.get( |  | ||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |  | ||||||
|         ) |  | ||||||
|         request.user = AnonymousUser() |  | ||||||
|         planner = FlowPlanner(flow) |  | ||||||
|         planner.allow_empty_flows = True |  | ||||||
|         plan = planner.plan(request) |  | ||||||
|  |  | ||||||
|         class TStageView(StageView): |  | ||||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): |  | ||||||
|                 return redirect("https://authentik.company") |  | ||||||
|  |  | ||||||
|         plan.append_stage(in_memory_stage(TStageView), ReevaluateMarker(None)) |  | ||||||
|         self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView])) |  | ||||||
|  | |||||||
| @ -171,8 +171,7 @@ class FlowExecutorView(APIView): | |||||||
|                     # Existing plan is deleted from session and instance |                     # Existing plan is deleted from session and instance | ||||||
|                     self.plan = None |                     self.plan = None | ||||||
|                     self.cancel() |                     self.cancel() | ||||||
|                 else: |                 self._logger.debug("f(exec): Continuing existing plan") | ||||||
|                     self._logger.debug("f(exec): Continuing existing plan") |  | ||||||
|  |  | ||||||
|             # Initial flow request, check if we have an upstream query string passed in |             # Initial flow request, check if we have an upstream query string passed in | ||||||
|             request.session[SESSION_KEY_GET] = get_params |             request.session[SESSION_KEY_GET] = get_params | ||||||
| @ -598,4 +597,9 @@ class ConfigureFlowInitView(LoginRequiredMixin, View): | |||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             LOGGER.warning("Flow not applicable to user") |             LOGGER.warning("Flow not applicable to user") | ||||||
|             raise Http404 from None |             raise Http404 from None | ||||||
|         return plan.to_redirect(request, stage.configure_flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             self.request.GET, | ||||||
|  |             flow_slug=stage.configure_flow.slug, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ import json | |||||||
| import os | import os | ||||||
| from collections.abc import Mapping | from collections.abc import Mapping | ||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
| from copy import deepcopy |  | ||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from glob import glob | from glob import glob | ||||||
| @ -337,58 +336,6 @@ def redis_url(db: int) -> str: | |||||||
|     return _redis_url |     return _redis_url | ||||||
|  |  | ||||||
|  |  | ||||||
| def django_db_config(config: ConfigLoader | None = None) -> dict: |  | ||||||
|     if not config: |  | ||||||
|         config = CONFIG |  | ||||||
|     db = { |  | ||||||
|         "default": { |  | ||||||
|             "ENGINE": "authentik.root.db", |  | ||||||
|             "HOST": config.get("postgresql.host"), |  | ||||||
|             "NAME": config.get("postgresql.name"), |  | ||||||
|             "USER": config.get("postgresql.user"), |  | ||||||
|             "PASSWORD": config.get("postgresql.password"), |  | ||||||
|             "PORT": config.get("postgresql.port"), |  | ||||||
|             "OPTIONS": { |  | ||||||
|                 "sslmode": config.get("postgresql.sslmode"), |  | ||||||
|                 "sslrootcert": config.get("postgresql.sslrootcert"), |  | ||||||
|                 "sslcert": config.get("postgresql.sslcert"), |  | ||||||
|                 "sslkey": config.get("postgresql.sslkey"), |  | ||||||
|             }, |  | ||||||
|             "TEST": { |  | ||||||
|                 "NAME": config.get("postgresql.test.name"), |  | ||||||
|             }, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if config.get_bool("postgresql.use_pgpool", False): |  | ||||||
|         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True |  | ||||||
|  |  | ||||||
|     if config.get_bool("postgresql.use_pgbouncer", False): |  | ||||||
|         # https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors |  | ||||||
|         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True |  | ||||||
|         # https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections |  | ||||||
|         db["default"]["CONN_MAX_AGE"] = None  # persistent |  | ||||||
|  |  | ||||||
|     for replica in config.get_keys("postgresql.read_replicas"): |  | ||||||
|         _database = deepcopy(db["default"]) |  | ||||||
|         for setting, current_value in db["default"].items(): |  | ||||||
|             if isinstance(current_value, dict): |  | ||||||
|                 continue |  | ||||||
|             override = config.get( |  | ||||||
|                 f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET |  | ||||||
|             ) |  | ||||||
|             if override is not UNSET: |  | ||||||
|                 _database[setting] = override |  | ||||||
|         for setting in db["default"]["OPTIONS"].keys(): |  | ||||||
|             override = config.get( |  | ||||||
|                 f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET |  | ||||||
|             ) |  | ||||||
|             if override is not UNSET: |  | ||||||
|                 _database["OPTIONS"][setting] = override |  | ||||||
|         db[f"replica_{replica}"] = _database |  | ||||||
|     return db |  | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     if len(argv) < 2:  # noqa: PLR2004 |     if len(argv) < 2:  # noqa: PLR2004 | ||||||
|         print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder)) |         print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder)) | ||||||
|  | |||||||
| @ -135,7 +135,6 @@ web: | |||||||
|   # No default here as it's set dynamically |   # No default here as it's set dynamically | ||||||
|   # workers: 2 |   # workers: 2 | ||||||
|   threads: 4 |   threads: 4 | ||||||
|   path: / |  | ||||||
|  |  | ||||||
| worker: | worker: | ||||||
|   concurrency: 2 |   concurrency: 2 | ||||||
|  | |||||||
| @ -36,7 +36,6 @@ from authentik.lib.utils.http import authentik_user_agent | |||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| _root_path = CONFIG.get("web.path", "/") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SentryIgnoredException(Exception): | class SentryIgnoredException(Exception): | ||||||
| @ -91,7 +90,7 @@ def traces_sampler(sampling_context: dict) -> float: | |||||||
|     path = sampling_context.get("asgi_scope", {}).get("path", "") |     path = sampling_context.get("asgi_scope", {}).get("path", "") | ||||||
|     _type = sampling_context.get("asgi_scope", {}).get("type", "") |     _type = sampling_context.get("asgi_scope", {}).get("type", "") | ||||||
|     # Ignore all healthcheck routes |     # Ignore all healthcheck routes | ||||||
|     if path.startswith(f"{_root_path}-/health") or path.startswith(f"{_root_path}-/metrics"): |     if path.startswith("/-/health") or path.startswith("/-/metrics"): | ||||||
|         return 0 |         return 0 | ||||||
|     if _type == "websocket": |     if _type == "websocket": | ||||||
|         return 0 |         return 0 | ||||||
|  | |||||||
| @ -82,7 +82,7 @@ class SyncTasks: | |||||||
|                 return |                 return | ||||||
|             try: |             try: | ||||||
|                 for page in users_paginator.page_range: |                 for page in users_paginator.page_range: | ||||||
|                     messages.append(_("Syncing page {page} of users".format(page=page))) |                     messages.append(_("Syncing page %(page)d of users" % {"page": page})) | ||||||
|                     for msg in sync_objects.apply_async( |                     for msg in sync_objects.apply_async( | ||||||
|                         args=(class_to_path(User), page, provider_pk), |                         args=(class_to_path(User), page, provider_pk), | ||||||
|                         time_limit=PAGE_TIMEOUT, |                         time_limit=PAGE_TIMEOUT, | ||||||
| @ -90,7 +90,7 @@ class SyncTasks: | |||||||
|                     ).get(): |                     ).get(): | ||||||
|                         messages.append(LogEvent(**msg)) |                         messages.append(LogEvent(**msg)) | ||||||
|                 for page in groups_paginator.page_range: |                 for page in groups_paginator.page_range: | ||||||
|                     messages.append(_("Syncing page {page} of groups".format(page=page))) |                     messages.append(_("Syncing page %(page)d of groups" % {"page": page})) | ||||||
|                     for msg in sync_objects.apply_async( |                     for msg in sync_objects.apply_async( | ||||||
|                         args=(class_to_path(Group), page, provider_pk), |                         args=(class_to_path(Group), page, provider_pk), | ||||||
|                         time_limit=PAGE_TIMEOUT, |                         time_limit=PAGE_TIMEOUT, | ||||||
|  | |||||||
| @ -9,14 +9,7 @@ from unittest import mock | |||||||
| from django.conf import ImproperlyConfigured | from django.conf import ImproperlyConfigured | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from authentik.lib.config import ( | from authentik.lib.config import ENV_PREFIX, UNSET, Attr, AttrEncoder, ConfigLoader | ||||||
|     ENV_PREFIX, |  | ||||||
|     UNSET, |  | ||||||
|     Attr, |  | ||||||
|     AttrEncoder, |  | ||||||
|     ConfigLoader, |  | ||||||
|     django_db_config, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestConfig(TestCase): | class TestConfig(TestCase): | ||||||
| @ -182,201 +175,3 @@ class TestConfig(TestCase): | |||||||
|         config = ConfigLoader() |         config = ConfigLoader() | ||||||
|         config.set("foo.bar", "baz") |         config.set("foo.bar", "baz") | ||||||
|         self.assertEqual(list(config.get_keys("foo")), ["bar"]) |         self.assertEqual(list(config.get_keys("foo")), ["bar"]) | ||||||
|  |  | ||||||
|     def test_db_default(self): |  | ||||||
|         """Test default DB Config""" |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         config.set("postgresql.host", "foo") |  | ||||||
|         config.set("postgresql.name", "foo") |  | ||||||
|         config.set("postgresql.user", "foo") |  | ||||||
|         config.set("postgresql.password", "foo") |  | ||||||
|         config.set("postgresql.port", "foo") |  | ||||||
|         config.set("postgresql.sslmode", "foo") |  | ||||||
|         config.set("postgresql.sslrootcert", "foo") |  | ||||||
|         config.set("postgresql.sslcert", "foo") |  | ||||||
|         config.set("postgresql.sslkey", "foo") |  | ||||||
|         config.set("postgresql.test.name", "foo") |  | ||||||
|         conf = django_db_config(config) |  | ||||||
|         self.assertEqual( |  | ||||||
|             conf, |  | ||||||
|             { |  | ||||||
|                 "default": { |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "foo", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_db_read_replicas(self): |  | ||||||
|         """Test read replicas""" |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         config.set("postgresql.host", "foo") |  | ||||||
|         config.set("postgresql.name", "foo") |  | ||||||
|         config.set("postgresql.user", "foo") |  | ||||||
|         config.set("postgresql.password", "foo") |  | ||||||
|         config.set("postgresql.port", "foo") |  | ||||||
|         config.set("postgresql.sslmode", "foo") |  | ||||||
|         config.set("postgresql.sslrootcert", "foo") |  | ||||||
|         config.set("postgresql.sslcert", "foo") |  | ||||||
|         config.set("postgresql.sslkey", "foo") |  | ||||||
|         config.set("postgresql.test.name", "foo") |  | ||||||
|         # Read replica |  | ||||||
|         config.set("postgresql.read_replicas.0.host", "bar") |  | ||||||
|         conf = django_db_config(config) |  | ||||||
|         self.assertEqual( |  | ||||||
|             conf, |  | ||||||
|             { |  | ||||||
|                 "default": { |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "foo", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|                 "replica_0": { |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "bar", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_db_read_replicas_pgpool(self): |  | ||||||
|         """Test read replicas""" |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         config.set("postgresql.host", "foo") |  | ||||||
|         config.set("postgresql.name", "foo") |  | ||||||
|         config.set("postgresql.user", "foo") |  | ||||||
|         config.set("postgresql.password", "foo") |  | ||||||
|         config.set("postgresql.port", "foo") |  | ||||||
|         config.set("postgresql.sslmode", "foo") |  | ||||||
|         config.set("postgresql.sslrootcert", "foo") |  | ||||||
|         config.set("postgresql.sslcert", "foo") |  | ||||||
|         config.set("postgresql.sslkey", "foo") |  | ||||||
|         config.set("postgresql.test.name", "foo") |  | ||||||
|         config.set("postgresql.use_pgpool", True) |  | ||||||
|         # Read replica |  | ||||||
|         config.set("postgresql.read_replicas.0.host", "bar") |  | ||||||
|         # This isn't supported |  | ||||||
|         config.set("postgresql.read_replicas.0.use_pgpool", False) |  | ||||||
|         conf = django_db_config(config) |  | ||||||
|         self.assertEqual( |  | ||||||
|             conf, |  | ||||||
|             { |  | ||||||
|                 "default": { |  | ||||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "foo", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|                 "replica_0": { |  | ||||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "bar", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_db_read_replicas_diff_ssl(self): |  | ||||||
|         """Test read replicas (with different SSL Settings)""" |  | ||||||
|         """Test read replicas""" |  | ||||||
|         config = ConfigLoader() |  | ||||||
|         config.set("postgresql.host", "foo") |  | ||||||
|         config.set("postgresql.name", "foo") |  | ||||||
|         config.set("postgresql.user", "foo") |  | ||||||
|         config.set("postgresql.password", "foo") |  | ||||||
|         config.set("postgresql.port", "foo") |  | ||||||
|         config.set("postgresql.sslmode", "foo") |  | ||||||
|         config.set("postgresql.sslrootcert", "foo") |  | ||||||
|         config.set("postgresql.sslcert", "foo") |  | ||||||
|         config.set("postgresql.sslkey", "foo") |  | ||||||
|         config.set("postgresql.test.name", "foo") |  | ||||||
|         # Read replica |  | ||||||
|         config.set("postgresql.read_replicas.0.host", "bar") |  | ||||||
|         config.set("postgresql.read_replicas.0.sslcert", "bar") |  | ||||||
|         conf = django_db_config(config) |  | ||||||
|         self.assertEqual( |  | ||||||
|             conf, |  | ||||||
|             { |  | ||||||
|                 "default": { |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "foo", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "foo", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|                 "replica_0": { |  | ||||||
|                     "ENGINE": "authentik.root.db", |  | ||||||
|                     "HOST": "bar", |  | ||||||
|                     "NAME": "foo", |  | ||||||
|                     "OPTIONS": { |  | ||||||
|                         "sslcert": "bar", |  | ||||||
|                         "sslkey": "foo", |  | ||||||
|                         "sslmode": "foo", |  | ||||||
|                         "sslrootcert": "foo", |  | ||||||
|                     }, |  | ||||||
|                     "PASSWORD": "foo", |  | ||||||
|                     "PORT": "foo", |  | ||||||
|                     "TEST": {"NAME": "foo"}, |  | ||||||
|                     "USER": "foo", |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -84,17 +84,19 @@ class PolicyBindingSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     def validate(self, attrs: OrderedDict) -> OrderedDict: |     def validate(self, attrs: OrderedDict) -> OrderedDict: | ||||||
|         """Check that either policy, group or user is set.""" |         """Check that either policy, group or user is set.""" | ||||||
|         target: PolicyBindingModel = attrs.get("target") |         count = sum( | ||||||
|         supported = target.supported_policy_binding_targets() |             [ | ||||||
|         supported.sort() |                 bool(attrs.get("policy", None)), | ||||||
|         count = sum([bool(attrs.get(x, None)) for x in supported]) |                 bool(attrs.get("group", None)), | ||||||
|  |                 bool(attrs.get("user", None)), | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|         invalid = count > 1 |         invalid = count > 1 | ||||||
|         empty = count < 1 |         empty = count < 1 | ||||||
|         warning = ", ".join(f"'{x}'" for x in supported) |  | ||||||
|         if invalid: |         if invalid: | ||||||
|             raise ValidationError(f"Only one of {warning} can be set.") |             raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.") | ||||||
|         if empty: |         if empty: | ||||||
|             raise ValidationError(f"One of {warning} must be set.") |             raise ValidationError("One of 'policy', 'group' or 'user' must be set.") | ||||||
|         return attrs |         return attrs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -43,9 +43,8 @@ class PasswordExpiryPolicy(Policy): | |||||||
|                 request.user.set_unusable_password() |                 request.user.set_unusable_password() | ||||||
|                 request.user.save() |                 request.user.save() | ||||||
|                 message = _( |                 message = _( | ||||||
|                     "Password expired {days} days ago. Please update your password.".format( |                     "Password expired %(days)d days ago. Please update your password." | ||||||
|                         days=days_since_expiry |                     % {"days": days_since_expiry} | ||||||
|                     ) |  | ||||||
|                 ) |                 ) | ||||||
|                 return PolicyResult(False, message) |                 return PolicyResult(False, message) | ||||||
|             return PolicyResult(False, _("Password has expired.")) |             return PolicyResult(False, _("Password has expired.")) | ||||||
|  | |||||||
| @ -1,6 +1,4 @@ | |||||||
| # Generated by Django 4.2.5 on 2023-09-13 18:07 | # Generated by Django 4.2.5 on 2023-09-13 18:07 | ||||||
| import authentik.lib.models |  | ||||||
| import django.db.models.deletion |  | ||||||
|  |  | ||||||
| from django.db import migrations, models | from django.db import migrations, models | ||||||
|  |  | ||||||
| @ -25,13 +23,4 @@ class Migration(migrations.Migration): | |||||||
|                 default=30, help_text="Timeout after which Policy execution is terminated." |                 default=30, help_text="Timeout after which Policy execution is terminated." | ||||||
|             ), |             ), | ||||||
|         ), |         ), | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="policybinding", |  | ||||||
|             name="target", |  | ||||||
|             field=authentik.lib.models.InheritanceForeignKey( |  | ||||||
|                 on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                 related_name="bindings", |  | ||||||
|                 to="authentik_policies.policybindingmodel", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |     ] | ||||||
|  | |||||||
| @ -47,10 +47,6 @@ class PolicyBindingModel(models.Model): | |||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"PolicyBindingModel {self.pbm_uuid}" |         return f"PolicyBindingModel {self.pbm_uuid}" | ||||||
|  |  | ||||||
|     def supported_policy_binding_targets(self): |  | ||||||
|         """Return the list of objects that can be bound to this object.""" |  | ||||||
|         return ["policy", "user", "group"] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyBinding(SerializerModel): | class PolicyBinding(SerializerModel): | ||||||
|     """Relationship between a Policy and a PolicyBindingModel.""" |     """Relationship between a Policy and a PolicyBindingModel.""" | ||||||
| @ -85,9 +81,7 @@ class PolicyBinding(SerializerModel): | |||||||
|         blank=True, |         blank=True, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     target = InheritanceForeignKey( |     target = InheritanceForeignKey(PolicyBindingModel, on_delete=models.CASCADE, related_name="+") | ||||||
|         PolicyBindingModel, on_delete=models.CASCADE, related_name="bindings" |  | ||||||
|     ) |  | ||||||
|     negate = models.BooleanField( |     negate = models.BooleanField( | ||||||
|         default=False, |         default=False, | ||||||
|         help_text=_("Negates the outcome of the policy. Messages are unaffected."), |         help_text=_("Negates the outcome of the policy. Messages are unaffected."), | ||||||
|  | |||||||
| @ -135,7 +135,7 @@ class PasswordPolicy(Policy): | |||||||
|         LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) |         LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) | ||||||
|         if final_count > self.hibp_allowed_count: |         if final_count > self.hibp_allowed_count: | ||||||
|             LOGGER.debug("password failed", check="hibp", count=final_count) |             LOGGER.debug("password failed", check="hibp", count=final_count) | ||||||
|             message = _("Password exists on {count} online lists.".format(count=final_count)) |             message = _("Password exists on %(count)d online lists." % {"count": final_count}) | ||||||
|             return PolicyResult(False, message) |             return PolicyResult(False, message) | ||||||
|         return PolicyResult(True) |         return PolicyResult(True) | ||||||
|  |  | ||||||
|  | |||||||
| @ -38,7 +38,7 @@ class TestBindingsAPI(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             response.content.decode(), |             response.content.decode(), | ||||||
|             {"non_field_errors": ["Only one of 'group', 'policy', 'user' can be set."]}, |             {"non_field_errors": ["Only one of 'policy', 'group' or 'user' can be set."]}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_invalid_too_little(self): |     def test_invalid_too_little(self): | ||||||
| @ -49,5 +49,5 @@ class TestBindingsAPI(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             response.content.decode(), |             response.content.decode(), | ||||||
|             {"non_field_errors": ["One of 'group', 'policy', 'user' must be set."]}, |             {"non_field_errors": ["One of 'policy', 'group' or 'user' must be set."]}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -73,8 +73,7 @@ class OAuth2ProviderSerializer(ProviderSerializer): | |||||||
|             "sub_mode", |             "sub_mode", | ||||||
|             "property_mappings", |             "property_mappings", | ||||||
|             "issuer_mode", |             "issuer_mode", | ||||||
|             "jwt_federation_sources", |             "jwks_sources", | ||||||
|             "jwt_federation_providers", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = ProviderSerializer.Meta.extra_kwargs |         extra_kwargs = ProviderSerializer.Meta.extra_kwargs | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,25 +0,0 @@ | |||||||
| # Generated by Django 5.0.9 on 2024-11-22 14:25 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_providers_oauth2", "0024_remove_oauth2provider_redirect_uris_and_more"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.RenameField( |  | ||||||
|             model_name="oauth2provider", |  | ||||||
|             old_name="jwks_sources", |  | ||||||
|             new_name="jwt_federation_sources", |  | ||||||
|         ), |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="oauth2provider", |  | ||||||
|             name="jwt_federation_providers", |  | ||||||
|             field=models.ManyToManyField( |  | ||||||
|                 blank=True, default=None, to="authentik_providers_oauth2.oauth2provider" |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,38 +0,0 @@ | |||||||
| # Generated by Django 5.0.10 on 2024-12-12 17:16 |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0040_provider_invalidation_flow"), |  | ||||||
|         ( |  | ||||||
|             "authentik_providers_oauth2", |  | ||||||
|             "0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more", |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="accesstoken", |  | ||||||
|             name="session", |  | ||||||
|             field=models.ForeignKey( |  | ||||||
|                 default=None, |  | ||||||
|                 null=True, |  | ||||||
|                 on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                 to="authentik_core.authenticatedsession", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="authorizationcode", |  | ||||||
|             name="session", |  | ||||||
|             field=models.ForeignKey( |  | ||||||
|                 default=None, |  | ||||||
|                 null=True, |  | ||||||
|                 on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                 to="authentik_core.authenticatedsession", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -244,7 +244,7 @@ class OAuth2Provider(WebfingerProvider, Provider): | |||||||
|         related_name="oauth2provider_encryption_key_set", |         related_name="oauth2provider_encryption_key_set", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     jwt_federation_sources = models.ManyToManyField( |     jwks_sources = models.ManyToManyField( | ||||||
|         OAuthSource, |         OAuthSource, | ||||||
|         verbose_name=_( |         verbose_name=_( | ||||||
|             "Any JWT signed by the JWK of the selected source can be used to authenticate." |             "Any JWT signed by the JWK of the selected source can be used to authenticate." | ||||||
| @ -253,7 +253,6 @@ class OAuth2Provider(WebfingerProvider, Provider): | |||||||
|         default=None, |         default=None, | ||||||
|         blank=True, |         blank=True, | ||||||
|     ) |     ) | ||||||
|     jwt_federation_providers = models.ManyToManyField("OAuth2Provider", blank=True, default=None) |  | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
|     def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: |     def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: | ||||||
| @ -396,7 +395,7 @@ class BaseGrantModel(models.Model): | |||||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) |     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||||
|     auth_time = models.DateTimeField(verbose_name="Authentication time") |     auth_time = models.DateTimeField(verbose_name="Authentication time") | ||||||
|     session = models.ForeignKey( |     session = models.ForeignKey( | ||||||
|         AuthenticatedSession, null=True, on_delete=models.CASCADE, default=None |         AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @ -497,11 +496,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | |||||||
|  |  | ||||||
|     token = models.TextField(default=generate_client_secret) |     token = models.TextField(default=generate_client_secret) | ||||||
|     _id_token = models.TextField(verbose_name=_("ID Token")) |     _id_token = models.TextField(verbose_name=_("ID Token")) | ||||||
|     # Shadow the `session` field from `BaseGrantModel` as we want refresh tokens to persist even |  | ||||||
|     # when the session is terminated. |  | ||||||
|     session = models.ForeignKey( |  | ||||||
|         AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         indexes = [ |         indexes = [ | ||||||
|  | |||||||
| @ -311,7 +311,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|         user = create_test_admin_user() |         user = create_test_admin_user() | ||||||
|         self.client.force_login(user) |         self.client.force_login(user) | ||||||
|         # Step 1, initiate params and get redirect to flow |         # Step 1, initiate params and get redirect to flow | ||||||
|         response = self.client.get( |         self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:authorize"), |             reverse("authentik_providers_oauth2:authorize"), | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
| @ -320,10 +320,16 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 "redirect_uri": "foo://localhost", |                 "redirect_uri": "foo://localhost", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertEqual( |         self.assertJSONEqual( | ||||||
|             response.url, |             response.content.decode(), | ||||||
|             f"foo://localhost?code={code.code}&state={state}", |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertAlmostEqual( |         self.assertAlmostEqual( | ||||||
|             code.expires.timestamp() - now().timestamp(), |             code.expires.timestamp() - now().timestamp(), | ||||||
| @ -371,7 +377,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             ), |             ), | ||||||
|         ): |         ): | ||||||
|             # Step 1, initiate params and get redirect to flow |             # Step 1, initiate params and get redirect to flow | ||||||
|             response = self.client.get( |             self.client.get( | ||||||
|                 reverse("authentik_providers_oauth2:authorize"), |                 reverse("authentik_providers_oauth2:authorize"), | ||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "id_token", |                     "response_type": "id_token", | ||||||
| @ -382,16 +388,22 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                     "nonce": generate_id(), |                     "nonce": generate_id(), | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|  |             response = self.client.get( | ||||||
|  |                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |             ) | ||||||
|             token: AccessToken = AccessToken.objects.filter(user=user).first() |             token: AccessToken = AccessToken.objects.filter(user=user).first() | ||||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() |             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||||
|             self.assertEqual( |             self.assertJSONEqual( | ||||||
|                 response.url, |                 response.content.decode(), | ||||||
|                 ( |                 { | ||||||
|                     f"http://localhost#access_token={token.token}" |                     "component": "xak-flow-redirect", | ||||||
|                     f"&id_token={provider.encode(token.id_token.to_dict())}" |                     "to": ( | ||||||
|                     f"&token_type={TOKEN_TYPE}" |                         f"http://localhost#access_token={token.token}" | ||||||
|                     f"&expires_in={int(expires)}&state={state}" |                         f"&id_token={provider.encode(token.id_token.to_dict())}" | ||||||
|                 ), |                         f"&token_type={TOKEN_TYPE}" | ||||||
|  |                         f"&expires_in={int(expires)}&state={state}" | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|             ) |             ) | ||||||
|             jwt = self.validate_jwt(token, provider) |             jwt = self.validate_jwt(token, provider) | ||||||
|             self.assertEqual(jwt["amr"], ["pwd"]) |             self.assertEqual(jwt["amr"], ["pwd"]) | ||||||
| @ -443,7 +455,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             ), |             ), | ||||||
|         ): |         ): | ||||||
|             # Step 1, initiate params and get redirect to flow |             # Step 1, initiate params and get redirect to flow | ||||||
|             response = self.client.get( |             self.client.get( | ||||||
|                 reverse("authentik_providers_oauth2:authorize"), |                 reverse("authentik_providers_oauth2:authorize"), | ||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "id_token", |                     "response_type": "id_token", | ||||||
| @ -454,7 +466,10 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                     "nonce": generate_id(), |                     "nonce": generate_id(), | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|             self.assertEqual(response.status_code, 302) |             response = self.client.get( | ||||||
|  |                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(response.status_code, 200) | ||||||
|             token: AccessToken = AccessToken.objects.filter(user=user).first() |             token: AccessToken = AccessToken.objects.filter(user=user).first() | ||||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() |             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||||
|             jwt = self.validate_jwe(token, provider) |             jwt = self.validate_jwe(token, provider) | ||||||
| @ -491,7 +506,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             ), |             ), | ||||||
|         ): |         ): | ||||||
|             # Step 1, initiate params and get redirect to flow |             # Step 1, initiate params and get redirect to flow | ||||||
|             response = self.client.get( |             self.client.get( | ||||||
|                 reverse("authentik_providers_oauth2:authorize"), |                 reverse("authentik_providers_oauth2:authorize"), | ||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "code", |                     "response_type": "code", | ||||||
| @ -503,10 +518,16 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                     "nonce": generate_id(), |                     "nonce": generate_id(), | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|  |             response = self.client.get( | ||||||
|  |                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |             ) | ||||||
|             code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |             code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|             self.assertEqual( |             self.assertJSONEqual( | ||||||
|                 response.url, |                 response.content.decode(), | ||||||
|                 f"http://localhost#code={code.code}&state={state}", |                 { | ||||||
|  |                     "component": "xak-flow-redirect", | ||||||
|  |                     "to": (f"http://localhost#code={code.code}" f"&state={state}"), | ||||||
|  |                 }, | ||||||
|             ) |             ) | ||||||
|             self.assertAlmostEqual( |             self.assertAlmostEqual( | ||||||
|                 code.expires.timestamp() - now().timestamp(), |                 code.expires.timestamp() - now().timestamp(), | ||||||
|  | |||||||
| @ -1,228 +0,0 @@ | |||||||
| """Test token view""" |  | ||||||
|  |  | ||||||
| from datetime import datetime, timedelta |  | ||||||
| from json import loads |  | ||||||
|  |  | ||||||
| from django.test import RequestFactory |  | ||||||
| from django.urls import reverse |  | ||||||
| from django.utils.timezone import now |  | ||||||
| from jwt import decode |  | ||||||
|  |  | ||||||
| from authentik.blueprints.tests import apply_blueprint |  | ||||||
| from authentik.core.models import Application, Group |  | ||||||
| from authentik.core.tests.utils import create_test_cert, create_test_flow, create_test_user |  | ||||||
| from authentik.lib.generators import generate_id |  | ||||||
| from authentik.policies.models import PolicyBinding |  | ||||||
| from authentik.providers.oauth2.constants import ( |  | ||||||
|     GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|     SCOPE_OPENID, |  | ||||||
|     SCOPE_OPENID_EMAIL, |  | ||||||
|     SCOPE_OPENID_PROFILE, |  | ||||||
|     TOKEN_TYPE, |  | ||||||
| ) |  | ||||||
| from authentik.providers.oauth2.models import ( |  | ||||||
|     AccessToken, |  | ||||||
|     OAuth2Provider, |  | ||||||
|     RedirectURI, |  | ||||||
|     RedirectURIMatchingMode, |  | ||||||
|     ScopeMapping, |  | ||||||
| ) |  | ||||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestTokenClientCredentialsJWTProvider(OAuthTestCase): |  | ||||||
|     """Test token (client_credentials, with JWT) view""" |  | ||||||
|  |  | ||||||
|     @apply_blueprint("system/providers-oauth2.yaml") |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         super().setUp() |  | ||||||
|         self.factory = RequestFactory() |  | ||||||
|         self.other_cert = create_test_cert() |  | ||||||
|         self.cert = create_test_cert() |  | ||||||
|  |  | ||||||
|         self.other_provider = OAuth2Provider.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             authorization_flow=create_test_flow(), |  | ||||||
|             signing_key=self.other_cert, |  | ||||||
|         ) |  | ||||||
|         self.other_provider.property_mappings.set(ScopeMapping.objects.all()) |  | ||||||
|         self.app = Application.objects.create( |  | ||||||
|             name=generate_id(), slug=generate_id(), provider=self.other_provider |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         self.provider: OAuth2Provider = OAuth2Provider.objects.create( |  | ||||||
|             name="test", |  | ||||||
|             authorization_flow=create_test_flow(), |  | ||||||
|             redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], |  | ||||||
|             signing_key=self.cert, |  | ||||||
|         ) |  | ||||||
|         self.provider.jwt_federation_providers.add(self.other_provider) |  | ||||||
|         self.provider.property_mappings.set(ScopeMapping.objects.all()) |  | ||||||
|         self.app = Application.objects.create(name="test", slug="test", provider=self.provider) |  | ||||||
|  |  | ||||||
|     def test_invalid_type(self): |  | ||||||
|         """test invalid type""" |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "foo", |  | ||||||
|                 "client_assertion": "foo.bar", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_invalid_jwt(self): |  | ||||||
|         """test invalid JWT""" |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": "foo.bar", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_invalid_signature(self): |  | ||||||
|         """test invalid JWT""" |  | ||||||
|         token = self.provider.encode( |  | ||||||
|             { |  | ||||||
|                 "sub": "foo", |  | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": token + "foo", |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_invalid_expired(self): |  | ||||||
|         """test invalid JWT""" |  | ||||||
|         token = self.provider.encode( |  | ||||||
|             { |  | ||||||
|                 "sub": "foo", |  | ||||||
|                 "exp": datetime.now() - timedelta(hours=2), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": token, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_invalid_no_app(self): |  | ||||||
|         """test invalid JWT""" |  | ||||||
|         self.app.provider = None |  | ||||||
|         self.app.save() |  | ||||||
|         token = self.provider.encode( |  | ||||||
|             { |  | ||||||
|                 "sub": "foo", |  | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": token, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_invalid_access_denied(self): |  | ||||||
|         """test invalid JWT""" |  | ||||||
|         group = Group.objects.create(name="foo") |  | ||||||
|         PolicyBinding.objects.create( |  | ||||||
|             group=group, |  | ||||||
|             target=self.app, |  | ||||||
|             order=0, |  | ||||||
|         ) |  | ||||||
|         token = self.provider.encode( |  | ||||||
|             { |  | ||||||
|                 "sub": "foo", |  | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": token, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["error"], "invalid_grant") |  | ||||||
|  |  | ||||||
|     def test_successful(self): |  | ||||||
|         """test successful""" |  | ||||||
|         user = create_test_user() |  | ||||||
|         token = self.other_provider.encode( |  | ||||||
|             { |  | ||||||
|                 "sub": "foo", |  | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         AccessToken.objects.create( |  | ||||||
|             provider=self.other_provider, |  | ||||||
|             token=token, |  | ||||||
|             user=user, |  | ||||||
|             auth_time=now(), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse("authentik_providers_oauth2:token"), |  | ||||||
|             { |  | ||||||
|                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, |  | ||||||
|                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", |  | ||||||
|                 "client_id": self.provider.client_id, |  | ||||||
|                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", |  | ||||||
|                 "client_assertion": token, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         body = loads(response.content.decode()) |  | ||||||
|         self.assertEqual(body["token_type"], TOKEN_TYPE) |  | ||||||
|         _, alg = self.provider.jwt_key |  | ||||||
|         jwt = decode( |  | ||||||
|             body["access_token"], |  | ||||||
|             key=self.provider.signing_key.public_key, |  | ||||||
|             algorithms=[alg], |  | ||||||
|             audience=self.provider.client_id, |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(jwt["given_name"], user.name) |  | ||||||
|         self.assertEqual(jwt["preferred_username"], user.username) |  | ||||||
| @ -37,16 +37,9 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.factory = RequestFactory() |         self.factory = RequestFactory() | ||||||
|         self.other_cert = create_test_cert() |  | ||||||
|         # Provider used as a helper to sign JWTs with the same key as the OAuth source has |  | ||||||
|         self.helper_provider = OAuth2Provider.objects.create( |  | ||||||
|             name=generate_id(), |  | ||||||
|             authorization_flow=create_test_flow(), |  | ||||||
|             signing_key=self.other_cert, |  | ||||||
|         ) |  | ||||||
|         self.cert = create_test_cert() |         self.cert = create_test_cert() | ||||||
|  |  | ||||||
|         jwk = JWKSView().get_jwk_for_key(self.other_cert, "sig") |         jwk = JWKSView().get_jwk_for_key(self.cert, "sig") | ||||||
|         self.source: OAuthSource = OAuthSource.objects.create( |         self.source: OAuthSource = OAuthSource.objects.create( | ||||||
|             name=generate_id(), |             name=generate_id(), | ||||||
|             slug=generate_id(), |             slug=generate_id(), | ||||||
| @ -69,7 +62,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|             redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], |             redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], | ||||||
|             signing_key=self.cert, |             signing_key=self.cert, | ||||||
|         ) |         ) | ||||||
|         self.provider.jwt_federation_sources.add(self.source) |         self.provider.jwks_sources.add(self.source) | ||||||
|         self.provider.property_mappings.set(ScopeMapping.objects.all()) |         self.provider.property_mappings.set(ScopeMapping.objects.all()) | ||||||
|         self.app = Application.objects.create(name="test", slug="test", provider=self.provider) |         self.app = Application.objects.create(name="test", slug="test", provider=self.provider) | ||||||
|  |  | ||||||
| @ -107,7 +100,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|  |  | ||||||
|     def test_invalid_signature(self): |     def test_invalid_signature(self): | ||||||
|         """test invalid JWT""" |         """test invalid JWT""" | ||||||
|         token = self.helper_provider.encode( |         token = self.provider.encode( | ||||||
|             { |             { | ||||||
|                 "sub": "foo", |                 "sub": "foo", | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |                 "exp": datetime.now() + timedelta(hours=2), | ||||||
| @ -129,7 +122,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|  |  | ||||||
|     def test_invalid_expired(self): |     def test_invalid_expired(self): | ||||||
|         """test invalid JWT""" |         """test invalid JWT""" | ||||||
|         token = self.helper_provider.encode( |         token = self.provider.encode( | ||||||
|             { |             { | ||||||
|                 "sub": "foo", |                 "sub": "foo", | ||||||
|                 "exp": datetime.now() - timedelta(hours=2), |                 "exp": datetime.now() - timedelta(hours=2), | ||||||
| @ -153,7 +146,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|         """test invalid JWT""" |         """test invalid JWT""" | ||||||
|         self.app.provider = None |         self.app.provider = None | ||||||
|         self.app.save() |         self.app.save() | ||||||
|         token = self.helper_provider.encode( |         token = self.provider.encode( | ||||||
|             { |             { | ||||||
|                 "sub": "foo", |                 "sub": "foo", | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |                 "exp": datetime.now() + timedelta(hours=2), | ||||||
| @ -181,7 +174,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|             target=self.app, |             target=self.app, | ||||||
|             order=0, |             order=0, | ||||||
|         ) |         ) | ||||||
|         token = self.helper_provider.encode( |         token = self.provider.encode( | ||||||
|             { |             { | ||||||
|                 "sub": "foo", |                 "sub": "foo", | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |                 "exp": datetime.now() + timedelta(hours=2), | ||||||
| @ -203,7 +196,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | |||||||
|  |  | ||||||
|     def test_successful(self): |     def test_successful(self): | ||||||
|         """test successful""" |         """test successful""" | ||||||
|         token = self.helper_provider.encode( |         token = self.provider.encode( | ||||||
|             { |             { | ||||||
|                 "sub": "foo", |                 "sub": "foo", | ||||||
|                 "exp": datetime.now() + timedelta(hours=2), |                 "exp": datetime.now() + timedelta(hours=2), | ||||||
|  | |||||||
| @ -45,7 +45,7 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|         challenge = generate_id() |         challenge = generate_id() | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         # Step 1, initiate params and get redirect to flow |         # Step 1, initiate params and get redirect to flow | ||||||
|         response = self.client.get( |         self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:authorize"), |             reverse("authentik_providers_oauth2:authorize"), | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
| @ -56,10 +56,16 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|                 "code_challenge_method": "S256", |                 "code_challenge_method": "S256", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertEqual( |         self.assertJSONEqual( | ||||||
|             response.url, |             response.content.decode(), | ||||||
|             f"foo://localhost?code={code.code}&state={state}", |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_providers_oauth2:token"), |             reverse("authentik_providers_oauth2:token"), | ||||||
| @ -101,7 +107,7 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|         self.client.force_login(user) |         self.client.force_login(user) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         # Step 1, initiate params and get redirect to flow |         # Step 1, initiate params and get redirect to flow | ||||||
|         response = self.client.get( |         self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:authorize"), |             reverse("authentik_providers_oauth2:authorize"), | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
| @ -112,10 +118,16 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|                 # "code_challenge_method": "S256", |                 # "code_challenge_method": "S256", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertEqual( |         self.assertJSONEqual( | ||||||
|             response.url, |             response.content.decode(), | ||||||
|             f"foo://localhost?code={code.code}&state={state}", |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_providers_oauth2:token"), |             reverse("authentik_providers_oauth2:token"), | ||||||
| @ -162,7 +174,7 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|         ) |         ) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         # Step 1, initiate params and get redirect to flow |         # Step 1, initiate params and get redirect to flow | ||||||
|         response = self.client.get( |         self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:authorize"), |             reverse("authentik_providers_oauth2:authorize"), | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
| @ -173,10 +185,16 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|                 "code_challenge_method": "S256", |                 "code_challenge_method": "S256", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertEqual( |         self.assertJSONEqual( | ||||||
|             response.url, |             response.content.decode(), | ||||||
|             f"foo://localhost?code={code.code}&state={state}", |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_providers_oauth2:token"), |             reverse("authentik_providers_oauth2:token"), | ||||||
| @ -207,7 +225,7 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|         verifier = generate_id() |         verifier = generate_id() | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         # Step 1, initiate params and get redirect to flow |         # Step 1, initiate params and get redirect to flow | ||||||
|         response = self.client.get( |         self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:authorize"), |             reverse("authentik_providers_oauth2:authorize"), | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
| @ -217,10 +235,16 @@ class TestTokenPKCE(OAuthTestCase): | |||||||
|                 "code_challenge": verifier, |                 "code_challenge": verifier, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertEqual( |         self.assertJSONEqual( | ||||||
|             response.url, |             response.content.decode(), | ||||||
|             f"foo://localhost?code={code.code}&state={state}", |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
|             reverse("authentik_providers_oauth2:token"), |             reverse("authentik_providers_oauth2:token"), | ||||||
|  | |||||||
| @ -27,7 +27,9 @@ from authentik.flows.exceptions import FlowNonApplicableException | |||||||
| from authentik.flows.models import in_memory_stage | from authentik.flows.models import in_memory_stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||||
| from authentik.flows.stage import StageView | from authentik.flows.stage import StageView | ||||||
|  | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
| from authentik.policies.views import PolicyAccessView, RequestValidationError | from authentik.policies.views import PolicyAccessView, RequestValidationError | ||||||
| @ -452,16 +454,11 @@ class AuthorizationFlowInitView(PolicyAccessView): | |||||||
|  |  | ||||||
|         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) |         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) | ||||||
|  |  | ||||||
|         return plan.to_redirect( |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|             self.request, |         return redirect_with_qs( | ||||||
|             self.provider.authorization_flow, |             "authentik_core:if-flow", | ||||||
|             # We can only skip the flow executor and directly go to the final redirect URL if |             self.request.GET, | ||||||
|             #  we can submit the data to the RP via URL |             flow_slug=self.provider.authorization_flow.slug, | ||||||
|             allowed_silent_types=( |  | ||||||
|                 [OAuthFulfillmentStage] |  | ||||||
|                 if self.params.response_mode in [ResponseMode.QUERY, ResponseMode.FRAGMENT] |  | ||||||
|                 else [] |  | ||||||
|             ), |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ from authentik.flows.models import in_memory_stage | |||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.policies.views import PolicyAccessView | from authentik.policies.views import PolicyAccessView | ||||||
| from authentik.providers.oauth2.models import DeviceToken | from authentik.providers.oauth2.models import DeviceToken | ||||||
| from authentik.providers.oauth2.views.device_finish import ( | from authentik.providers.oauth2.views.device_finish import ( | ||||||
| @ -72,7 +73,12 @@ class CodeValidatorView(PolicyAccessView): | |||||||
|             LOGGER.warning("Flow not applicable to user") |             LOGGER.warning("Flow not applicable to user") | ||||||
|             return None |             return None | ||||||
|         plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) |         plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) | ||||||
|         return plan.to_redirect(self.request, self.token.provider.authorization_flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             request.GET, | ||||||
|  |             flow_slug=self.token.provider.authorization_flow.slug, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceEntryView(PolicyAccessView): | class DeviceEntryView(PolicyAccessView): | ||||||
| @ -103,7 +109,11 @@ class DeviceEntryView(PolicyAccessView): | |||||||
|         plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) |         plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) | ||||||
|  |  | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return plan.to_redirect(self.request, device_flow) |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             self.request.GET, | ||||||
|  |             flow_slug=device_flow.slug, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class OAuthDeviceCodeChallenge(Challenge): | class OAuthDeviceCodeChallenge(Challenge): | ||||||
| @ -127,7 +137,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse): | |||||||
|  |  | ||||||
|  |  | ||||||
| class OAuthDeviceCodeStage(ChallengeStageView): | class OAuthDeviceCodeStage(ChallengeStageView): | ||||||
|     """Flow challenge for users to enter device code""" |     """Flow challenge for users to enter device codes""" | ||||||
|  |  | ||||||
|     response_class = OAuthDeviceCodeChallengeResponse |     response_class = OAuthDeviceCodeChallengeResponse | ||||||
|  |  | ||||||
|  | |||||||
| @ -7,6 +7,8 @@ from authentik.core.models import Application | |||||||
| from authentik.flows.models import Flow, in_memory_stage | from authentik.flows.models import Flow, in_memory_stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||||
| from authentik.flows.stage import SessionEndStage | from authentik.flows.stage import SessionEndStage | ||||||
|  | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.policies.views import PolicyAccessView | from authentik.policies.views import PolicyAccessView | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -35,4 +37,9 @@ class EndSessionView(PolicyAccessView): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.insert_stage(in_memory_stage(SessionEndStage)) |         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||||
|         return plan.to_redirect(self.request, self.flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             self.request.GET, | ||||||
|  |             flow_slug=self.flow.slug, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -362,9 +362,23 @@ class TokenParams: | |||||||
|             }, |             }, | ||||||
|         ).from_http(request, user=user) |         ).from_http(request, user=user) | ||||||
|  |  | ||||||
|     def __validate_jwt_from_source( |     def __post_init_client_credentials_jwt(self, request: HttpRequest): | ||||||
|         self, assertion: str |         assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "") | ||||||
|     ) -> tuple[dict, OAuthSource] | tuple[None, None]: |         if assertion_type != CLIENT_ASSERTION_TYPE_JWT: | ||||||
|  |             LOGGER.warning("Invalid assertion type", assertion_type=assertion_type) | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |         client_secret = request.POST.get("client_secret", None) | ||||||
|  |         assertion = request.POST.get(CLIENT_ASSERTION, client_secret) | ||||||
|  |         if not assertion: | ||||||
|  |             LOGGER.warning("Missing client assertion") | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |         token = None | ||||||
|  |  | ||||||
|  |         source: OAuthSource | None = None | ||||||
|  |         parsed_key: PyJWK | None = None | ||||||
|  |  | ||||||
|         # Fully decode the JWT without verifying the signature, so we can get access to |         # Fully decode the JWT without verifying the signature, so we can get access to | ||||||
|         # the header. |         # the header. | ||||||
|         # Get the Key ID from the header, and use that to optimise our source query to only find |         # Get the Key ID from the header, and use that to optimise our source query to only find | ||||||
| @ -379,23 +393,19 @@ class TokenParams: | |||||||
|             LOGGER.warning("failed to parse JWT for kid lookup", exc=exc) |             LOGGER.warning("failed to parse JWT for kid lookup", exc=exc) | ||||||
|             raise TokenError("invalid_grant") from None |             raise TokenError("invalid_grant") from None | ||||||
|         expected_kid = decode_unvalidated["header"]["kid"] |         expected_kid = decode_unvalidated["header"]["kid"] | ||||||
|         fallback_alg = decode_unvalidated["header"]["alg"] |         for source in self.provider.jwks_sources.filter( | ||||||
|         token = source = None |  | ||||||
|         for source in self.provider.jwt_federation_sources.filter( |  | ||||||
|             oidc_jwks__keys__contains=[{"kid": expected_kid}] |             oidc_jwks__keys__contains=[{"kid": expected_kid}] | ||||||
|         ): |         ): | ||||||
|             LOGGER.debug("verifying JWT with source", source=source.slug) |             LOGGER.debug("verifying JWT with source", source=source.slug) | ||||||
|             keys = source.oidc_jwks.get("keys", []) |             keys = source.oidc_jwks.get("keys", []) | ||||||
|             for key in keys: |             for key in keys: | ||||||
|                 if key.get("kid") and key.get("kid") != expected_kid: |  | ||||||
|                     continue |  | ||||||
|                 LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid")) |                 LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid")) | ||||||
|                 try: |                 try: | ||||||
|                     parsed_key = PyJWK.from_dict(key).key |                     parsed_key = PyJWK.from_dict(key) | ||||||
|                     token = decode( |                     token = decode( | ||||||
|                         assertion, |                         assertion, | ||||||
|                         parsed_key, |                         parsed_key.key, | ||||||
|                         algorithms=[key.get("alg")] if "alg" in key else [fallback_alg], |                         algorithms=[key.get("alg")], | ||||||
|                         options={ |                         options={ | ||||||
|                             "verify_aud": False, |                             "verify_aud": False, | ||||||
|                         }, |                         }, | ||||||
| @ -404,61 +414,13 @@ class TokenParams: | |||||||
|                 # and not a public key |                 # and not a public key | ||||||
|                 except (PyJWTError, ValueError, TypeError, AttributeError) as exc: |                 except (PyJWTError, ValueError, TypeError, AttributeError) as exc: | ||||||
|                     LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug) |                     LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug) | ||||||
|         if token: |  | ||||||
|             LOGGER.info("successfully verified JWT with source", source=source.slug) |  | ||||||
|         return token, source |  | ||||||
|  |  | ||||||
|     def __validate_jwt_from_provider( |  | ||||||
|         self, assertion: str |  | ||||||
|     ) -> tuple[dict, OAuth2Provider] | tuple[None, None]: |  | ||||||
|         token = provider = _key = None |  | ||||||
|         federated_token = AccessToken.objects.filter( |  | ||||||
|             token=assertion, provider__in=self.provider.jwt_federation_providers.all() |  | ||||||
|         ).first() |  | ||||||
|         if federated_token: |  | ||||||
|             _key, _alg = federated_token.provider.jwt_key |  | ||||||
|             try: |  | ||||||
|                 token = decode( |  | ||||||
|                     assertion, |  | ||||||
|                     _key.public_key(), |  | ||||||
|                     algorithms=[_alg], |  | ||||||
|                     options={ |  | ||||||
|                         "verify_aud": False, |  | ||||||
|                     }, |  | ||||||
|                 ) |  | ||||||
|                 provider = federated_token.provider |  | ||||||
|                 self.user = federated_token.user |  | ||||||
|             except (PyJWTError, ValueError, TypeError, AttributeError) as exc: |  | ||||||
|                 LOGGER.warning( |  | ||||||
|                     "failed to verify JWT", exc=exc, provider=federated_token.provider.name |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|         if token: |  | ||||||
|             LOGGER.info("successfully verified JWT with provider", provider=provider.name) |  | ||||||
|         return token, provider |  | ||||||
|  |  | ||||||
|     def __post_init_client_credentials_jwt(self, request: HttpRequest): |  | ||||||
|         assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "") |  | ||||||
|         if assertion_type != CLIENT_ASSERTION_TYPE_JWT: |  | ||||||
|             LOGGER.warning("Invalid assertion type", assertion_type=assertion_type) |  | ||||||
|             raise TokenError("invalid_grant") |  | ||||||
|  |  | ||||||
|         client_secret = request.POST.get("client_secret", None) |  | ||||||
|         assertion = request.POST.get(CLIENT_ASSERTION, client_secret) |  | ||||||
|         if not assertion: |  | ||||||
|             LOGGER.warning("Missing client assertion") |  | ||||||
|             raise TokenError("invalid_grant") |  | ||||||
|  |  | ||||||
|         source = provider = None |  | ||||||
|  |  | ||||||
|         token, source = self.__validate_jwt_from_source(assertion) |  | ||||||
|         if not token: |  | ||||||
|             token, provider = self.__validate_jwt_from_provider(assertion) |  | ||||||
|  |  | ||||||
|         if not token: |         if not token: | ||||||
|             LOGGER.warning("No token could be verified") |             LOGGER.warning("No token could be verified") | ||||||
|             raise TokenError("invalid_grant") |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |         LOGGER.info("successfully verified JWT with source", source=source.slug) | ||||||
|  |  | ||||||
|         if "exp" in token: |         if "exp" in token: | ||||||
|             exp = datetime.fromtimestamp(token["exp"]) |             exp = datetime.fromtimestamp(token["exp"]) | ||||||
|             # Non-timezone aware check since we assume `exp` is in UTC |             # Non-timezone aware check since we assume `exp` is in UTC | ||||||
| @ -472,16 +434,15 @@ class TokenParams: | |||||||
|             raise TokenError("invalid_grant") |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|         self.__check_policy_access(app, request, oauth_jwt=token) |         self.__check_policy_access(app, request, oauth_jwt=token) | ||||||
|         if not provider: |         self.__create_user_from_jwt(token, app, source) | ||||||
|             self.__create_user_from_jwt(token, app, source) |  | ||||||
|  |  | ||||||
|         method_args = { |         method_args = { | ||||||
|             "jwt": token, |             "jwt": token, | ||||||
|         } |         } | ||||||
|         if source: |         if source: | ||||||
|             method_args["source"] = source |             method_args["source"] = source | ||||||
|         if provider: |         if parsed_key: | ||||||
|             method_args["provider"] = provider |             method_args["jwk_id"] = parsed_key.key_id | ||||||
|         Event.new( |         Event.new( | ||||||
|             action=EventAction.LOGIN, |             action=EventAction.LOGIN, | ||||||
|             **{ |             **{ | ||||||
|  | |||||||
| @ -94,8 +94,7 @@ class ProxyProviderSerializer(ProviderSerializer): | |||||||
|             "intercept_header_auth", |             "intercept_header_auth", | ||||||
|             "redirect_uris", |             "redirect_uris", | ||||||
|             "cookie_domain", |             "cookie_domain", | ||||||
|             "jwt_federation_sources", |             "jwks_sources", | ||||||
|             "jwt_federation_providers", |  | ||||||
|             "access_token_validity", |             "access_token_validity", | ||||||
|             "refresh_token_validity", |             "refresh_token_validity", | ||||||
|             "outpost_set", |             "outpost_set", | ||||||
|  | |||||||
| @ -127,7 +127,6 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware] | |||||||
|                     authResponseHeaders=[ |                     authResponseHeaders=[ | ||||||
|                         "X-authentik-username", |                         "X-authentik-username", | ||||||
|                         "X-authentik-groups", |                         "X-authentik-groups", | ||||||
|                         "X-authentik-entitlements", |  | ||||||
|                         "X-authentik-email", |                         "X-authentik-email", | ||||||
|                         "X-authentik-name", |                         "X-authentik-name", | ||||||
|                         "X-authentik-uid", |                         "X-authentik-uid", | ||||||
|  | |||||||
| @ -147,7 +147,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | |||||||
|                 "goauthentik.io/providers/oauth2/scope-openid", |                 "goauthentik.io/providers/oauth2/scope-openid", | ||||||
|                 "goauthentik.io/providers/oauth2/scope-profile", |                 "goauthentik.io/providers/oauth2/scope-profile", | ||||||
|                 "goauthentik.io/providers/oauth2/scope-email", |                 "goauthentik.io/providers/oauth2/scope-email", | ||||||
|                 "goauthentik.io/providers/oauth2/scope-entitlements", |  | ||||||
|                 "goauthentik.io/providers/proxy/scope-proxy", |                 "goauthentik.io/providers/proxy/scope-proxy", | ||||||
|             ] |             ] | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -13,6 +13,8 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.flows.models import Flow, in_memory_stage | from authentik.flows.models import Flow, in_memory_stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||||
| from authentik.flows.stage import SessionEndStage | from authentik.flows.stage import SessionEndStage | ||||||
|  | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
| from authentik.policies.views import PolicyAccessView | from authentik.policies.views import PolicyAccessView | ||||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||||
| @ -62,7 +64,12 @@ class SAMLSLOView(PolicyAccessView): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.insert_stage(in_memory_stage(SessionEndStage)) |         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||||
|         return plan.to_redirect(self.request, self.flow) |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_core:if-flow", | ||||||
|  |             self.request.GET, | ||||||
|  |             flow_slug=self.flow.slug, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: |     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||||
|         """GET and POST use the same handler, but we can't |         """GET and POST use the same handler, but we can't | ||||||
|  | |||||||
| @ -13,11 +13,12 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.models import in_memory_stage | from authentik.flows.models import in_memory_stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||||
| from authentik.flows.views.executor import SESSION_KEY_POST | from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
| from authentik.policies.views import PolicyAccessView | from authentik.policies.views import PolicyAccessView | ||||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||||
| from authentik.providers.saml.models import SAMLBindings, SAMLProvider | from authentik.providers.saml.models import SAMLProvider | ||||||
| from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser | from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser | ||||||
| from authentik.providers.saml.views.flows import ( | from authentik.providers.saml.views.flows import ( | ||||||
|     REQUEST_KEY_RELAY_STATE, |     REQUEST_KEY_RELAY_STATE, | ||||||
| @ -73,12 +74,11 @@ class SAMLSSOView(PolicyAccessView): | |||||||
|         except FlowNonApplicableException: |         except FlowNonApplicableException: | ||||||
|             raise Http404 from None |             raise Http404 from None | ||||||
|         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) |         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) | ||||||
|         return plan.to_redirect( |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|             request, |         return redirect_with_qs( | ||||||
|             self.provider.authorization_flow, |             "authentik_core:if-flow", | ||||||
|             allowed_silent_types=( |             request.GET, | ||||||
|                 [SAMLFlowFinalView] if self.provider.sp_binding in [SAMLBindings.REDIRECT] else [] |             flow_slug=self.provider.authorization_flow.slug, | ||||||
|             ), |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: |     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ from sentry_sdk import set_tag | |||||||
| from xmlsec import enable_debug_trace | from xmlsec import enable_debug_trace | ||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.lib.config import CONFIG, django_db_config, redis_url | from authentik.lib.config import CONFIG, redis_url | ||||||
| from authentik.lib.logging import get_logger_config, structlog_configure | from authentik.lib.logging import get_logger_config, structlog_configure | ||||||
| from authentik.lib.sentry import sentry_init | from authentik.lib.sentry import sentry_init | ||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| @ -32,8 +32,6 @@ LOGIN_URL = "authentik_flows:default-authentication" | |||||||
| # Custom user model | # Custom user model | ||||||
| AUTH_USER_MODEL = "authentik_core.User" | AUTH_USER_MODEL = "authentik_core.User" | ||||||
|  |  | ||||||
| CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = CONFIG.get("web.path", "/") |  | ||||||
|  |  | ||||||
| CSRF_COOKIE_NAME = "authentik_csrf" | CSRF_COOKIE_NAME = "authentik_csrf" | ||||||
| CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" | CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" | ||||||
| LANGUAGE_COOKIE_NAME = "authentik_language" | LANGUAGE_COOKIE_NAME = "authentik_language" | ||||||
| @ -114,7 +112,6 @@ TENANT_APPS = [ | |||||||
|     "authentik.stages.invitation", |     "authentik.stages.invitation", | ||||||
|     "authentik.stages.password", |     "authentik.stages.password", | ||||||
|     "authentik.stages.prompt", |     "authentik.stages.prompt", | ||||||
|     "authentik.stages.redirect", |  | ||||||
|     "authentik.stages.user_delete", |     "authentik.stages.user_delete", | ||||||
|     "authentik.stages.user_login", |     "authentik.stages.user_login", | ||||||
|     "authentik.stages.user_logout", |     "authentik.stages.user_logout", | ||||||
| @ -298,7 +295,47 @@ CHANNEL_LAYERS = { | |||||||
| # https://docs.djangoproject.com/en/2.1/ref/settings/#databases | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases | ||||||
|  |  | ||||||
| ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql" | ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql" | ||||||
| DATABASES = django_db_config() | DATABASES = { | ||||||
|  |     "default": { | ||||||
|  |         "ENGINE": "authentik.root.db", | ||||||
|  |         "HOST": CONFIG.get("postgresql.host"), | ||||||
|  |         "NAME": CONFIG.get("postgresql.name"), | ||||||
|  |         "USER": CONFIG.get("postgresql.user"), | ||||||
|  |         "PASSWORD": CONFIG.get("postgresql.password"), | ||||||
|  |         "PORT": CONFIG.get("postgresql.port"), | ||||||
|  |         "OPTIONS": { | ||||||
|  |             "sslmode": CONFIG.get("postgresql.sslmode"), | ||||||
|  |             "sslrootcert": CONFIG.get("postgresql.sslrootcert"), | ||||||
|  |             "sslcert": CONFIG.get("postgresql.sslcert"), | ||||||
|  |             "sslkey": CONFIG.get("postgresql.sslkey"), | ||||||
|  |         }, | ||||||
|  |         "TEST": { | ||||||
|  |             "NAME": CONFIG.get("postgresql.test.name"), | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if CONFIG.get_bool("postgresql.use_pgpool", False): | ||||||
|  |     DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||||
|  |  | ||||||
|  | if CONFIG.get_bool("postgresql.use_pgbouncer", False): | ||||||
|  |     # https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors | ||||||
|  |     DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||||
|  |     # https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections | ||||||
|  |     DATABASES["default"]["CONN_MAX_AGE"] = None  # persistent | ||||||
|  |  | ||||||
|  | for replica in CONFIG.get_keys("postgresql.read_replicas"): | ||||||
|  |     _database = DATABASES["default"].copy() | ||||||
|  |     for setting in DATABASES["default"].keys(): | ||||||
|  |         default = object() | ||||||
|  |         if setting in ("TEST",): | ||||||
|  |             continue | ||||||
|  |         override = CONFIG.get( | ||||||
|  |             f"postgresql.read_replicas.{replica}.{setting.lower()}", default=default | ||||||
|  |         ) | ||||||
|  |         if override is not default: | ||||||
|  |             _database[setting] = override | ||||||
|  |     DATABASES[f"replica_{replica}"] = _database | ||||||
|  |  | ||||||
| DATABASE_ROUTERS = ( | DATABASE_ROUTERS = ( | ||||||
|     "authentik.tenants.db.FailoverRouter", |     "authentik.tenants.db.FailoverRouter", | ||||||
| @ -389,7 +426,7 @@ if _ERROR_REPORTING: | |||||||
| # https://docs.djangoproject.com/en/2.1/howto/static-files/ | # https://docs.djangoproject.com/en/2.1/howto/static-files/ | ||||||
|  |  | ||||||
| STATICFILES_DIRS = [BASE_DIR / Path("web")] | STATICFILES_DIRS = [BASE_DIR / Path("web")] | ||||||
| STATIC_URL = CONFIG.get("web.path", "/") + "static/" | STATIC_URL = "/static/" | ||||||
|  |  | ||||||
| STORAGES = { | STORAGES = { | ||||||
|     "staticfiles": { |     "staticfiles": { | ||||||
|  | |||||||
| @ -4,7 +4,6 @@ from django.urls import include, path | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.views import error | from authentik.core.views import error | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.lib.utils.reflection import get_apps | from authentik.lib.utils.reflection import get_apps | ||||||
| from authentik.root.monitoring import LiveView, MetricsView, ReadyView | from authentik.root.monitoring import LiveView, MetricsView, ReadyView | ||||||
|  |  | ||||||
| @ -15,7 +14,7 @@ handler403 = error.ForbiddenView.as_view() | |||||||
| handler404 = error.NotFoundView.as_view() | handler404 = error.NotFoundView.as_view() | ||||||
| handler500 = error.ServerErrorView.as_view() | handler500 = error.ServerErrorView.as_view() | ||||||
|  |  | ||||||
| _urlpatterns = [] | urlpatterns = [] | ||||||
|  |  | ||||||
| for _authentik_app in get_apps(): | for _authentik_app in get_apps(): | ||||||
|     mountpoints = None |     mountpoints = None | ||||||
| @ -36,7 +35,7 @@ for _authentik_app in get_apps(): | |||||||
|                 namespace=namespace, |                 namespace=namespace, | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         _urlpatterns.append(_path) |         urlpatterns.append(_path) | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "Mounted URLs", |             "Mounted URLs", | ||||||
|             app_name=_authentik_app.name, |             app_name=_authentik_app.name, | ||||||
| @ -44,10 +43,8 @@ for _authentik_app in get_apps(): | |||||||
|             namespace=namespace, |             namespace=namespace, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| _urlpatterns += [ | urlpatterns += [ | ||||||
|     path("-/metrics/", MetricsView.as_view(), name="metrics"), |     path("-/metrics/", MetricsView.as_view(), name="metrics"), | ||||||
|     path("-/health/live/", LiveView.as_view(), name="health-live"), |     path("-/health/live/", LiveView.as_view(), name="health-live"), | ||||||
|     path("-/health/ready/", ReadyView.as_view(), name="health-ready"), |     path("-/health/ready/", ReadyView.as_view(), name="health-ready"), | ||||||
| ] | ] | ||||||
|  |  | ||||||
| urlpatterns = [path(CONFIG.get("web.path", "/")[1:], include(_urlpatterns))] |  | ||||||
|  | |||||||
| @ -2,16 +2,13 @@ | |||||||
|  |  | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from channels.routing import URLRouter |  | ||||||
| from django.urls import path |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.lib.utils.reflection import get_apps | from authentik.lib.utils.reflection import get_apps | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| _websocket_urlpatterns = [] | websocket_urlpatterns = [] | ||||||
| for _authentik_app in get_apps(): | for _authentik_app in get_apps(): | ||||||
|     try: |     try: | ||||||
|         api_urls = import_module(f"{_authentik_app.name}.urls") |         api_urls = import_module(f"{_authentik_app.name}.urls") | ||||||
| @ -20,15 +17,8 @@ for _authentik_app in get_apps(): | |||||||
|     if not hasattr(api_urls, "websocket_urlpatterns"): |     if not hasattr(api_urls, "websocket_urlpatterns"): | ||||||
|         continue |         continue | ||||||
|     urls: list = api_urls.websocket_urlpatterns |     urls: list = api_urls.websocket_urlpatterns | ||||||
|     _websocket_urlpatterns.extend(urls) |     websocket_urlpatterns.extend(urls) | ||||||
|     LOGGER.debug( |     LOGGER.debug( | ||||||
|         "Mounted Websocket URLs", |         "Mounted Websocket URLs", | ||||||
|         app_name=_authentik_app.name, |         app_name=_authentik_app.name, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| websocket_urlpatterns = [ |  | ||||||
|     path( |  | ||||||
|         CONFIG.get("web.path", "/")[1:], |  | ||||||
|         URLRouter(_websocket_urlpatterns), |  | ||||||
|     ), |  | ||||||
| ] |  | ||||||
|  | |||||||
| @ -32,7 +32,6 @@ class KerberosSourceSerializer(SourceSerializer): | |||||||
|             "group_matching_mode", |             "group_matching_mode", | ||||||
|             "realm", |             "realm", | ||||||
|             "krb5_conf", |             "krb5_conf", | ||||||
|             "kadmin_type", |  | ||||||
|             "sync_users", |             "sync_users", | ||||||
|             "sync_users_password", |             "sync_users_password", | ||||||
|             "sync_principal", |             "sync_principal", | ||||||
| @ -70,7 +69,6 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "slug", |         "slug", | ||||||
|         "enabled", |         "enabled", | ||||||
|         "realm", |         "realm", | ||||||
|         "kadmin_type", |  | ||||||
|         "sync_users", |         "sync_users", | ||||||
|         "sync_users_password", |         "sync_users_password", | ||||||
|         "sync_principal", |         "sync_principal", | ||||||
|  | |||||||
| @ -1,22 +0,0 @@ | |||||||
| # Generated by Django 5.0.10 on 2024-12-06 19:24 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_sources_kerberos", "0001_initial"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="kerberossource", |  | ||||||
|             name="kadmin_type", |  | ||||||
|             field=models.TextField( |  | ||||||
|                 choices=[("MIT", "Mit"), ("Heimdal", "Heimdal"), ("other", "Other")], |  | ||||||
|                 default="other", |  | ||||||
|                 help_text="KAdmin server type", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -6,6 +6,7 @@ from tempfile import gettempdir | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| import gssapi | import gssapi | ||||||
|  | import kadmin | ||||||
| import pglock | import pglock | ||||||
| from django.db import connection, models | from django.db import connection, models | ||||||
| from django.db.models.fields import b64decode | from django.db.models.fields import b64decode | ||||||
| @ -13,8 +14,6 @@ from django.http import HttpRequest | |||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| 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 kadmin import KAdmin, KAdminApiVersion |  | ||||||
| from kadmin.exceptions import PyKAdminException |  | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -31,17 +30,12 @@ from authentik.flows.challenge import RedirectChallenge | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| # Creating kadmin connections is expensive. As such, this global is used to reuse | # python-kadmin leaks file descriptors. As such, this global is used to reuse | ||||||
| # existing kadmin connections instead of creating new ones | # existing kadmin connections instead of creating new ones, which results in less to no file | ||||||
|  | # descriptors leaks | ||||||
| _kadmin_connections: dict[str, Any] = {} | _kadmin_connections: dict[str, Any] = {} | ||||||
|  |  | ||||||
|  |  | ||||||
| class KAdminType(models.TextChoices): |  | ||||||
|     MIT = "MIT" |  | ||||||
|     HEIMDAL = "Heimdal" |  | ||||||
|     OTHER = "other" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class KerberosSource(Source): | class KerberosSource(Source): | ||||||
|     """Federate Kerberos realm with authentik""" |     """Federate Kerberos realm with authentik""" | ||||||
|  |  | ||||||
| @ -50,9 +44,6 @@ class KerberosSource(Source): | |||||||
|         blank=True, |         blank=True, | ||||||
|         help_text=_("Custom krb5.conf to use. Uses the system one by default"), |         help_text=_("Custom krb5.conf to use. Uses the system one by default"), | ||||||
|     ) |     ) | ||||||
|     kadmin_type = models.TextField( |  | ||||||
|         choices=KAdminType.choices, default=KAdminType.OTHER, help_text=_("KAdmin server type") |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     sync_users = models.BooleanField( |     sync_users = models.BooleanField( | ||||||
|         default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True |         default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True | ||||||
| @ -207,24 +198,15 @@ class KerberosSource(Source): | |||||||
|         conf_path.write_text(self.krb5_conf) |         conf_path.write_text(self.krb5_conf) | ||||||
|         return str(conf_path) |         return str(conf_path) | ||||||
|  |  | ||||||
|     def _kadmin_init(self) -> KAdmin | None: |     def _kadmin_init(self) -> "kadmin.KAdmin | None": | ||||||
|         api_version = None |  | ||||||
|         match self.kadmin_type: |  | ||||||
|             case KAdminType.MIT: |  | ||||||
|                 api_version = KAdminApiVersion.Version4 |  | ||||||
|             case KAdminType.HEIMDAL: |  | ||||||
|                 api_version = KAdminApiVersion.Version2 |  | ||||||
|             case KAdminType.OTHER: |  | ||||||
|                 api_version = KAdminApiVersion.Version2 |  | ||||||
|         # kadmin doesn't use a ccache for its connection |         # kadmin doesn't use a ccache for its connection | ||||||
|         # as such, we don't need to create a separate ccache for each source |         # as such, we don't need to create a separate ccache for each source | ||||||
|         if not self.sync_principal: |         if not self.sync_principal: | ||||||
|             return None |             return None | ||||||
|         if self.sync_password: |         if self.sync_password: | ||||||
|             return KAdmin.with_password( |             return kadmin.init_with_password( | ||||||
|                 self.sync_principal, |                 self.sync_principal, | ||||||
|                 self.sync_password, |                 self.sync_password, | ||||||
|                 api_version=api_version, |  | ||||||
|             ) |             ) | ||||||
|         if self.sync_keytab: |         if self.sync_keytab: | ||||||
|             keytab = self.sync_keytab |             keytab = self.sync_keytab | ||||||
| @ -233,20 +215,18 @@ class KerberosSource(Source): | |||||||
|                 keytab_path.touch(mode=0o600) |                 keytab_path.touch(mode=0o600) | ||||||
|                 keytab_path.write_bytes(b64decode(self.sync_keytab)) |                 keytab_path.write_bytes(b64decode(self.sync_keytab)) | ||||||
|                 keytab = f"FILE:{keytab_path}" |                 keytab = f"FILE:{keytab_path}" | ||||||
|             return KAdmin.with_keytab( |             return kadmin.init_with_keytab( | ||||||
|                 self.sync_principal, |                 self.sync_principal, | ||||||
|                 keytab, |                 keytab, | ||||||
|                 api_version=api_version, |  | ||||||
|             ) |             ) | ||||||
|         if self.sync_ccache: |         if self.sync_ccache: | ||||||
|             return KAdmin.with_ccache( |             return kadmin.init_with_ccache( | ||||||
|                 self.sync_principal, |                 self.sync_principal, | ||||||
|                 self.sync_ccache, |                 self.sync_ccache, | ||||||
|                 api_version=api_version, |  | ||||||
|             ) |             ) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def connection(self) -> KAdmin | None: |     def connection(self) -> "kadmin.KAdmin | None": | ||||||
|         """Get kadmin connection""" |         """Get kadmin connection""" | ||||||
|         if str(self.pk) not in _kadmin_connections: |         if str(self.pk) not in _kadmin_connections: | ||||||
|             kadm = self._kadmin_init() |             kadm = self._kadmin_init() | ||||||
| @ -266,7 +246,7 @@ class KerberosSource(Source): | |||||||
|                     status["status"] = "no connection" |                     status["status"] = "no connection" | ||||||
|                     return status |                     return status | ||||||
|                 status["principal_exists"] = kadm.principal_exists(self.sync_principal) |                 status["principal_exists"] = kadm.principal_exists(self.sync_principal) | ||||||
|             except PyKAdminException as exc: |             except kadmin.KAdminError as exc: | ||||||
|                 status["status"] = str(exc) |                 status["status"] = str(exc) | ||||||
|         return status |         return status | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| """authentik kerberos source signals""" | """authentik kerberos source signals""" | ||||||
|  |  | ||||||
|  | import kadmin | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from kadmin.exceptions import PyKAdminException |  | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -45,12 +45,10 @@ def kerberos_sync_password(sender, user: User, password: str, **_): | |||||||
|             continue |             continue | ||||||
|         with Krb5ConfContext(source): |         with Krb5ConfContext(source): | ||||||
|             try: |             try: | ||||||
|                 kadm = source.connection() |                 source.connection().getprinc(user_source_connection.identifier).change_password( | ||||||
|                 kadm.get_principal(user_source_connection.identifier).change_password( |                     password | ||||||
|                     kadm, |  | ||||||
|                     password, |  | ||||||
|                 ) |                 ) | ||||||
|             except PyKAdminException as exc: |             except kadmin.KAdminError as exc: | ||||||
|                 LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) |                 LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) | ||||||
|                 Event.new( |                 Event.new( | ||||||
|                     EventAction.CONFIGURATION_ERROR, |                     EventAction.CONFIGURATION_ERROR, | ||||||
|  | |||||||
| @ -2,9 +2,9 @@ | |||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
|  | import kadmin | ||||||
| from django.core.exceptions import FieldError | from django.core.exceptions import FieldError | ||||||
| from django.db import IntegrityError, transaction | from django.db import IntegrityError, transaction | ||||||
| from kadmin import KAdmin |  | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.expression.exceptions import ( | from authentik.core.expression.exceptions import ( | ||||||
| @ -30,7 +30,7 @@ class KerberosSync: | |||||||
|  |  | ||||||
|     _source: KerberosSource |     _source: KerberosSource | ||||||
|     _logger: BoundLogger |     _logger: BoundLogger | ||||||
|     _connection: KAdmin |     _connection: "kadmin.KAdmin" | ||||||
|     mapper: SourceMapper |     mapper: SourceMapper | ||||||
|     user_manager: PropertyMappingManager |     user_manager: PropertyMappingManager | ||||||
|     group_manager: PropertyMappingManager |     group_manager: PropertyMappingManager | ||||||
| @ -43,10 +43,8 @@ class KerberosSync: | |||||||
|         self._messages = [] |         self._messages = [] | ||||||
|         self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) |         self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) | ||||||
|         self.mapper = SourceMapper(self._source) |         self.mapper = SourceMapper(self._source) | ||||||
|         self.user_manager = self.mapper.get_manager(User, ["principal", "principal_obj"]) |         self.user_manager = self.mapper.get_manager(User, ["principal"]) | ||||||
|         self.group_manager = self.mapper.get_manager( |         self.group_manager = self.mapper.get_manager(Group, ["group_id", "principal"]) | ||||||
|             Group, ["group_id", "principal", "principal_obj"] |  | ||||||
|         ) |  | ||||||
|         self.matcher = SourceMatcher( |         self.matcher = SourceMatcher( | ||||||
|             self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection |             self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection | ||||||
|         ) |         ) | ||||||
| @ -69,16 +67,12 @@ class KerberosSync: | |||||||
|  |  | ||||||
|     def _handle_principal(self, principal: str) -> bool: |     def _handle_principal(self, principal: str) -> bool: | ||||||
|         try: |         try: | ||||||
|             # TODO: handle permission error |  | ||||||
|             principal_obj = self._connection.get_principal(principal) |  | ||||||
|  |  | ||||||
|             defaults = self.mapper.build_object_properties( |             defaults = self.mapper.build_object_properties( | ||||||
|                 object_type=User, |                 object_type=User, | ||||||
|                 manager=self.user_manager, |                 manager=self.user_manager, | ||||||
|                 user=None, |                 user=None, | ||||||
|                 request=None, |                 request=None, | ||||||
|                 principal=principal, |                 principal=principal, | ||||||
|                 principal_obj=principal_obj, |  | ||||||
|             ) |             ) | ||||||
|             self._logger.debug("Writing user with attributes", **defaults) |             self._logger.debug("Writing user with attributes", **defaults) | ||||||
|             if "username" not in defaults: |             if "username" not in defaults: | ||||||
| @ -97,7 +91,6 @@ class KerberosSync: | |||||||
|                     request=None, |                     request=None, | ||||||
|                     group_id=group_id, |                     group_id=group_id, | ||||||
|                     principal=principal, |                     principal=principal, | ||||||
|                     principal_obj=principal_obj, |  | ||||||
|                 ) |                 ) | ||||||
|                 for group_id in defaults.pop("groups", []) |                 for group_id in defaults.pop("groups", []) | ||||||
|             } |             } | ||||||
| @ -168,7 +161,7 @@ class KerberosSync: | |||||||
|  |  | ||||||
|         user_count = 0 |         user_count = 0 | ||||||
|         with Krb5ConfContext(self._source): |         with Krb5ConfContext(self._source): | ||||||
|             for principal in self._connection.list_principals(None): |             for principal in self._connection.principals(): | ||||||
|                 if self._handle_principal(principal): |                 if self._handle_principal(principal): | ||||||
|                     user_count += 1 |                     user_count += 1 | ||||||
|         return user_count |         return user_count | ||||||
|  | |||||||
| @ -23,7 +23,6 @@ class TestKerberosAuth(KerberosTestCase): | |||||||
|         ) |         ) | ||||||
|         self.user = User.objects.create(username=generate_id()) |         self.user = User.objects.create(username=generate_id()) | ||||||
|         self.user.set_unusable_password() |         self.user.set_unusable_password() | ||||||
|         self.user.save() |  | ||||||
|         UserKerberosSourceConnection.objects.create( |         UserKerberosSourceConnection.objects.create( | ||||||
|             source=self.source, user=self.user, identifier=self.realm.user_princ |             source=self.source, user=self.user, identifier=self.realm.user_princ | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -2,8 +2,6 @@ | |||||||
|  |  | ||||||
| from base64 import b64decode, b64encode | from base64 import b64decode, b64encode | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from sys import platform |  | ||||||
| from unittest import skipUnless |  | ||||||
|  |  | ||||||
| import gssapi | import gssapi | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| @ -38,7 +36,6 @@ class TestSPNEGOSource(KerberosTestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     @skipUnless(platform.startswith("linux"), "Requires compatible GSSAPI implementation") |  | ||||||
|     def test_source_login(self): |     def test_source_login(self): | ||||||
|         """test login view""" |         """test login view""" | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	