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] | ||||
| current_version = 2024.12.0-rc1 | ||||
| current_version = 2024.10.5 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||
| @ -30,5 +30,3 @@ optional_value = final | ||||
| [bumpversion:file:internal/constants/constants.go] | ||||
|  | ||||
| [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" | ||||
|  | ||||
| outputs: | ||||
|   shouldPush: | ||||
|     description: "Whether to push the image or not" | ||||
|     value: ${{ steps.ev.outputs.shouldPush }} | ||||
|   shouldBuild: | ||||
|     description: "Whether to build image or not" | ||||
|     value: ${{ steps.ev.outputs.shouldBuild }} | ||||
|  | ||||
|   sha: | ||||
|     description: "sha" | ||||
|  | ||||
| @ -7,14 +7,7 @@ from time import time | ||||
| parser = configparser.ConfigParser() | ||||
| parser.read(".bumpversion.cfg") | ||||
|  | ||||
| # Decide if we should push the image or not | ||||
| 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 | ||||
| should_build = str(len(os.environ.get("DOCKER_USERNAME", "")) > 0).lower() | ||||
|  | ||||
| branch_name = os.environ["GITHUB_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: | ||||
|     print(f"shouldPush={str(should_push).lower()}", file=_output) | ||||
|     print(f"shouldBuild={should_build}", file=_output) | ||||
|     print(f"sha={sha}", file=_output) | ||||
|     print(f"version={version}", 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: | | ||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         poetry install --sync | ||||
|         poetry install | ||||
|         cd web && npm ci | ||||
|     - name: Generate config | ||||
|       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: | ||||
| jobs: | ||||
|   build: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       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: | ||||
| jobs: | ||||
|   build: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - 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 coverage xml | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         uses: codecov/codecov-action@v4 | ||||
|         with: | ||||
|           flags: unit | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
| @ -134,13 +134,13 @@ jobs: | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Create k8s Kind Cluster | ||||
|         uses: helm/kind-action@v1.11.0 | ||||
|         uses: helm/kind-action@v1.10.0 | ||||
|       - name: run integration | ||||
|         run: | | ||||
|           poetry run coverage run manage.py test tests/integration | ||||
|           poetry run coverage xml | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         uses: codecov/codecov-action@v4 | ||||
|         with: | ||||
|           flags: integration | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
| @ -198,7 +198,7 @@ jobs: | ||||
|           poetry run coverage run manage.py test ${{ matrix.job.glob }} | ||||
|           poetry run coverage xml | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         uses: codecov/codecov-action@v4 | ||||
|         with: | ||||
|           flags: e2e | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
| @ -209,7 +209,6 @@ jobs: | ||||
|           file: unittest.xml | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|   ci-core-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
|       - test-migrations | ||||
| @ -219,9 +218,7 @@ jobs: | ||||
|       - test-e2e | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|       - run: echo mark | ||||
|   build: | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
| @ -255,7 +252,7 @@ jobs: | ||||
|           image-name: ghcr.io/goauthentik/dev-server | ||||
|           image-arch: ${{ matrix.arch }} | ||||
|       - name: Login to Container Registry | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
| @ -272,15 +269,15 @@ jobs: | ||||
|             GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }} | ||||
|             GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }} | ||||
|           tags: ${{ steps.ev.outputs.imageTags }} | ||||
|           push: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|           build-args: | | ||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||
|           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 }} | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|         id: attest | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
|           subject-digest: ${{ steps.push.outputs.digest }} | ||||
| @ -306,7 +303,7 @@ jobs: | ||||
|         with: | ||||
|           image-name: ghcr.io/goauthentik/dev-server | ||||
|       - name: Comment on PR | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|         uses: ./.github/actions/comment-pr-instructions | ||||
|         with: | ||||
|           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: | | ||||
|           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | ||||
|   ci-outpost-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint-golint | ||||
|       - test-unittest | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|       - run: echo mark | ||||
|   build-container: | ||||
|     timeout-minutes: 120 | ||||
|     needs: | ||||
| @ -93,7 +90,7 @@ jobs: | ||||
|         with: | ||||
|           image-name: ghcr.io/goauthentik/dev-${{ matrix.type }} | ||||
|       - name: Login to Container Registry | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
| @ -107,16 +104,16 @@ jobs: | ||||
|         with: | ||||
|           tags: ${{ steps.ev.outputs.imageTags }} | ||||
|           file: ${{ matrix.type }}.Dockerfile | ||||
|           push: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|           push: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|           build-args: | | ||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
|           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) || '' }} | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|           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@v1 | ||||
|         id: attest | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
|           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/ | ||||
|         run: npm run build | ||||
|   ci-web-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - build | ||||
|       - lint | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|       - run: echo mark | ||||
|   test: | ||||
|     needs: | ||||
|       - 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/ | ||||
|         run: npm run ${{ matrix.job }} | ||||
|   ci-website-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
|       - test | ||||
|       - build | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|       - run: echo mark | ||||
|  | ||||
| @ -11,7 +11,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,6 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   clean-ghcr: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     name: Delete old unused container images | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							| @ -12,7 +12,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   publish-source-docs: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 120 | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   update-next: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     environment: internal-production | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										25
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -55,7 +55,7 @@ jobs: | ||||
|             VERSION=${{ github.ref }} | ||||
|           tags: ${{ steps.ev.outputs.imageTags }} | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|         id: attest | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
| @ -119,7 +119,7 @@ jobs: | ||||
|           file: ${{ matrix.type }}.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|         id: attest | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
| @ -169,27 +169,6 @@ jobs: | ||||
|           file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} | ||||
|           asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} | ||||
|           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: | ||||
|     needs: | ||||
|       - 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 | ||||
|           docker buildx install | ||||
|           mkdir -p ./gen-ts-api | ||||
|           docker build -t testing:latest . | ||||
|           docker build --no-cache -t testing:latest . | ||||
|           echo "AUTHENTIK_IMAGE=testing" >> .env | ||||
|           echo "AUTHENTIK_TAG=latest" >> .env | ||||
|           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: | ||||
|   stale: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|  | ||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -33,8 +33,7 @@ | ||||
|         "!If sequence", | ||||
|         "!Index scalar", | ||||
|         "!KeyOf scalar", | ||||
|         "!Value scalar", | ||||
|         "!AtIndex scalar" | ||||
|         "!Value scalar" | ||||
|     ], | ||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||
|  | ||||
							
								
								
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							| @ -19,18 +19,10 @@ Dockerfile                      @goauthentik/infrastructure | ||||
| *Dockerfile                     @goauthentik/infrastructure | ||||
| .dockerignore                   @goauthentik/infrastructure | ||||
| docker-compose.yml              @goauthentik/infrastructure | ||||
| Makefile                        @goauthentik/infrastructure | ||||
| .editorconfig                   @goauthentik/infrastructure | ||||
| CODEOWNERS                      @goauthentik/infrastructure | ||||
| # Web | ||||
| web/                            @goauthentik/frontend | ||||
| tests/wdio/                     @goauthentik/frontend | ||||
| # Locale | ||||
| locale/                         @goauthentik/backend @goauthentik/frontend | ||||
| web/xliff/                      @goauthentik/backend @goauthentik/frontend | ||||
| # Docs & Website | ||||
| website/                        @goauthentik/docs | ||||
| CODE_OF_CONDUCT.md              @goauthentik/docs | ||||
| # Security | ||||
| SECURITY.md                     @goauthentik/security @goauthentik/docs | ||||
| website/docs/security/          @goauthentik/security @goauthentik/docs | ||||
| website/docs/security/          @goauthentik/security | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| # Stage 4: MaxMind GeoIP | ||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip | ||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 AS geoip | ||||
|  | ||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ||||
| ENV GEOIPUPDATE_VERBOSE="1" | ||||
|  | ||||
							
								
								
									
										5
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Makefile
									
									
									
									
									
								
							| @ -5,7 +5,7 @@ PWD = $(shell pwd) | ||||
| UID = $(shell id -u) | ||||
| GID = $(shell id -g) | ||||
| 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" | ||||
|  | ||||
| GEN_API_TS = "gen-ts-api" | ||||
| @ -252,9 +252,6 @@ website-build: | ||||
| website-watch:  ## Build and watch the documentation website, updating automatically | ||||
| 	cd website && npm run watch | ||||
|  | ||||
| aws-cfn: | ||||
| 	cd website && npm run aws-cfn | ||||
|  | ||||
| ######################### | ||||
| ## Docker | ||||
| ######################### | ||||
|  | ||||
| @ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di | ||||
|  | ||||
| ## 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 | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2024.12.0" | ||||
| __version__ = "2024.10.5" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -146,10 +146,6 @@ entries: | ||||
|                   ] | ||||
|               ] | ||||
|               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: | ||||
|           name: test | ||||
|       conditions: | ||||
|  | ||||
| @ -215,10 +215,6 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|                     }, | ||||
|                     "nested_context": "context-nested-value", | ||||
|                     "env_null": None, | ||||
|                     "at_index_sequence": "foo", | ||||
|                     "at_index_sequence_default": "non existent", | ||||
|                     "at_index_mapping": 2, | ||||
|                     "at_index_mapping_default": "non existent", | ||||
|                 } | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
| @ -24,10 +24,6 @@ from authentik.lib.sentry import SentryIgnoredException | ||||
| 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]: | ||||
|     """Get object's attributes via their serializer, and convert it to a normal dict""" | ||||
|     serializer: Serializer = obj.serializer(obj) | ||||
| @ -560,53 +556,6 @@ class Value(EnumeratedItem): | ||||
|             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): | ||||
|     """Dump dataclasses to yaml""" | ||||
|  | ||||
| @ -657,7 +606,6 @@ class BlueprintLoader(SafeLoader): | ||||
|         self.add_constructor("!Enumerate", Enumerate) | ||||
|         self.add_constructor("!Value", Value) | ||||
|         self.add_constructor("!Index", Index) | ||||
|         self.add_constructor("!AtIndex", AtIndex) | ||||
|  | ||||
|  | ||||
| class EntryInvalidError(SentryIgnoredException): | ||||
|  | ||||
| @ -65,12 +65,7 @@ from authentik.lib.utils.reflection import get_apps | ||||
| from authentik.outposts.models import OutpostServiceConnection | ||||
| from authentik.policies.models import Policy, PolicyBindingModel | ||||
| from authentik.policies.reputation.models import Reputation | ||||
| from authentik.providers.oauth2.models import ( | ||||
|     AccessToken, | ||||
|     AuthorizationCode, | ||||
|     DeviceToken, | ||||
|     RefreshToken, | ||||
| ) | ||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | ||||
| from authentik.rbac.models import Role | ||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||
| @ -130,7 +125,6 @@ def excluded_models() -> list[type[Model]]: | ||||
|         MicrosoftEntraProviderGroup, | ||||
|         EndpointDevice, | ||||
|         EndpointDeviceConnection, | ||||
|         DeviceToken, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @ -299,11 +293,7 @@ class Importer: | ||||
|  | ||||
|         serializer_kwargs = {} | ||||
|         model_instance = existing_models.first() | ||||
|         if ( | ||||
|             not isinstance(model(), BaseMetaModel) | ||||
|             and model_instance | ||||
|             and entry.state != BlueprintEntryDesiredState.MUST_CREATED | ||||
|         ): | ||||
|         if not isinstance(model(), BaseMetaModel) and model_instance: | ||||
|             self.logger.debug( | ||||
|                 "Initialise serializer with instance", | ||||
|                 model=model, | ||||
| @ -313,12 +303,11 @@ class Importer: | ||||
|             serializer_kwargs["instance"] = model_instance | ||||
|             serializer_kwargs["partial"] = True | ||||
|         elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED: | ||||
|             msg = ( | ||||
|                 f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} " | ||||
|                 "and object exists already", | ||||
|             ) | ||||
|             raise EntryInvalidError.from_entry( | ||||
|                 ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"), | ||||
|                 ( | ||||
|                     f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} " | ||||
|                     "and object exists already", | ||||
|                 ), | ||||
|                 entry, | ||||
|             ) | ||||
|         else: | ||||
|  | ||||
| @ -159,7 +159,7 @@ def blueprints_discovery(self: SystemTask, path: str | None = None): | ||||
|         check_blueprint_v1_file(blueprint) | ||||
|         count += 1 | ||||
|     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") | ||||
|     branding_title = CharField() | ||||
|     branding_logo = CharField(source="branding_logo_url") | ||||
|     branding_favicon = CharField(source="branding_favicon_url") | ||||
|     branding_logo = CharField() | ||||
|     branding_favicon = CharField() | ||||
|     ui_footer_links = ListField( | ||||
|         child=FooterLinkSerializer(), | ||||
|         read_only=True, | ||||
|  | ||||
| @ -25,7 +25,5 @@ class BrandMiddleware: | ||||
|             locale = brand.default_locale | ||||
|             if locale != "": | ||||
|                 locale_to_set = locale | ||||
|         if locale_to_set: | ||||
|             with override(locale_to_set): | ||||
|                 return self.get_response(request) | ||||
|         return self.get_response(request) | ||||
|         with override(locale_to_set): | ||||
|             return self.get_response(request) | ||||
|  | ||||
| @ -10,7 +10,6 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import SerializerModel | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -72,18 +71,6 @@ class Brand(SerializerModel): | ||||
|     ) | ||||
|     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 | ||||
|     def serializer(self) -> Serializer: | ||||
|         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): | ||||
|     """User source connection""" | ||||
|     """OAuth Source Serializer""" | ||||
|  | ||||
|     source_obj = SourceSerializer(read_only=True, source="source") | ||||
|     source = SourceSerializer(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = UserSourceConnection | ||||
| @ -169,10 +169,10 @@ class UserSourceConnectionSerializer(SourceSerializer): | ||||
|             "pk", | ||||
|             "user", | ||||
|             "source", | ||||
|             "source_obj", | ||||
|             "created", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "user": {"read_only": True}, | ||||
|             "created": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
| @ -197,9 +197,9 @@ class UserSourceConnectionViewSet( | ||||
|  | ||||
|  | ||||
| class GroupSourceConnectionSerializer(SourceSerializer): | ||||
|     """Group Source Connection""" | ||||
|     """Group Source Connection Serializer""" | ||||
|  | ||||
|     source_obj = SourceSerializer(read_only=True) | ||||
|     source = SourceSerializer(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = GroupSourceConnection | ||||
| @ -207,11 +207,12 @@ class GroupSourceConnectionSerializer(SourceSerializer): | ||||
|             "pk", | ||||
|             "group", | ||||
|             "source", | ||||
|             "source_obj", | ||||
|             "identifier", | ||||
|             "created", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "group": {"read_only": True}, | ||||
|             "identifier": {"read_only": True}, | ||||
|             "created": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @ -1,12 +1,10 @@ | ||||
| """transactional application and provider creation""" | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.db.models import Model | ||||
| from django.utils.translation import gettext as _ | ||||
| from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field | ||||
| from rest_framework.exceptions import PermissionDenied, ValidationError | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| @ -22,9 +20,8 @@ from authentik.blueprints.v1.common import ( | ||||
| from authentik.blueprints.v1.importer import Importer | ||||
| from authentik.core.api.applications import ApplicationSerializer | ||||
| 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.policies.api.bindings import PolicyBindingSerializer | ||||
|  | ||||
|  | ||||
| def get_provider_serializer_mapping(): | ||||
| @ -48,20 +45,6 @@ class TransactionProviderField(DictField): | ||||
|     """Dictionary field which can hold provider creation data""" | ||||
|  | ||||
|  | ||||
| class TransactionPolicyBindingSerializer(PolicyBindingSerializer): | ||||
|     """PolicyBindingSerializer which does not require target as target is set implicitly""" | ||||
|  | ||||
|     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): | ||||
|     """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 = TransactionProviderField() | ||||
|  | ||||
|     policy_bindings = TransactionPolicyBindingSerializer(many=True, required=False) | ||||
|  | ||||
|     _provider_model: type[Provider] = None | ||||
|  | ||||
|     def validate_provider_model(self, fq_model_name: str) -> str: | ||||
| @ -115,19 +96,6 @@ class TransactionApplicationSerializer(PassiveSerializer): | ||||
|                 id="app", | ||||
|             ) | ||||
|         ) | ||||
|         for binding in attrs.get("policy_bindings", []): | ||||
|             binding["target"] = KeyOf(None, ScalarNode(tag="", value="app")) | ||||
|             for key, value in binding.items(): | ||||
|                 if not isinstance(value, Model): | ||||
|                     continue | ||||
|                 binding[key] = value.pk | ||||
|             blueprint.entries.append( | ||||
|                 BlueprintEntry( | ||||
|                     model="authentik_policies.policybinding", | ||||
|                     state=BlueprintEntryDesiredState.MUST_CREATED, | ||||
|                     identifiers=binding, | ||||
|                 ) | ||||
|             ) | ||||
|         importer = Importer(blueprint, {}) | ||||
|         try: | ||||
|             valid, _ = importer.validate(raise_validation_errors=True) | ||||
| @ -152,7 +120,8 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): | ||||
| class TransactionalApplicationView(APIView): | ||||
|     """Create provider and application and attach them in a single transaction""" | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     # TODO: Migrate to a more specific permission | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @extend_schema( | ||||
|         request=TransactionApplicationSerializer(), | ||||
| @ -164,23 +133,8 @@ class TransactionalApplicationView(APIView): | ||||
|         """Convert data into a blueprint, validate it and apply it""" | ||||
|         data = TransactionApplicationSerializer(data=request.data) | ||||
|         data.is_valid(raise_exception=True) | ||||
|         blueprint: Blueprint = data.validated_data | ||||
|         for entry in blueprint.entries: | ||||
|             full_model = entry.get_model(blueprint) | ||||
|             app, __, model = full_model.partition(".") | ||||
|             if not request.user.has_perm(f"{app}.add_{model}"): | ||||
|                 raise PermissionDenied( | ||||
|                     { | ||||
|                         entry.id: _( | ||||
|                             "User lacks permission to create {model}".format_map( | ||||
|                                 { | ||||
|                                     "model": full_model, | ||||
|                                 } | ||||
|                             ) | ||||
|                         ) | ||||
|                     } | ||||
|                 ) | ||||
|         importer = Importer(blueprint, {}) | ||||
|  | ||||
|         importer = Importer(data.validated_data, {}) | ||||
|         applied = importer.apply() | ||||
|         response = {"applied": False, "logs": []} | ||||
|         response["applied"] = applied | ||||
|  | ||||
| @ -666,12 +666,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|  | ||||
|     @permission_required("authentik_core.impersonate") | ||||
|     @extend_schema( | ||||
|         request=inline_serializer( | ||||
|             "ImpersonationSerializer", | ||||
|             { | ||||
|                 "reason": CharField(required=True), | ||||
|             }, | ||||
|         ), | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|             "204": OpenApiResponse(description="Successfully started impersonation"), | ||||
|             "401": OpenApiResponse(description="Access denied"), | ||||
| @ -684,7 +679,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|             LOGGER.debug("User attempted to impersonate", user=request.user) | ||||
|             return Response(status=401) | ||||
|         user_to_be = self.get_object() | ||||
|         reason = request.data.get("reason", "") | ||||
|         # Check both object-level perms and global perms | ||||
|         if not request.user.has_perm( | ||||
|             "authentik_core.impersonate", user_to_be | ||||
| @ -694,16 +688,11 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         if user_to_be.pk == self.request.user.pk: | ||||
|             LOGGER.debug("User attempted to impersonate themselves", user=request.user) | ||||
|             return Response(status=401) | ||||
|         if not reason and request.tenant.impersonation_require_reason: | ||||
|             LOGGER.debug( | ||||
|                 "User attempted to impersonate without providing a reason", user=request.user | ||||
|             ) | ||||
|             return Response(status=401) | ||||
|  | ||||
|         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user | ||||
|         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be | ||||
|  | ||||
|         Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be) | ||||
|         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) | ||||
|  | ||||
|         return Response(status=201) | ||||
|  | ||||
|  | ||||
| @ -42,10 +42,8 @@ class ImpersonateMiddleware: | ||||
|             # Ensure that the user is active, otherwise nothing will work | ||||
|             request.user.is_active = True | ||||
|  | ||||
|         if locale_to_set: | ||||
|             with override(locale_to_set): | ||||
|                 return self.get_response(request) | ||||
|         return self.get_response(request) | ||||
|         with override(locale_to_set): | ||||
|             return self.get_response(request) | ||||
|  | ||||
|  | ||||
| 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) | ||||
|         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 | ||||
|     def serializer(self) -> Serializer: | ||||
|         from authentik.core.api.users import UserSerializer | ||||
| @ -607,31 +581,6 @@ class Application(SerializerModel, PolicyBindingModel): | ||||
|         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): | ||||
|     """Different modes a source can handle new/returning users""" | ||||
|  | ||||
|  | ||||
| @ -238,7 +238,13 @@ class SourceFlowManager: | ||||
|                 self.request.GET, | ||||
|                 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: | ||||
|             return bad_request_message( | ||||
| @ -259,7 +265,12 @@ class SourceFlowManager: | ||||
|         if stages: | ||||
|             for stage in stages: | ||||
|                 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( | ||||
|         self, | ||||
|  | ||||
| @ -9,9 +9,6 @@ | ||||
|         versionFamily: "{{ version_family }}", | ||||
|         versionSubdomain: "{{ version_subdomain }}", | ||||
|         build: "{{ build }}", | ||||
|         api: { | ||||
|             base: "{{ base_url }}", | ||||
|         }, | ||||
|     }; | ||||
|     window.addEventListener("DOMContentLoaded", function () { | ||||
|         {% for message in messages %} | ||||
|  | ||||
| @ -9,8 +9,8 @@ | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {% 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/theme-dark.css' %}" media="(prefers-color-scheme: dark)"> | ||||
| {% include "base/header_js.html" %} | ||||
| @ -13,7 +13,7 @@ | ||||
| {% block head %} | ||||
| <style> | ||||
| :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-2x: var(--ak-flow-background); | ||||
|     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); | ||||
| @ -50,7 +50,7 @@ | ||||
|     <div class="ak-login-container"> | ||||
|         <main class="pf-c-login__main"> | ||||
|             <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> | ||||
|             <header class="pf-c-login__main-header"> | ||||
|                 <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( | ||||
|                 "authentik_api:user-impersonate", | ||||
|                 kwargs={"pk": self.other_user.pk}, | ||||
|             ), | ||||
|             data={"reason": "some reason"}, | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get(reverse("authentik_api:user-me")) | ||||
| @ -56,8 +55,7 @@ class TestImpersonation(APITestCase): | ||||
|             reverse( | ||||
|                 "authentik_api:user-impersonate", | ||||
|                 kwargs={"pk": self.other_user.pk}, | ||||
|             ), | ||||
|             data={"reason": "some reason"}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|  | ||||
| @ -77,8 +75,7 @@ class TestImpersonation(APITestCase): | ||||
|             reverse( | ||||
|                 "authentik_api:user-impersonate", | ||||
|                 kwargs={"pk": self.other_user.pk}, | ||||
|             ), | ||||
|             data={"reason": "some reason"}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|  | ||||
| @ -92,8 +89,7 @@ class TestImpersonation(APITestCase): | ||||
|         self.client.force_login(self.other_user) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), | ||||
|             data={"reason": "some reason"}, | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
| @ -109,8 +105,7 @@ class TestImpersonation(APITestCase): | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}), | ||||
|             data={"reason": "some reason"}, | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 401) | ||||
|  | ||||
| @ -123,22 +118,7 @@ class TestImpersonation(APITestCase): | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), | ||||
|             data={"reason": "some reason"}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 401) | ||||
|  | ||||
|         response = self.client.get(reverse("authentik_api:user-me")) | ||||
|         response_body = loads(response.content.decode()) | ||||
|         self.assertEqual(response_body["user"]["username"], self.user.username) | ||||
|  | ||||
|     def test_impersonate_reason_required(self): | ||||
|         """test impersonation that user must provide reason""" | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}), | ||||
|             data={"reason": ""}, | ||||
|             reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 401) | ||||
|  | ||||
|  | ||||
| @ -1,13 +1,11 @@ | ||||
| """Test Transactional API""" | ||||
|  | ||||
| from django.urls import reverse | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, Group | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.providers.oauth2.models import OAuth2Provider | ||||
|  | ||||
|  | ||||
| @ -15,9 +13,7 @@ class TestTransactionalApplicationsAPI(APITestCase): | ||||
|     """Test Transactional API""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_user() | ||||
|         assign_perm("authentik_core.add_application", self.user) | ||||
|         assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user) | ||||
|         self.user = create_test_admin_user() | ||||
|  | ||||
|     def test_create_transactional(self): | ||||
|         """Test transactional Application + provider creation""" | ||||
| @ -46,66 +42,6 @@ class TestTransactionalApplicationsAPI(APITestCase): | ||||
|         self.assertIsNotNone(app) | ||||
|         self.assertEqual(app.provider.pk, provider.pk) | ||||
|  | ||||
|     def test_create_transactional_permission_denied(self): | ||||
|         """Test transactional Application + provider creation (missing permissions)""" | ||||
|         self.client.force_login(self.user) | ||||
|         uid = generate_id() | ||||
|         response = self.client.put( | ||||
|             reverse("authentik_api:core-transactional-application"), | ||||
|             data={ | ||||
|                 "app": { | ||||
|                     "name": uid, | ||||
|                     "slug": uid, | ||||
|                 }, | ||||
|                 "provider_model": "authentik_providers_saml.samlprovider", | ||||
|                 "provider": { | ||||
|                     "name": uid, | ||||
|                     "authorization_flow": str(create_test_flow().pk), | ||||
|                     "invalidation_flow": str(create_test_flow().pk), | ||||
|                     "acs_url": "https://goauthentik.io", | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             {"provider": "User lacks permission to create authentik_providers_saml.samlprovider"}, | ||||
|         ) | ||||
|  | ||||
|     def test_create_transactional_bindings(self): | ||||
|         """Test transactional Application + provider creation""" | ||||
|         assign_perm("authentik_policies.add_policybinding", self.user) | ||||
|         self.client.force_login(self.user) | ||||
|         uid = generate_id() | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         authorization_flow = create_test_flow() | ||||
|         response = self.client.put( | ||||
|             reverse("authentik_api:core-transactional-application"), | ||||
|             data={ | ||||
|                 "app": { | ||||
|                     "name": uid, | ||||
|                     "slug": uid, | ||||
|                 }, | ||||
|                 "provider_model": "authentik_providers_oauth2.oauth2provider", | ||||
|                 "provider": { | ||||
|                     "name": uid, | ||||
|                     "authorization_flow": str(authorization_flow.pk), | ||||
|                     "invalidation_flow": str(authorization_flow.pk), | ||||
|                     "redirect_uris": [], | ||||
|                 }, | ||||
|                 "policy_bindings": [{"group": group.pk, "order": 0}], | ||||
|             }, | ||||
|         ) | ||||
|         self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []}) | ||||
|         provider = OAuth2Provider.objects.filter(name=uid).first() | ||||
|         self.assertIsNotNone(provider) | ||||
|         app = Application.objects.filter(slug=uid).first() | ||||
|         self.assertIsNotNone(app) | ||||
|         self.assertEqual(app.provider.pk, provider.pk) | ||||
|         binding = PolicyBinding.objects.filter(target=app).first() | ||||
|         self.assertIsNotNone(binding) | ||||
|         self.assertEqual(binding.target, app) | ||||
|         self.assertEqual(binding.group, group) | ||||
|  | ||||
|     def test_create_transactional_invalid(self): | ||||
|         """Test transactional Application + provider creation""" | ||||
|         self.client.force_login(self.user) | ||||
| @ -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.urls import path | ||||
|  | ||||
| from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet | ||||
| from authentik.core.api.applications import ApplicationViewSet | ||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||
| from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | ||||
| @ -70,7 +69,6 @@ urlpatterns = [ | ||||
| api_urlpatterns = [ | ||||
|     ("core/authenticated_sessions", AuthenticatedSessionViewSet), | ||||
|     ("core/applications", ApplicationViewSet), | ||||
|     ("core/application_entitlements", ApplicationEntitlementViewSet), | ||||
|     path( | ||||
|         "core/transactional/applications/", | ||||
|         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.views.executor import ( | ||||
|     SESSION_KEY_APPLICATION_PRE, | ||||
|     SESSION_KEY_PLAN, | ||||
|     ToDefaultFlow, | ||||
| ) | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.stages.consent.stage import ( | ||||
|     PLAN_CONTEXT_CONSENT_HEADER, | ||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||
| @ -56,7 +58,8 @@ class RedirectToAppLaunch(View): | ||||
|         except FlowNonApplicableException: | ||||
|             raise Http404 from None | ||||
|         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): | ||||
|  | ||||
| @ -16,7 +16,6 @@ from authentik.api.v3.config import ConfigView | ||||
| from authentik.brands.api import CurrentBrandSerializer | ||||
| from authentik.brands.models import Brand | ||||
| from authentik.core.models import UserTypes | ||||
| from authentik.lib.config import CONFIG | ||||
| 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["build"] = get_build_hash() | ||||
|         kwargs["url_kwargs"] = self.kwargs | ||||
|         kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -85,5 +85,5 @@ def certificate_discovery(self: SystemTask): | ||||
|         if dirty: | ||||
|             cert.save() | ||||
|     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> | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | ||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}"> | ||||
| <link rel="icon" href="{{ tenant.branding_favicon }}"> | ||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon }}"> | ||||
| {% include "base/header_js.html" %} | ||||
| {% endblock %} | ||||
|  | ||||
|  | ||||
| @ -18,7 +18,9 @@ from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| 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.urls import redirect_with_qs | ||||
| from authentik.policies.engine import PolicyEngine | ||||
|  | ||||
|  | ||||
| @ -54,7 +56,12 @@ class RACStartView(EnterprisePolicyAccessView): | ||||
|                 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): | ||||
|  | ||||
| @ -60,7 +60,7 @@ def default_event_duration(): | ||||
|     """Default duration an Event is saved. | ||||
|     This is used as a fallback when no brand is available""" | ||||
|     try: | ||||
|         tenant = get_current_tenant(only=["event_retention"]) | ||||
|         tenant = get_current_tenant() | ||||
|         return now() + timedelta_from_string(tenant.event_retention) | ||||
|     except Tenant.DoesNotExist: | ||||
|         return now() + timedelta(days=365) | ||||
|  | ||||
| @ -40,7 +40,6 @@ class Migration(migrations.Migration): | ||||
|                     ("require_authenticated", "Require Authenticated"), | ||||
|                     ("require_unauthenticated", "Require Unauthenticated"), | ||||
|                     ("require_superuser", "Require Superuser"), | ||||
|                     ("require_redirect", "Require Redirect"), | ||||
|                     ("require_outpost", "Require Outpost"), | ||||
|                 ], | ||||
|                 default="none", | ||||
|  | ||||
| @ -14,7 +14,6 @@ from structlog.stdlib import get_logger | ||||
| from authentik.core.models import Token | ||||
| from authentik.core.types import UserSettingSerializer | ||||
| from authentik.flows.challenge import FlowLayout | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
| @ -33,7 +32,6 @@ class FlowAuthenticationRequirement(models.TextChoices): | ||||
|     REQUIRE_AUTHENTICATED = "require_authenticated" | ||||
|     REQUIRE_UNAUTHENTICATED = "require_unauthenticated" | ||||
|     REQUIRE_SUPERUSER = "require_superuser" | ||||
|     REQUIRE_REDIRECT = "require_redirect" | ||||
|     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 | ||||
|         it is returned as-is""" | ||||
|         if not self.background: | ||||
|             return ( | ||||
|                 CONFIG.get("web.path", "/")[:-1] + "/static/dist/assets/images/flow_background.jpg" | ||||
|             ) | ||||
|         if self.background.name.startswith("http"): | ||||
|             return "/static/dist/assets/images/flow_background.jpg" | ||||
|         if self.background.name.startswith("http") or self.background.name.startswith("/static"): | ||||
|             return self.background.name | ||||
|         if self.background.name.startswith("/static"): | ||||
|             return CONFIG.get("web.path", "/")[:-1] + self.background.name | ||||
|         return self.background.url | ||||
|  | ||||
|     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| """Flows Planner""" | ||||
|  | ||||
| from dataclasses import dataclass, field | ||||
| from typing import TYPE_CHECKING, Any | ||||
| from typing import Any | ||||
|  | ||||
| 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.tracing import Span | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| @ -23,15 +23,10 @@ from authentik.flows.models import ( | ||||
|     in_memory_stage, | ||||
| ) | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.outposts.models import Outpost | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.root.middleware import ClientIPMiddleware | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.flows.stage import StageView | ||||
|  | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||
| 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 | ||||
| # was 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_PREFIX = "goauthentik.io/flows/planner/" | ||||
|  | ||||
| @ -117,54 +110,6 @@ class FlowPlan: | ||||
|         """Check if there are any stages left in this plan""" | ||||
|         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: | ||||
|     """Execute all policies to plan out a flat list of all Stages | ||||
| @ -183,7 +128,7 @@ class FlowPlanner: | ||||
|         self.flow = flow | ||||
|         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`""" | ||||
|         if ( | ||||
|             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED | ||||
| @ -200,11 +145,6 @@ class FlowPlanner: | ||||
|             and not request.user.is_superuser | ||||
|         ): | ||||
|             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) | ||||
|         if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: | ||||
|             if not outpost_user: | ||||
| @ -236,13 +176,18 @@ class FlowPlanner: | ||||
|             ) | ||||
|             context = default_context or {} | ||||
|             # 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: | ||||
|                 user = context[PLAN_CONTEXT_PENDING_USER] | ||||
|             else: | ||||
|                 user = request.user | ||||
|  | ||||
|             context.update(self._check_authentication(request, context)) | ||||
|                 # We only need to check the flow authentication if it's planned without a user | ||||
|                 # 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 | ||||
|             # to make sure the user even has access to the flow | ||||
|             engine = PolicyEngine(self.flow, user, request) | ||||
|  | ||||
| @ -93,11 +93,7 @@ class ChallengeStageView(StageView): | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         """Return a challenge for the frontend to solve""" | ||||
|         try: | ||||
|             challenge = self._get_challenge(*args, **kwargs) | ||||
|         except StageInvalidException as exc: | ||||
|             self.logger.debug("Got StageInvalidException", exc=exc) | ||||
|             return self.executor.stage_invalid() | ||||
|         challenge = self._get_challenge(*args, **kwargs) | ||||
|         if not challenge.is_valid(): | ||||
|             self.logger.warning( | ||||
|                 "f(ch): Invalid challenge", | ||||
| @ -173,7 +169,11 @@ class ChallengeStageView(StageView): | ||||
|                 stage_type=self.__class__.__name__, method="get_challenge" | ||||
|             ).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( | ||||
|             op="authentik.flow.stage._get_challenge", | ||||
|             name=self.__class__.__name__, | ||||
|  | ||||
| @ -9,8 +9,8 @@ | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|         <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.sessions.middleware import SessionMiddleware | ||||
| from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from django.shortcuts import redirect | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
| 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.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||
| from authentik.flows.models import ( | ||||
|     FlowAuthenticationRequirement, | ||||
|     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.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key | ||||
| from authentik.lib.tests.utils import dummy_get_response | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST | ||||
| from authentik.outposts.models import Outpost | ||||
| @ -86,24 +73,6 @@ class TestFlowPlanner(TestCase): | ||||
|         planner.allow_empty_flows = True | ||||
|         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") | ||||
|     def test_authentication_outpost(self): | ||||
|         """Test flow authentication (outpost)""" | ||||
| @ -242,99 +211,3 @@ class TestFlowPlanner(TestCase): | ||||
|  | ||||
|             self.assertIsInstance(plan.markers[0], StageMarker) | ||||
|             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 | ||||
|                     self.plan = None | ||||
|                     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 | ||||
|             request.session[SESSION_KEY_GET] = get_params | ||||
| @ -598,4 +597,9 @@ class ConfigureFlowInitView(LoginRequiredMixin, View): | ||||
|         except FlowNonApplicableException: | ||||
|             LOGGER.warning("Flow not applicable to user") | ||||
|             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 | ||||
| from collections.abc import Mapping | ||||
| from contextlib import contextmanager | ||||
| from copy import deepcopy | ||||
| from dataclasses import dataclass, field | ||||
| from enum import Enum | ||||
| from glob import glob | ||||
| @ -337,58 +336,6 @@ def redis_url(db: int) -> str: | ||||
|     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 len(argv) < 2:  # noqa: PLR2004 | ||||
|         print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder)) | ||||
|  | ||||
| @ -135,7 +135,6 @@ web: | ||||
|   # No default here as it's set dynamically | ||||
|   # workers: 2 | ||||
|   threads: 4 | ||||
|   path: / | ||||
|  | ||||
| worker: | ||||
|   concurrency: 2 | ||||
|  | ||||
| @ -36,7 +36,6 @@ from authentik.lib.utils.http import authentik_user_agent | ||||
| from authentik.lib.utils.reflection import get_env | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| _root_path = CONFIG.get("web.path", "/") | ||||
|  | ||||
|  | ||||
| class SentryIgnoredException(Exception): | ||||
| @ -91,7 +90,7 @@ def traces_sampler(sampling_context: dict) -> float: | ||||
|     path = sampling_context.get("asgi_scope", {}).get("path", "") | ||||
|     _type = sampling_context.get("asgi_scope", {}).get("type", "") | ||||
|     # 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 | ||||
|     if _type == "websocket": | ||||
|         return 0 | ||||
|  | ||||
| @ -82,7 +82,7 @@ class SyncTasks: | ||||
|                 return | ||||
|             try: | ||||
|                 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( | ||||
|                         args=(class_to_path(User), page, provider_pk), | ||||
|                         time_limit=PAGE_TIMEOUT, | ||||
| @ -90,7 +90,7 @@ class SyncTasks: | ||||
|                     ).get(): | ||||
|                         messages.append(LogEvent(**msg)) | ||||
|                 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( | ||||
|                         args=(class_to_path(Group), page, provider_pk), | ||||
|                         time_limit=PAGE_TIMEOUT, | ||||
|  | ||||
| @ -9,14 +9,7 @@ from unittest import mock | ||||
| from django.conf import ImproperlyConfigured | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.lib.config import ( | ||||
|     ENV_PREFIX, | ||||
|     UNSET, | ||||
|     Attr, | ||||
|     AttrEncoder, | ||||
|     ConfigLoader, | ||||
|     django_db_config, | ||||
| ) | ||||
| from authentik.lib.config import ENV_PREFIX, UNSET, Attr, AttrEncoder, ConfigLoader | ||||
|  | ||||
|  | ||||
| class TestConfig(TestCase): | ||||
| @ -182,201 +175,3 @@ class TestConfig(TestCase): | ||||
|         config = ConfigLoader() | ||||
|         config.set("foo.bar", "baz") | ||||
|         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: | ||||
|         """Check that either policy, group or user is set.""" | ||||
|         target: PolicyBindingModel = attrs.get("target") | ||||
|         supported = target.supported_policy_binding_targets() | ||||
|         supported.sort() | ||||
|         count = sum([bool(attrs.get(x, None)) for x in supported]) | ||||
|         count = sum( | ||||
|             [ | ||||
|                 bool(attrs.get("policy", None)), | ||||
|                 bool(attrs.get("group", None)), | ||||
|                 bool(attrs.get("user", None)), | ||||
|             ] | ||||
|         ) | ||||
|         invalid = count > 1 | ||||
|         empty = count < 1 | ||||
|         warning = ", ".join(f"'{x}'" for x in supported) | ||||
|         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: | ||||
|             raise ValidationError(f"One of {warning} must be set.") | ||||
|             raise ValidationError("One of 'policy', 'group' or 'user' must be set.") | ||||
|         return attrs | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -43,9 +43,8 @@ class PasswordExpiryPolicy(Policy): | ||||
|                 request.user.set_unusable_password() | ||||
|                 request.user.save() | ||||
|                 message = _( | ||||
|                     "Password expired {days} days ago. Please update your password.".format( | ||||
|                         days=days_since_expiry | ||||
|                     ) | ||||
|                     "Password expired %(days)d days ago. Please update your password." | ||||
|                     % {"days": days_since_expiry} | ||||
|                 ) | ||||
|                 return PolicyResult(False, message) | ||||
|             return PolicyResult(False, _("Password has expired.")) | ||||
|  | ||||
| @ -1,6 +1,4 @@ | ||||
| # 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 | ||||
|  | ||||
| @ -25,13 +23,4 @@ class Migration(migrations.Migration): | ||||
|                 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: | ||||
|         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): | ||||
|     """Relationship between a Policy and a PolicyBindingModel.""" | ||||
| @ -85,9 +81,7 @@ class PolicyBinding(SerializerModel): | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     target = InheritanceForeignKey( | ||||
|         PolicyBindingModel, on_delete=models.CASCADE, related_name="bindings" | ||||
|     ) | ||||
|     target = InheritanceForeignKey(PolicyBindingModel, on_delete=models.CASCADE, related_name="+") | ||||
|     negate = models.BooleanField( | ||||
|         default=False, | ||||
|         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]) | ||||
|         if final_count > self.hibp_allowed_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(True) | ||||
|  | ||||
|  | ||||
| @ -38,7 +38,7 @@ class TestBindingsAPI(APITestCase): | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             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): | ||||
| @ -49,5 +49,5 @@ class TestBindingsAPI(APITestCase): | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             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", | ||||
|             "property_mappings", | ||||
|             "issuer_mode", | ||||
|             "jwt_federation_sources", | ||||
|             "jwt_federation_providers", | ||||
|             "jwks_sources", | ||||
|         ] | ||||
|         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", | ||||
|     ) | ||||
|  | ||||
|     jwt_federation_sources = models.ManyToManyField( | ||||
|     jwks_sources = models.ManyToManyField( | ||||
|         OAuthSource, | ||||
|         verbose_name=_( | ||||
|             "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, | ||||
|         blank=True, | ||||
|     ) | ||||
|     jwt_federation_providers = models.ManyToManyField("OAuth2Provider", blank=True, default=None) | ||||
|  | ||||
|     @cached_property | ||||
|     def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: | ||||
| @ -396,7 +395,7 @@ class BaseGrantModel(models.Model): | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|     auth_time = models.DateTimeField(verbose_name="Authentication time") | ||||
|     session = models.ForeignKey( | ||||
|         AuthenticatedSession, null=True, on_delete=models.CASCADE, default=None | ||||
|         AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
| @ -497,11 +496,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | ||||
|  | ||||
|     token = models.TextField(default=generate_client_secret) | ||||
|     _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: | ||||
|         indexes = [ | ||||
|  | ||||
| @ -311,7 +311,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|         user = create_test_admin_user() | ||||
|         self.client.force_login(user) | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         response = self.client.get( | ||||
|         self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -320,10 +320,16 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "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() | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         ) | ||||
|         self.assertAlmostEqual( | ||||
|             code.expires.timestamp() - now().timestamp(), | ||||
| @ -371,7 +377,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             response = self.client.get( | ||||
|             self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "id_token", | ||||
| @ -382,16 +388,22 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "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() | ||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||
|             self.assertEqual( | ||||
|                 response.url, | ||||
|                 ( | ||||
|                     f"http://localhost#access_token={token.token}" | ||||
|                     f"&id_token={provider.encode(token.id_token.to_dict())}" | ||||
|                     f"&token_type={TOKEN_TYPE}" | ||||
|                     f"&expires_in={int(expires)}&state={state}" | ||||
|                 ), | ||||
|             self.assertJSONEqual( | ||||
|                 response.content.decode(), | ||||
|                 { | ||||
|                     "component": "xak-flow-redirect", | ||||
|                     "to": ( | ||||
|                         f"http://localhost#access_token={token.token}" | ||||
|                         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) | ||||
|             self.assertEqual(jwt["amr"], ["pwd"]) | ||||
| @ -443,7 +455,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             response = self.client.get( | ||||
|             self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "id_token", | ||||
| @ -454,7 +466,10 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "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() | ||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||
|             jwt = self.validate_jwe(token, provider) | ||||
| @ -491,7 +506,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             response = self.client.get( | ||||
|             self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "code", | ||||
| @ -503,10 +518,16 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "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() | ||||
|             self.assertEqual( | ||||
|                 response.url, | ||||
|                 f"http://localhost#code={code.code}&state={state}", | ||||
|             self.assertJSONEqual( | ||||
|                 response.content.decode(), | ||||
|                 { | ||||
|                     "component": "xak-flow-redirect", | ||||
|                     "to": (f"http://localhost#code={code.code}" f"&state={state}"), | ||||
|                 }, | ||||
|             ) | ||||
|             self.assertAlmostEqual( | ||||
|                 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: | ||||
|         super().setUp() | ||||
|         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() | ||||
|  | ||||
|         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( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
| @ -69,7 +62,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|             redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], | ||||
|             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.app = Application.objects.create(name="test", slug="test", provider=self.provider) | ||||
|  | ||||
| @ -107,7 +100,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|  | ||||
|     def test_invalid_signature(self): | ||||
|         """test invalid JWT""" | ||||
|         token = self.helper_provider.encode( | ||||
|         token = self.provider.encode( | ||||
|             { | ||||
|                 "sub": "foo", | ||||
|                 "exp": datetime.now() + timedelta(hours=2), | ||||
| @ -129,7 +122,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|  | ||||
|     def test_invalid_expired(self): | ||||
|         """test invalid JWT""" | ||||
|         token = self.helper_provider.encode( | ||||
|         token = self.provider.encode( | ||||
|             { | ||||
|                 "sub": "foo", | ||||
|                 "exp": datetime.now() - timedelta(hours=2), | ||||
| @ -153,7 +146,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|         """test invalid JWT""" | ||||
|         self.app.provider = None | ||||
|         self.app.save() | ||||
|         token = self.helper_provider.encode( | ||||
|         token = self.provider.encode( | ||||
|             { | ||||
|                 "sub": "foo", | ||||
|                 "exp": datetime.now() + timedelta(hours=2), | ||||
| @ -181,7 +174,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|             target=self.app, | ||||
|             order=0, | ||||
|         ) | ||||
|         token = self.helper_provider.encode( | ||||
|         token = self.provider.encode( | ||||
|             { | ||||
|                 "sub": "foo", | ||||
|                 "exp": datetime.now() + timedelta(hours=2), | ||||
| @ -203,7 +196,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): | ||||
|  | ||||
|     def test_successful(self): | ||||
|         """test successful""" | ||||
|         token = self.helper_provider.encode( | ||||
|         token = self.provider.encode( | ||||
|             { | ||||
|                 "sub": "foo", | ||||
|                 "exp": datetime.now() + timedelta(hours=2), | ||||
|  | ||||
| @ -45,7 +45,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         challenge = generate_id() | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         response = self.client.get( | ||||
|         self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -56,10 +56,16 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "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() | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -101,7 +107,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         self.client.force_login(user) | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         response = self.client.get( | ||||
|         self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -112,10 +118,16 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 # "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() | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -162,7 +174,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         ) | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         response = self.client.get( | ||||
|         self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -173,10 +185,16 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "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() | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -207,7 +225,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         verifier = generate_id() | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         response = self.client.get( | ||||
|         self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -217,10 +235,16 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "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() | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             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.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| 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.urls import redirect_with_qs | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.views import PolicyAccessView, RequestValidationError | ||||
| @ -452,16 +454,11 @@ class AuthorizationFlowInitView(PolicyAccessView): | ||||
|  | ||||
|         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) | ||||
|  | ||||
|         return plan.to_redirect( | ||||
|             self.request, | ||||
|             self.provider.authorization_flow, | ||||
|             # We can only skip the flow executor and directly go to the final redirect URL if | ||||
|             #  we can submit the data to the RP via URL | ||||
|             allowed_silent_types=( | ||||
|                 [OAuthFulfillmentStage] | ||||
|                 if self.params.response_mode in [ResponseMode.QUERY, ResponseMode.FRAGMENT] | ||||
|                 else [] | ||||
|             ), | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=self.provider.authorization_flow.slug, | ||||
|         ) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -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.stage import ChallengeStageView | ||||
| 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.providers.oauth2.models import DeviceToken | ||||
| from authentik.providers.oauth2.views.device_finish import ( | ||||
| @ -72,7 +73,12 @@ class CodeValidatorView(PolicyAccessView): | ||||
|             LOGGER.warning("Flow not applicable to user") | ||||
|             return None | ||||
|         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): | ||||
| @ -103,7 +109,11 @@ class DeviceEntryView(PolicyAccessView): | ||||
|         plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) | ||||
|  | ||||
|         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): | ||||
| @ -127,7 +137,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse): | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeStage(ChallengeStageView): | ||||
|     """Flow challenge for users to enter device code""" | ||||
|     """Flow challenge for users to enter device codes""" | ||||
|  | ||||
|     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.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| 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 | ||||
|  | ||||
|  | ||||
| @ -35,4 +37,9 @@ class EndSessionView(PolicyAccessView): | ||||
|             }, | ||||
|         ) | ||||
|         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) | ||||
|  | ||||
|     def __validate_jwt_from_source( | ||||
|         self, assertion: str | ||||
|     ) -> tuple[dict, OAuthSource] | tuple[None, None]: | ||||
|     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") | ||||
|  | ||||
|         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 | ||||
|         # the header. | ||||
|         # 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) | ||||
|             raise TokenError("invalid_grant") from None | ||||
|         expected_kid = decode_unvalidated["header"]["kid"] | ||||
|         fallback_alg = decode_unvalidated["header"]["alg"] | ||||
|         token = source = None | ||||
|         for source in self.provider.jwt_federation_sources.filter( | ||||
|         for source in self.provider.jwks_sources.filter( | ||||
|             oidc_jwks__keys__contains=[{"kid": expected_kid}] | ||||
|         ): | ||||
|             LOGGER.debug("verifying JWT with source", source=source.slug) | ||||
|             keys = source.oidc_jwks.get("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")) | ||||
|                 try: | ||||
|                     parsed_key = PyJWK.from_dict(key).key | ||||
|                     parsed_key = PyJWK.from_dict(key) | ||||
|                     token = decode( | ||||
|                         assertion, | ||||
|                         parsed_key, | ||||
|                         algorithms=[key.get("alg")] if "alg" in key else [fallback_alg], | ||||
|                         parsed_key.key, | ||||
|                         algorithms=[key.get("alg")], | ||||
|                         options={ | ||||
|                             "verify_aud": False, | ||||
|                         }, | ||||
| @ -404,61 +414,13 @@ class TokenParams: | ||||
|                 # and not a public key | ||||
|                 except (PyJWTError, ValueError, TypeError, AttributeError) as exc: | ||||
|                     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: | ||||
|             LOGGER.warning("No token could be verified") | ||||
|             raise TokenError("invalid_grant") | ||||
|  | ||||
|         LOGGER.info("successfully verified JWT with source", source=source.slug) | ||||
|  | ||||
|         if "exp" in token: | ||||
|             exp = datetime.fromtimestamp(token["exp"]) | ||||
|             # Non-timezone aware check since we assume `exp` is in UTC | ||||
| @ -472,16 +434,15 @@ class TokenParams: | ||||
|             raise TokenError("invalid_grant") | ||||
|  | ||||
|         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 = { | ||||
|             "jwt": token, | ||||
|         } | ||||
|         if source: | ||||
|             method_args["source"] = source | ||||
|         if provider: | ||||
|             method_args["provider"] = provider | ||||
|         if parsed_key: | ||||
|             method_args["jwk_id"] = parsed_key.key_id | ||||
|         Event.new( | ||||
|             action=EventAction.LOGIN, | ||||
|             **{ | ||||
|  | ||||
| @ -94,8 +94,7 @@ class ProxyProviderSerializer(ProviderSerializer): | ||||
|             "intercept_header_auth", | ||||
|             "redirect_uris", | ||||
|             "cookie_domain", | ||||
|             "jwt_federation_sources", | ||||
|             "jwt_federation_providers", | ||||
|             "jwks_sources", | ||||
|             "access_token_validity", | ||||
|             "refresh_token_validity", | ||||
|             "outpost_set", | ||||
|  | ||||
| @ -127,7 +127,6 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware] | ||||
|                     authResponseHeaders=[ | ||||
|                         "X-authentik-username", | ||||
|                         "X-authentik-groups", | ||||
|                         "X-authentik-entitlements", | ||||
|                         "X-authentik-email", | ||||
|                         "X-authentik-name", | ||||
|                         "X-authentik-uid", | ||||
|  | ||||
| @ -147,7 +147,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|                 "goauthentik.io/providers/oauth2/scope-openid", | ||||
|                 "goauthentik.io/providers/oauth2/scope-profile", | ||||
|                 "goauthentik.io/providers/oauth2/scope-email", | ||||
|                 "goauthentik.io/providers/oauth2/scope-entitlements", | ||||
|                 "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.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| 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.policies.views import PolicyAccessView | ||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||
| @ -62,7 +64,12 @@ class SAMLSLOView(PolicyAccessView): | ||||
|             }, | ||||
|         ) | ||||
|         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: | ||||
|         """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.models import in_memory_stage | ||||
| 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.policies.views import PolicyAccessView | ||||
| 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.views.flows import ( | ||||
|     REQUEST_KEY_RELAY_STATE, | ||||
| @ -73,12 +74,11 @@ class SAMLSSOView(PolicyAccessView): | ||||
|         except FlowNonApplicableException: | ||||
|             raise Http404 from None | ||||
|         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) | ||||
|         return plan.to_redirect( | ||||
|             request, | ||||
|             self.provider.authorization_flow, | ||||
|             allowed_silent_types=( | ||||
|                 [SAMLFlowFinalView] if self.provider.sp_binding in [SAMLBindings.REDIRECT] else [] | ||||
|             ), | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=self.provider.authorization_flow.slug, | ||||
|         ) | ||||
|  | ||||
|     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 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.sentry import sentry_init | ||||
| from authentik.lib.utils.reflection import get_env | ||||
| @ -32,8 +32,6 @@ LOGIN_URL = "authentik_flows:default-authentication" | ||||
| # Custom user model | ||||
| 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_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" | ||||
| LANGUAGE_COOKIE_NAME = "authentik_language" | ||||
| @ -114,7 +112,6 @@ TENANT_APPS = [ | ||||
|     "authentik.stages.invitation", | ||||
|     "authentik.stages.password", | ||||
|     "authentik.stages.prompt", | ||||
|     "authentik.stages.redirect", | ||||
|     "authentik.stages.user_delete", | ||||
|     "authentik.stages.user_login", | ||||
|     "authentik.stages.user_logout", | ||||
| @ -298,7 +295,47 @@ CHANNEL_LAYERS = { | ||||
| # https://docs.djangoproject.com/en/2.1/ref/settings/#databases | ||||
|  | ||||
| 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 = ( | ||||
|     "authentik.tenants.db.FailoverRouter", | ||||
| @ -389,7 +426,7 @@ if _ERROR_REPORTING: | ||||
| # https://docs.djangoproject.com/en/2.1/howto/static-files/ | ||||
|  | ||||
| STATICFILES_DIRS = [BASE_DIR / Path("web")] | ||||
| STATIC_URL = CONFIG.get("web.path", "/") + "static/" | ||||
| STATIC_URL = "/static/" | ||||
|  | ||||
| STORAGES = { | ||||
|     "staticfiles": { | ||||
|  | ||||
| @ -4,7 +4,6 @@ from django.urls import include, path | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.views import error | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.reflection import get_apps | ||||
| from authentik.root.monitoring import LiveView, MetricsView, ReadyView | ||||
|  | ||||
| @ -15,7 +14,7 @@ handler403 = error.ForbiddenView.as_view() | ||||
| handler404 = error.NotFoundView.as_view() | ||||
| handler500 = error.ServerErrorView.as_view() | ||||
|  | ||||
| _urlpatterns = [] | ||||
| urlpatterns = [] | ||||
|  | ||||
| for _authentik_app in get_apps(): | ||||
|     mountpoints = None | ||||
| @ -36,7 +35,7 @@ for _authentik_app in get_apps(): | ||||
|                 namespace=namespace, | ||||
|             ), | ||||
|         ) | ||||
|         _urlpatterns.append(_path) | ||||
|         urlpatterns.append(_path) | ||||
|         LOGGER.debug( | ||||
|             "Mounted URLs", | ||||
|             app_name=_authentik_app.name, | ||||
| @ -44,10 +43,8 @@ for _authentik_app in get_apps(): | ||||
|             namespace=namespace, | ||||
|         ) | ||||
|  | ||||
| _urlpatterns += [ | ||||
| urlpatterns += [ | ||||
|     path("-/metrics/", MetricsView.as_view(), name="metrics"), | ||||
|     path("-/health/live/", LiveView.as_view(), name="health-live"), | ||||
|     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 channels.routing import URLRouter | ||||
| from django.urls import path | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.reflection import get_apps | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| _websocket_urlpatterns = [] | ||||
| websocket_urlpatterns = [] | ||||
| for _authentik_app in get_apps(): | ||||
|     try: | ||||
|         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"): | ||||
|         continue | ||||
|     urls: list = api_urls.websocket_urlpatterns | ||||
|     _websocket_urlpatterns.extend(urls) | ||||
|     websocket_urlpatterns.extend(urls) | ||||
|     LOGGER.debug( | ||||
|         "Mounted Websocket URLs", | ||||
|         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", | ||||
|             "realm", | ||||
|             "krb5_conf", | ||||
|             "kadmin_type", | ||||
|             "sync_users", | ||||
|             "sync_users_password", | ||||
|             "sync_principal", | ||||
| @ -70,7 +69,6 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet): | ||||
|         "slug", | ||||
|         "enabled", | ||||
|         "realm", | ||||
|         "kadmin_type", | ||||
|         "sync_users", | ||||
|         "sync_users_password", | ||||
|         "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 | ||||
|  | ||||
| import gssapi | ||||
| import kadmin | ||||
| import pglock | ||||
| from django.db import connection, models | ||||
| from django.db.models.fields import b64decode | ||||
| @ -13,8 +14,6 @@ from django.http import HttpRequest | ||||
| from django.shortcuts import reverse | ||||
| from django.templatetags.static import static | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from kadmin import KAdmin, KAdminApiVersion | ||||
| from kadmin.exceptions import PyKAdminException | ||||
| from rest_framework.serializers import Serializer | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -31,17 +30,12 @@ from authentik.flows.challenge import RedirectChallenge | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| # Creating kadmin connections is expensive. As such, this global is used to reuse | ||||
| # existing kadmin connections instead of creating new ones | ||||
| # python-kadmin leaks file descriptors. As such, this global is used to reuse | ||||
| # existing kadmin connections instead of creating new ones, which results in less to no file | ||||
| # descriptors leaks | ||||
| _kadmin_connections: dict[str, Any] = {} | ||||
|  | ||||
|  | ||||
| class KAdminType(models.TextChoices): | ||||
|     MIT = "MIT" | ||||
|     HEIMDAL = "Heimdal" | ||||
|     OTHER = "other" | ||||
|  | ||||
|  | ||||
| class KerberosSource(Source): | ||||
|     """Federate Kerberos realm with authentik""" | ||||
|  | ||||
| @ -50,9 +44,6 @@ class KerberosSource(Source): | ||||
|         blank=True, | ||||
|         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( | ||||
|         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) | ||||
|         return str(conf_path) | ||||
|  | ||||
|     def _kadmin_init(self) -> 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 | ||||
|     def _kadmin_init(self) -> "kadmin.KAdmin | None": | ||||
|         # kadmin doesn't use a ccache for its connection | ||||
|         # as such, we don't need to create a separate ccache for each source | ||||
|         if not self.sync_principal: | ||||
|             return None | ||||
|         if self.sync_password: | ||||
|             return KAdmin.with_password( | ||||
|             return kadmin.init_with_password( | ||||
|                 self.sync_principal, | ||||
|                 self.sync_password, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         if self.sync_keytab: | ||||
|             keytab = self.sync_keytab | ||||
| @ -233,20 +215,18 @@ class KerberosSource(Source): | ||||
|                 keytab_path.touch(mode=0o600) | ||||
|                 keytab_path.write_bytes(b64decode(self.sync_keytab)) | ||||
|                 keytab = f"FILE:{keytab_path}" | ||||
|             return KAdmin.with_keytab( | ||||
|             return kadmin.init_with_keytab( | ||||
|                 self.sync_principal, | ||||
|                 keytab, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         if self.sync_ccache: | ||||
|             return KAdmin.with_ccache( | ||||
|             return kadmin.init_with_ccache( | ||||
|                 self.sync_principal, | ||||
|                 self.sync_ccache, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         return None | ||||
|  | ||||
|     def connection(self) -> KAdmin | None: | ||||
|     def connection(self) -> "kadmin.KAdmin | None": | ||||
|         """Get kadmin connection""" | ||||
|         if str(self.pk) not in _kadmin_connections: | ||||
|             kadm = self._kadmin_init() | ||||
| @ -266,7 +246,7 @@ class KerberosSource(Source): | ||||
|                     status["status"] = "no connection" | ||||
|                     return status | ||||
|                 status["principal_exists"] = kadm.principal_exists(self.sync_principal) | ||||
|             except PyKAdminException as exc: | ||||
|             except kadmin.KAdminError as exc: | ||||
|                 status["status"] = str(exc) | ||||
|         return status | ||||
|  | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| """authentik kerberos source signals""" | ||||
|  | ||||
| import kadmin | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch import receiver | ||||
| from kadmin.exceptions import PyKAdminException | ||||
| from rest_framework.serializers import ValidationError | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -45,12 +45,10 @@ def kerberos_sync_password(sender, user: User, password: str, **_): | ||||
|             continue | ||||
|         with Krb5ConfContext(source): | ||||
|             try: | ||||
|                 kadm = source.connection() | ||||
|                 kadm.get_principal(user_source_connection.identifier).change_password( | ||||
|                     kadm, | ||||
|                     password, | ||||
|                 source.connection().getprinc(user_source_connection.identifier).change_password( | ||||
|                     password | ||||
|                 ) | ||||
|             except PyKAdminException as exc: | ||||
|             except kadmin.KAdminError as exc: | ||||
|                 LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) | ||||
|                 Event.new( | ||||
|                     EventAction.CONFIGURATION_ERROR, | ||||
|  | ||||
| @ -2,9 +2,9 @@ | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| import kadmin | ||||
| from django.core.exceptions import FieldError | ||||
| from django.db import IntegrityError, transaction | ||||
| from kadmin import KAdmin | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.core.expression.exceptions import ( | ||||
| @ -30,7 +30,7 @@ class KerberosSync: | ||||
|  | ||||
|     _source: KerberosSource | ||||
|     _logger: BoundLogger | ||||
|     _connection: KAdmin | ||||
|     _connection: "kadmin.KAdmin" | ||||
|     mapper: SourceMapper | ||||
|     user_manager: PropertyMappingManager | ||||
|     group_manager: PropertyMappingManager | ||||
| @ -43,10 +43,8 @@ class KerberosSync: | ||||
|         self._messages = [] | ||||
|         self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) | ||||
|         self.mapper = SourceMapper(self._source) | ||||
|         self.user_manager = self.mapper.get_manager(User, ["principal", "principal_obj"]) | ||||
|         self.group_manager = self.mapper.get_manager( | ||||
|             Group, ["group_id", "principal", "principal_obj"] | ||||
|         ) | ||||
|         self.user_manager = self.mapper.get_manager(User, ["principal"]) | ||||
|         self.group_manager = self.mapper.get_manager(Group, ["group_id", "principal"]) | ||||
|         self.matcher = SourceMatcher( | ||||
|             self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection | ||||
|         ) | ||||
| @ -69,16 +67,12 @@ class KerberosSync: | ||||
|  | ||||
|     def _handle_principal(self, principal: str) -> bool: | ||||
|         try: | ||||
|             # TODO: handle permission error | ||||
|             principal_obj = self._connection.get_principal(principal) | ||||
|  | ||||
|             defaults = self.mapper.build_object_properties( | ||||
|                 object_type=User, | ||||
|                 manager=self.user_manager, | ||||
|                 user=None, | ||||
|                 request=None, | ||||
|                 principal=principal, | ||||
|                 principal_obj=principal_obj, | ||||
|             ) | ||||
|             self._logger.debug("Writing user with attributes", **defaults) | ||||
|             if "username" not in defaults: | ||||
| @ -97,7 +91,6 @@ class KerberosSync: | ||||
|                     request=None, | ||||
|                     group_id=group_id, | ||||
|                     principal=principal, | ||||
|                     principal_obj=principal_obj, | ||||
|                 ) | ||||
|                 for group_id in defaults.pop("groups", []) | ||||
|             } | ||||
| @ -168,7 +161,7 @@ class KerberosSync: | ||||
|  | ||||
|         user_count = 0 | ||||
|         with Krb5ConfContext(self._source): | ||||
|             for principal in self._connection.list_principals(None): | ||||
|             for principal in self._connection.principals(): | ||||
|                 if self._handle_principal(principal): | ||||
|                     user_count += 1 | ||||
|         return user_count | ||||
|  | ||||
| @ -23,7 +23,6 @@ class TestKerberosAuth(KerberosTestCase): | ||||
|         ) | ||||
|         self.user = User.objects.create(username=generate_id()) | ||||
|         self.user.set_unusable_password() | ||||
|         self.user.save() | ||||
|         UserKerberosSourceConnection.objects.create( | ||||
|             source=self.source, user=self.user, identifier=self.realm.user_princ | ||||
|         ) | ||||
|  | ||||
| @ -2,8 +2,6 @@ | ||||
|  | ||||
| from base64 import b64decode, b64encode | ||||
| from pathlib import Path | ||||
| from sys import platform | ||||
| from unittest import skipUnless | ||||
|  | ||||
| import gssapi | ||||
| from django.urls import reverse | ||||
| @ -38,7 +36,6 @@ class TestSPNEGOSource(KerberosTestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     @skipUnless(platform.startswith("linux"), "Requires compatible GSSAPI implementation") | ||||
|     def test_source_login(self): | ||||
|         """test login view""" | ||||
|         response = self.client.get( | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	