Compare commits

..

4 Commits

Author SHA1 Message Date
Connor Peshek
43b73995ba cleanup 2026-02-27 19:18:57 -06:00
Connor Peshek
b0284256ea remove empty exception file 2026-02-26 16:53:51 -06:00
Connor Peshek
ad4f81e5b0 update imports 2026-02-26 04:27:17 -06:00
Connor Peshek
a98b8fccdf providers/saml: verify logoutrequest/response signatures 2026-02-26 03:29:43 -06:00
303 changed files with 22684 additions and 155276 deletions

View File

@@ -22,7 +22,7 @@ runs:
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v5
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v5
with:
enable-cache: true
- name: Setup python
@@ -34,29 +34,17 @@ runs:
if: ${{ contains(inputs.dependencies, 'python') }}
shell: bash
run: uv sync --all-extras --dev --frozen
- name: Setup node (web)
- name: Setup node
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
registry-url: "https://registry.npmjs.org"
- name: Setup node (root)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
if: ${{ contains(inputs.dependencies, 'node') }}
shell: bash
run: npm ci
registry-url: 'https://registry.npmjs.org'
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5
with:
go-version-file: "go.mod"
- name: Setup docker cache

1
.github/codespell-dictionary.txt vendored Normal file
View File

@@ -0,0 +1 @@
authentic->authentik

32
.github/codespell-words.txt vendored Normal file
View File

@@ -0,0 +1,32 @@
akadmin
asgi
assertIn
authentik
authn
crate
docstrings
entra
goauthentik
gunicorn
hass
jwe
jwks
keypair
keypairs
kubernetes
oidc
ontext
openid
passwordless
plex
saml
scim
singed
slo
sso
totp
traefik
# https://github.com/codespell-project/codespell/issues/1224
upToDate
warmup
webauthn

View File

@@ -43,8 +43,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -56,23 +56,23 @@ jobs:
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
@@ -80,7 +80,7 @@ jobs:
make gen-client-ts
make gen-client-go
- name: Build Docker Image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
id: push
with:
context: .
@@ -95,7 +95,7 @@ jobs:
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -79,13 +79,13 @@ jobs:
image-name: ${{ inputs.image_name }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -97,7 +97,7 @@ jobs:
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}

View File

@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -55,7 +55,7 @@ jobs:
env:
NODE_ENV: production
run: npm run build -w api
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
with:
name: api-docs
path: website/api/build
@@ -67,11 +67,11 @@ jobs:
- build
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v5
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

@@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: lifecycle/aws/package.json
cache: "npm"

View File

@@ -36,7 +36,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -53,7 +53,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -77,9 +77,9 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -89,14 +89,14 @@ jobs:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
@@ -105,7 +105,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -31,7 +31,7 @@ jobs:
job:
- bandit
- black
- spellcheck
- codespell
- pending-migrations
- ruff
- mypy
@@ -279,7 +279,7 @@ jobs:
with:
flags: conformance
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- name: Setup authentik env
@@ -90,9 +90,9 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -102,7 +102,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -111,7 +111,7 @@ jobs:
run: make gen-client-go
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
@@ -122,7 +122,7 @@ jobs:
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@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
@@ -148,10 +148,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -32,7 +32,7 @@ jobs:
project: web
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
@@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -77,7 +77,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -35,13 +35,13 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json

View File

@@ -33,9 +33,9 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -44,21 +44,21 @@ jobs:
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
if: true
with:
@@ -84,18 +84,18 @@ jobs:
- rac
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -108,18 +108,18 @@ jobs:
make gen-client-ts
make gen-client-go
- name: Docker Login Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
id: push
with:
push: true
@@ -129,7 +129,7 @@ jobs:
file: lifecycle/container/${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
@@ -152,10 +152,10 @@ jobs:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -180,7 +180,7 @@ jobs:
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}

View File

@@ -91,7 +91,6 @@ jobs:
# ID from https://api.github.com/users/authentik-automation[bot]
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
git pull
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
git push --follow-tags
@@ -175,7 +174,7 @@ jobs:
if: "${{ inputs.release_reason == 'feature' }}"
run: |
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
reason="${{ inputs.release_reason }}"
reason="{{ inputs.release_reason }}"
jq \
--arg version "${{ inputs.version }}" \
--arg changelog "See ${changelog_url}" \
@@ -187,7 +186,7 @@ jobs:
if: "${{ inputs.release_reason != 'feature' }}"
run: |
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
reason="${{ inputs.release_reason }}"
reason="{{ inputs.release_reason }}"
jq \
--arg version "${{ inputs.version }}" \
--arg changelog "See ${changelog_url}" \

3
.gitignore vendored
View File

@@ -15,9 +15,6 @@ media
node_modules
.cspellcache
cspell-report.*
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/

12
.vscode/settings.json vendored
View File

@@ -14,10 +14,6 @@
"[xml]": {
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
},
"files.associations": {
// The built-in "ignore" language gives us enough syntax highlighting to make these files readable.
"**/dictionaries/*.txt": "ignore"
},
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [
@@ -53,9 +49,13 @@
"ignoreCase": false
}
],
"go.testFlags": ["-count=1"],
"go.testFlags": [
"-count=1"
],
"go.testEnvVars": {
"WORKSPACE_DIR": "${workspaceFolder}"
},
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
"github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
]
}

View File

@@ -77,12 +77,12 @@ test: ## Run the server tests and produce a coverage report (locally)
$(UV) run coverage html
$(UV) run coverage report
lint-fix: lint-spellcheck ## Lint and automatically fix errors in the python source code. Reports spelling errors.
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
$(UV) run black $(PY_SOURCES)
$(UV) run ruff check --fix $(PY_SOURCES)
lint-spellcheck: ## Reports spelling errors.
npm run lint:spellcheck
lint-codespell: ## Reports spelling errors.
$(UV) run codespell -w
lint: ci-bandit ci-mypy ## Lint the python and golang sources
golangci-lint run -v
@@ -168,22 +168,12 @@ gen-build: ## Extract the schema from the database
gen-compose:
$(UV) run scripts/generate_compose.py
gen-changelog: ## (Release) generate the changelog based from the commits since the last version
# These are best-effort guesses based on commit messages
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
$(eval current_commit := $(shell git rev-parse HEAD))
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${current_commit} > merged_to_current
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${last_version} > merged_to_last
grep -Eo 'cherry-pick (#\d+)' merged_to_last | cut -d ' ' -f 2 | sed 's/.*/(&)$$/' > cherry_picked_to_last
grep -vf cherry_picked_to_last merged_to_current | sort > changelog.md
rm merged_to_current
rm merged_to_last
rm cherry_picked_to_last
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
npx prettier --write changelog.md
gen-diff: ## (Release) generate the changelog diff between the current schema and the last version
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
git show ${last_version}:schema.yml > schema-old.yml
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
@@ -286,7 +276,7 @@ docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Au
docs-install:
npm ci --prefix website
docs-lint-fix: lint-spellcheck
docs-lint-fix: lint-codespell
npm run --prefix website prettier
docs-build:
@@ -343,8 +333,8 @@ ci-black: ci--meta-debug
ci-ruff: ci--meta-debug
$(UV) run ruff check $(PY_SOURCES)
ci-spellcheck: ci--meta-debug
npm run lint:spellcheck
ci-codespell: ci--meta-debug
$(UV) run codespell -s
ci-bandit: ci--meta-debug
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii

View File

@@ -100,25 +100,13 @@ class S3Backend(ManageableBackend):
f"storage.{self.usage.value}.{self.name}.addressing_style",
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
)
signature_version = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.signature_version",
CONFIG.get(f"storage.{self.name}.signature_version", "s3v4"),
)
# Keep signature_version pass-through and let boto3/botocore handle it.
# In boto3's S3 configuration docs, `s3v4` (default) and deprecated `s3`
# are the documented values:
# https://github.com/boto/boto3/blob/791a3e8f36d83664a47b4281a0586b3546cef3ec/docs/source/guide/configuration.rst?plain=1#L398-L407
# Botocore also supports additional signer names, so we intentionally do
# not enforce a restricted allowlist here.
return self.session.client(
"s3",
endpoint_url=endpoint_url,
use_ssl=use_ssl,
region_name=region_name,
config=Config(
signature_version=signature_version, s3={"addressing_style": addressing_style}
),
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
)
@property

View File

@@ -1,6 +1,5 @@
from unittest import skipUnless
from botocore.exceptions import UnsupportedSignatureVersionError
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
@@ -82,27 +81,6 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
self.assertIn("X-Amz-Signature=", url)
self.assertIn("test.png", url)
def test_client_signature_version_default_v4(self):
"""Test S3 client defaults to v4 signature when not configured."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")
@CONFIG.patch("storage.s3.signature_version", "s3")
def test_client_signature_version_global_override(self):
"""Test S3 client respects globally configured signature version."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
@CONFIG.patch("storage.s3.signature_version", "s3v4")
@CONFIG.patch("storage.media.s3.signature_version", "s3")
def test_client_signature_version_media_override(self):
"""Test usage-specific signature version takes precedence over global."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3")
@CONFIG.patch("storage.media.s3.signature_version", "not-a-real-signature")
def test_client_signature_version_unsupported(self):
"""Test unsupported signature version raises botocore error."""
with self.assertRaises(UnsupportedSignatureVersionError):
self.media_s3_backend.file_url("test.png", use_cache=False)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""

View File

@@ -1,4 +1,4 @@
"""authentik SAML IDP Exceptions"""
"""Common SAML Exceptions"""
from authentik.lib.sentry import SentryIgnoredException

View File

@@ -0,0 +1,81 @@
"""LogoutResponse parser"""
import binascii
import zlib
from base64 import b64decode
from dataclasses import dataclass
from defusedxml import ElementTree
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
from authentik.common.saml.exceptions import CannotHandleAssertion
@dataclass(slots=True)
class LogoutResponse:
"""Logout Response"""
id: str | None = None
in_response_to: str | None = None
issuer: str | None = None
status: str | None = None
relay_state: str | None = None
class LogoutResponseParser:
"""LogoutResponse Parser"""
def _parse_xml(
self, decoded_xml: str | bytes, relay_state: str | None = None
) -> LogoutResponse:
root = ElementTree.fromstring(decoded_xml)
response = LogoutResponse(
id=root.attrib.get("ID"),
in_response_to=root.attrib.get("InResponseTo"),
)
# Extract Issuer
issuers = root.findall(f"{{{NS_SAML_ASSERTION}}}Issuer")
if not issuers:
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
if len(issuers) > 0:
response.issuer = issuers[0].text
# Extract Status
status_elements = root.findall(f"{{{NS_SAML_PROTOCOL}}}Status")
if len(status_elements) > 0:
status_codes = status_elements[0].findall(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
if len(status_codes) > 0:
response.status = status_codes[0].attrib.get("Value")
response.relay_state = relay_state
return response
def parse(self, saml_response: str, relay_state: str | None = None) -> LogoutResponse:
"""Validate and parse raw response with enveloped signature (POST binding)."""
try:
decoded_xml = b64decode(saml_response.encode())
except UnicodeDecodeError, binascii.Error:
raise CannotHandleAssertion("Cannot decode SAML response.") from None
return self._parse_xml(decoded_xml, relay_state)
def parse_detached(self, saml_response: str, relay_state: str | None = None) -> LogoutResponse:
"""Validate and parse raw response with detached signature (Redirect binding)."""
try:
decoded_data = b64decode(saml_response)
try:
decoded_xml = zlib.decompress(decoded_data, -15).decode("utf-8")
except zlib.error:
decoded_xml = decoded_data.decode("utf-8")
except UnicodeDecodeError, binascii.Error, zlib.error:
raise CannotHandleAssertion("Cannot decode SAML response.") from None
return self._parse_xml(decoded_xml, relay_state)
def verify_status(self, response: LogoutResponse):
"""Verify that the LogoutResponse has a successful status."""
if response.status != SAML_STATUS_SUCCESS:
raise CannotHandleAssertion(f"LogoutResponse status is not success: {response.status}")

View File

@@ -0,0 +1,93 @@
"""Shared SAML signature verification utilities"""
from base64 import b64decode
from urllib.parse import quote_plus
import xmlsec
from authentik.common.saml.constants import (
NS_MAP,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.lib.xml import lxml_from_string
def verify_enveloped_signature(raw_xml: bytes, verification_kp, xpath: str):
"""Verify an enveloped XML signature.
Args:
raw_xml: The raw XML bytes
verification_kp: CertificateKeyPair with certificate_data
xpath: XPath to signature node, e.g. '/samlp:LogoutRequest/ds:Signature'
"""
root = lxml_from_string(raw_xml)
xmlsec.tree.add_ids(root, ["ID"])
signature_nodes = root.xpath(xpath, namespaces=NS_MAP)
if len(signature_nodes) < 1:
raise CannotHandleAssertion(
"Verification Certificate configured, but message is not signed."
)
signature_node = signature_nodes[0]
try:
ctx = xmlsec.SignatureContext()
key = xmlsec.Key.from_memory(
verification_kp.certificate_data,
xmlsec.constants.KeyDataFormatCertPem,
None,
)
ctx.key = key
ctx.verify(signature_node)
except xmlsec.Error as exc:
raise CannotHandleAssertion("Failed to verify signature") from exc
def verify_detached_signature(
saml_param_name: str,
saml_value: str,
relay_state: str | None,
signature: str | None,
sig_alg: str | None,
verification_kp,
):
"""Verify a detached redirect-binding signature.
Args:
saml_param_name: "SAMLRequest" or "SAMLResponse"
saml_value: The raw base64+deflated SAML message value
relay_state: RelayState value, if present
signature: Base64-encoded signature from query params
sig_alg: Signature algorithm URI from query params
verification_kp: CertificateKeyPair with certificate_data
"""
if not (signature and sig_alg):
raise CannotHandleAssertion(
"Verification Certificate configured, but message is not signed."
)
querystring = f"{saml_param_name}={quote_plus(saml_value)}&"
if relay_state is not None:
querystring += f"RelayState={quote_plus(relay_state)}&"
querystring += f"SigAlg={quote_plus(sig_alg)}"
dsig_ctx = xmlsec.SignatureContext()
key = xmlsec.Key.from_memory(
verification_kp.certificate_data, xmlsec.constants.KeyDataFormatCertPem, None
)
dsig_ctx.key = key
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
sig_alg, xmlsec.constants.TransformRsaSha1
)
try:
dsig_ctx.verify_binary(
querystring.encode("utf-8"),
sign_algorithm_transform,
b64decode(signature),
)
except xmlsec.Error as exc:
raise CannotHandleAssertion("Failed to verify signature") from exc

View File

@@ -17,6 +17,7 @@ from django.contrib.sessions.base_session import AbstractBaseSession
from django.core.validators import validate_slug
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -44,7 +45,6 @@ from authentik.lib.models import (
DomainlessFormattedURLValidator,
SerializerModel,
)
from authentik.lib.utils.inheritance import get_deepest_child
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.rbac.models import Role
@@ -803,7 +803,25 @@ class Application(SerializerModel, PolicyBindingModel):
"""Get casted provider instance. Needs Application queryset with_provider"""
if not self.provider:
return None
return get_deepest_child(self.provider)
candidates = []
base_class = Provider
for subclass in base_class.objects.get_queryset()._get_subclasses_recurse(base_class):
parent = self.provider
for level in subclass.split(LOOKUP_SEP):
try:
parent = getattr(parent, level)
except AttributeError:
break
if parent in candidates:
continue
idx = subclass.count(LOOKUP_SEP)
if type(parent) is not base_class:
idx += 1
candidates.insert(idx, parent)
if not candidates:
return None
return candidates[-1]
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
"""Get Backchannel provider for a specific type"""

View File

@@ -78,7 +78,7 @@ def generate_key_id_legacy(key_data: str) -> str:
"""Generate Key ID using MD5 (legacy format for backwards compatibility)."""
if not key_data:
return ""
return md5(key_data.encode("utf-8"), usedforsecurity=False).hexdigest() # nosec
return md5(key_data.encode("utf-8")).hexdigest() # nosec
class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):

View File

@@ -1,11 +1,8 @@
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.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.controller import Capabilities
from authentik.endpoints.models import Connector, EndpointStage
from authentik.endpoints.models import EndpointStage
from authentik.flows.api.stages import StageSerializer
@@ -14,13 +11,6 @@ class EndpointStageSerializer(StageSerializer):
connector_obj = ConnectorSerializer(source="connector", read_only=True)
def validate_connector(self, connector: Connector) -> Connector:
conn: Connector = Connector.objects.get_subclass(pk=connector.pk)
controller = conn.controller(conn)
if Capabilities.STAGE_ENDPOINTS not in controller.capabilities():
raise ValidationError(_("Selected connector is not compatible with this stage."))
return connector
class Meta:
model = EndpointStage
fields = StageSerializer.Meta.fields + [

View File

@@ -8,7 +8,7 @@ from rest_framework.fields import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
from authentik.endpoints.controller import BaseController, Capabilities
from authentik.endpoints.controller import BaseController
from authentik.endpoints.facts import OSFamily
@@ -48,8 +48,8 @@ class AgentConnectorController(BaseController[AgentConnector]):
def vendor_identifier() -> str:
return "goauthentik.io/platform"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.STAGE_ENDPOINTS]
def supported_enrollment_methods(self):
return []
def generate_mdm_config(
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken

View File

@@ -8,15 +8,13 @@ from authentik.lib.sentry import SentryIgnoredException
MERGED_VENDOR = "goauthentik.io/@merged"
class Capabilities(models.TextChoices):
class EnrollmentMethods(models.TextChoices):
# Automatically enrolled through user action
ENROLL_AUTOMATIC_USER = "enroll_automatic_user"
AUTOMATIC_USER = "automatic_user"
# Automatically enrolled through connector integration
ENROLL_AUTOMATIC_API = "enroll_automatic_api"
AUTOMATIC_API = "automatic_api"
# Manually enrolled with user interaction (user scanning a QR code for example)
ENROLL_MANUAL_USER = "enroll_manual_user"
# Supported for use with Endpoints stage
STAGE_ENDPOINTS = "stage_endpoints"
MANUAL_USER = "manual_user"
class ConnectorSyncException(SentryIgnoredException):
@@ -36,7 +34,7 @@ class BaseController[T: "Connector"]:
def vendor_identifier() -> str:
raise NotImplementedError
def capabilities(self) -> list[Capabilities]:
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
return []
def stage_view_enrollment(self) -> StageView | None:

View File

@@ -63,7 +63,7 @@ class OperatingSystemSerializer(Serializer):
"Operating System version, must always be the version number but may contain build name"
),
)
arch = CharField(required=False)
arch = CharField(required=True)
class NetworkInterfaceSerializer(Serializer):

View File

@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.endpoints.controller import Capabilities
from authentik.endpoints.controller import EnrollmentMethods
from authentik.endpoints.models import Connector
LOGGER = get_logger()
@@ -21,7 +21,7 @@ def endpoints_sync(connector_pk: Any):
return
controller = connector.controller
ctrl = controller(connector)
if Capabilities.AUTOMATIC_API not in ctrl.capabilities():
if EnrollmentMethods.AUTOMATIC_API not in ctrl.supported_enrollment_methods():
return
LOGGER.info("Syncing connector", connector=connector.name)
ctrl.sync_endpoints()

View File

@@ -1,41 +0,0 @@
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.models import StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.lib.generators import generate_id
class TestAPI(APITestCase):
def setUp(self):
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_endpoint_stage_agent(self):
connector = AgentConnector.objects.create(name=generate_id())
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
self.assertEqual(res.status_code, 201)
def test_endpoint_stage_fleet(self):
connector = FleetConnector.objects.create(name=generate_id())
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"connector": ["Selected connector is not compatible with this stage."]}
)

View File

@@ -6,7 +6,7 @@ from requests import RequestException
from rest_framework.exceptions import ValidationError
from authentik.core.models import User
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
from authentik.endpoints.controller import BaseController, ConnectorSyncException, EnrollmentMethods
from authentik.endpoints.facts import (
DeviceFacts,
OSFamily,
@@ -43,8 +43,8 @@ class FleetController(BaseController[DBC]):
def vendor_identifier() -> str:
return "fleetdm.com"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.ENROLL_AUTOMATIC_API]
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
return [EnrollmentMethods.AUTOMATIC_API]
def _url(self, path: str) -> str:
return f"{self.connector.url}{path}"

View File

@@ -1,42 +0,0 @@
"""GoogleChromeConnector API Views"""
from django.urls import reverse
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.endpoints.connectors.google_chrome.models import GoogleChromeConnector
class GoogleChromeConnectorSerializer(EnterpriseRequiredMixin, ConnectorSerializer):
"""GoogleChromeConnector Serializer"""
chrome_url = SerializerMethodField()
def get_chrome_url(self, _: GoogleChromeConnector) -> str | None:
"""Full URL to be used in Google Workspace configuration"""
request: Request = self.context.get("request", None)
if not request:
return True
return request.build_absolute_uri(
reverse("authentik_endpoints_connectors_google_chrome:chrome")
)
class Meta:
model = GoogleChromeConnector
fields = ConnectorSerializer.Meta.fields + ["credentials", "chrome_url"]
class GoogleChromeConnectorViewSet(UsedByMixin, ModelViewSet):
"""GoogleChromeConnector Viewset"""
queryset = GoogleChromeConnector.objects.all()
serializer_class = GoogleChromeConnectorSerializer
filterset_fields = [
"name",
]
search_fields = ["name"]
ordering = ["name"]

View File

@@ -1,13 +0,0 @@
"""authentik Endpoint app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEndpointsConnectorGoogleChromeAppConfig(EnterpriseConfig):
"""authentik endpoint config"""
name = "authentik.enterprise.endpoints.connectors.google_chrome"
label = "authentik_endpoints_connectors_google_chrome"
verbose_name = "authentik Enterprise.Endpoints.Connectors.Google Chrome"
default = True
mountpoint = "endpoints/google/"

View File

@@ -1,116 +0,0 @@
from json import dumps, loads
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse
from googleapiclient.discovery import build
from authentik.endpoints.controller import BaseController, Capabilities
from authentik.endpoints.facts import DeviceFacts, OSFamily
from authentik.endpoints.models import Device, DeviceConnection
from authentik.enterprise.endpoints.connectors.google_chrome.google_schema import (
DeviceSignals,
VerifyChallengeResponseResult,
)
from authentik.enterprise.endpoints.connectors.google_chrome.models import GoogleChromeConnector
from authentik.policies.utils import delete_none_values
# Header we get from chrome that initiates verified access
HEADER_DEVICE_TRUST = "X-Device-Trust"
# Header we send to the client with the challenge
HEADER_ACCESS_CHALLENGE = "X-Verified-Access-Challenge"
# Header we get back from the client that we verify with google
HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
# Header value for x-device-trust that initiates the flow
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
class GoogleChromeController(BaseController[GoogleChromeConnector]):
def __init__(self, connector):
super().__init__(connector)
self.google_client = build(
"verifiedaccess",
"v2",
cache_discovery=False,
**connector.google_credentials(),
)
@staticmethod
def vendor_identifier() -> str:
return "chrome.google.com"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_USER]
def generate_challenge(self, request: HttpRequest) -> HttpResponseRedirect:
challenge = self.google_client.challenge().generate().execute()
res = HttpResponseRedirect(
request.build_absolute_uri(
reverse("authentik_endpoints_connectors_google_chrome:chrome")
)
)
res[HEADER_ACCESS_CHALLENGE] = dumps(challenge)
return res
def validate_challenge(self, response: str) -> Device:
response = VerifyChallengeResponseResult(
self.google_client.challenge().verify(body=loads(response)).execute()
)
# Remove deprecated string representation of deviceSignals
response.pop("deviceSignal", None)
signals = DeviceSignals(response["deviceSignals"])
device, _ = Device.objects.update_or_create(
identifier=signals["serialNumber"],
defaults={
"name": signals["hostname"],
},
)
conn, _ = DeviceConnection.objects.update_or_create(
device=device,
connector=self.connector,
)
conn.create_snapshot(self.convert_data(signals))
return device
def convert_os_family(self, family) -> OSFamily:
return {
"CHROME_OS": OSFamily.linux,
"CHROMIUM_OS": OSFamily.linux,
"WINDOWS": OSFamily.windows,
"MAC_OS_X": OSFamily.macOS,
"LINUX": OSFamily.linux,
}.get(family, OSFamily.other)
def convert_data(self, raw_signals: DeviceSignals):
data = {
"os": delete_none_values(
{
"family": self.convert_os_family(raw_signals["operatingSystem"]),
"version": raw_signals["osVersion"],
}
),
"disks": [],
"network": delete_none_values(
{
"hostname": raw_signals["hostname"],
"interfaces": [],
"firewall_enabled": raw_signals["osFirewall"] == "OS_FIREWALL_ENABLED",
},
),
"hardware": delete_none_values(
{
"model": raw_signals["deviceModel"],
"manufacturer": raw_signals["deviceManufacturer"],
"serial": raw_signals["serialNumber"],
}
),
"vendor": {
self.vendor_identifier(): {
"agent_version": raw_signals["browserVersion"],
"raw": raw_signals,
},
},
}
facts = DeviceFacts(data=data)
facts.is_valid(raise_exception=True)
return facts.validated_data

View File

@@ -1,129 +0,0 @@
from typing import Literal, TypedDict
# Based on https://github.com/henribru/google-api-python-client-stubs/blob/master/googleapiclient-stubs/_apis/verifiedaccess/v2/schemas.pyi
class Antivirus(TypedDict, total=False):
state: Literal["STATE_UNSPECIFIED", "MISSING", "DISABLED", "ENABLED"]
class Challenge(TypedDict, total=False):
challenge: str
class CrowdStrikeAgent(TypedDict, total=False):
agentId: str
customerId: str
class DeviceSignals(TypedDict, total=False):
allowScreenLock: bool
antivirus: Antivirus
browserVersion: str
builtInDnsClientEnabled: bool
chromeRemoteDesktopAppBlocked: bool
crowdStrikeAgent: CrowdStrikeAgent
deviceAffiliationIds: list[str]
deviceEnrollmentDomain: str
deviceManufacturer: str
deviceModel: str
diskEncryption: Literal[
"DISK_ENCRYPTION_UNSPECIFIED",
"DISK_ENCRYPTION_UNKNOWN",
"DISK_ENCRYPTION_DISABLED",
"DISK_ENCRYPTION_ENCRYPTED",
]
displayName: str
hostname: str
imei: list[str]
macAddresses: list[str]
meid: list[str]
operatingSystem: Literal[
"OPERATING_SYSTEM_UNSPECIFIED",
"CHROME_OS",
"CHROMIUM_OS",
"WINDOWS",
"MAC_OS_X",
"LINUX",
]
osFirewall: Literal[
"OS_FIREWALL_UNSPECIFIED",
"OS_FIREWALL_UNKNOWN",
"OS_FIREWALL_DISABLED",
"OS_FIREWALL_ENABLED",
]
osVersion: str
passwordProtectionWarningTrigger: Literal[
"PASSWORD_PROTECTION_WARNING_TRIGGER_UNSPECIFIED",
"POLICY_UNSET",
"PASSWORD_PROTECTION_OFF",
"PASSWORD_REUSE",
"PHISHING_REUSE",
]
profileAffiliationIds: list[str]
profileEnrollmentDomain: str
realtimeUrlCheckMode: Literal[
"REALTIME_URL_CHECK_MODE_UNSPECIFIED",
"REALTIME_URL_CHECK_MODE_DISABLED",
"REALTIME_URL_CHECK_MODE_ENABLED_MAIN_FRAME",
]
safeBrowsingProtectionLevel: Literal[
"SAFE_BROWSING_PROTECTION_LEVEL_UNSPECIFIED", "INACTIVE", "STANDARD", "ENHANCED"
]
screenLockSecured: Literal[
"SCREEN_LOCK_SECURED_UNSPECIFIED",
"SCREEN_LOCK_SECURED_UNKNOWN",
"SCREEN_LOCK_SECURED_DISABLED",
"SCREEN_LOCK_SECURED_ENABLED",
]
secureBootMode: Literal[
"SECURE_BOOT_MODE_UNSPECIFIED",
"SECURE_BOOT_MODE_UNKNOWN",
"SECURE_BOOT_MODE_DISABLED",
"SECURE_BOOT_MODE_ENABLED",
]
serialNumber: str
siteIsolationEnabled: bool
systemDnsServers: list[str]
thirdPartyBlockingEnabled: bool
trigger: Literal["TRIGGER_UNSPECIFIED", "TRIGGER_BROWSER_NAVIGATION", "TRIGGER_LOGIN_SCREEN"]
windowsMachineDomain: str
windowsUserDomain: str
class Empty(TypedDict, total=False): ...
class VerifyChallengeResponseRequest(TypedDict, total=False):
challengeResponse: str
expectedIdentity: str
class VerifyChallengeResponseResult(TypedDict, total=False):
attestedDeviceId: str
customerId: str
deviceEnrollmentId: str
devicePermanentId: str
deviceSignal: str
deviceSignals: DeviceSignals
keyTrustLevel: Literal[
"KEY_TRUST_LEVEL_UNSPECIFIED",
"CHROME_OS_VERIFIED_MODE",
"CHROME_OS_DEVELOPER_MODE",
"CHROME_BROWSER_HW_KEY",
"CHROME_BROWSER_OS_KEY",
"CHROME_BROWSER_NO_KEY",
]
profileCustomerId: str
profileKeyTrustLevel: Literal[
"KEY_TRUST_LEVEL_UNSPECIFIED",
"CHROME_OS_VERIFIED_MODE",
"CHROME_OS_DEVELOPER_MODE",
"CHROME_BROWSER_HW_KEY",
"CHROME_BROWSER_OS_KEY",
"CHROME_BROWSER_NO_KEY",
]
profilePermanentId: str
signedPublicKeyAndChallenge: str
virtualDeviceId: str
virtualProfileId: str

View File

@@ -1,38 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-01 18:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_endpoints", "0004_deviceaccessgroup_attributes"),
]
operations = [
migrations.CreateModel(
name="GoogleChromeConnector",
fields=[
(
"connector_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_endpoints.connector",
),
),
("credentials", models.JSONField()),
],
options={
"verbose_name": "Google Device Trust Connector",
"verbose_name_plural": "Google Device Trust Connectors",
},
bases=("authentik_endpoints.connector",),
),
]

View File

@@ -1,69 +0,0 @@
"""Endpoint stage"""
from typing import TYPE_CHECKING
from django.db import models
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from google.oauth2.service_account import Credentials
from rest_framework.serializers import BaseSerializer
from authentik.endpoints.models import Connector
from authentik.flows.stage import StageView
if TYPE_CHECKING:
from authentik.enterprise.endpoints.connectors.google_chrome.controller import (
GoogleChromeController,
)
class GoogleChromeConnector(Connector):
"""Verify Google Chrome Device Trust connection for the user's browser."""
credentials = models.JSONField()
def google_credentials(self):
return {
"credentials": Credentials.from_service_account_info(
self.credentials, scopes=["https://www.googleapis.com/auth/verifiedaccess"]
),
}
@property
def icon_url(self):
return static("authentik/sources/google.svg")
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.endpoints.connectors.google_chrome.api import (
GoogleChromeConnectorSerializer,
)
return GoogleChromeConnectorSerializer
@property
def stage(self) -> type[StageView] | None:
from authentik.enterprise.endpoints.connectors.google_chrome.stage import (
GoogleChromeStageView,
)
return GoogleChromeStageView
@property
def controller(self) -> type[GoogleChromeController]:
from authentik.enterprise.endpoints.connectors.google_chrome.controller import (
GoogleChromeController,
)
return GoogleChromeController
@property
def component(self) -> str:
return "ak-endpoints-connector-gdtc-form"
def __str__(self) -> str:
return f"Google Device Trust Connector {self.name}"
class Meta:
verbose_name = _("Google Device Trust Connector")
verbose_name_plural = _("Google Device Trust Connectors")

View File

@@ -1,32 +0,0 @@
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
FrameChallenge,
FrameChallengeResponse,
)
from authentik.flows.stage import ChallengeStageView
class GoogleChromeStageView(ChallengeStageView):
"""Endpoint stage"""
response_class = FrameChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
return FrameChallenge(
data={
"component": "xak-flow-frame",
"url": self.request.build_absolute_uri(
reverse("authentik_endpoints_connectors_google_chrome:chrome")
),
"loading_overlay": True,
"loading_text": _("Verifying your browser..."),
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@@ -1,36 +0,0 @@
{
"devicePermanentId": "6f30327d-e436-4f7a-9f89-c37a7b6bf408",
"keyTrustLevel": "CHROME_BROWSER_HW_KEY",
"virtualDeviceId": "Z5DDF07GK6",
"customerId": "qewrqer",
"deviceSignals": {
"deviceManufacturer": "Apple Inc.",
"deviceModel": "MacBookPro18,1",
"operatingSystem": "MAC_OS_X",
"osVersion": "26.2.0",
"displayName": "jens-mac-vm",
"diskEncryption": "DISK_ENCRYPTION_ENCRYPTED",
"serialNumber": "Z5DDF07GK6",
"osFirewall": "OS_FIREWALL_DISABLED",
"systemDnsServers": [
"10.120.20.250:53"
],
"hostname": "jens-mac-vm.lab.beryju.org",
"macAddresses": [
"f4:d4:88:79:07:0e"
],
"screenLockSecured": "SCREEN_LOCK_SECURED_ENABLED",
"deviceEnrollmentDomain": "beryju.org",
"browserVersion": "145.0.7632.76",
"deviceAffiliationIds": [
"qewrqer"
],
"builtInDnsClientEnabled": true,
"chromeRemoteDesktopAppBlocked": false,
"safeBrowsingProtectionLevel": "STANDARD",
"siteIsolationEnabled": true,
"passwordProtectionWarningTrigger": "POLICY_UNSET",
"realtimeUrlCheckMode": "REALTIME_URL_CHECK_MODE_DISABLED",
"trigger": "TRIGGER_BROWSER_NAVIGATION"
}
}

View File

@@ -1,67 +0,0 @@
from json import dumps
from unittest.mock import MagicMock, patch
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import RequestFactory
from authentik.endpoints.facts import OSFamily
from authentik.endpoints.models import Device
from authentik.enterprise.endpoints.connectors.google_chrome.controller import (
HEADER_ACCESS_CHALLENGE,
GoogleChromeController,
)
from authentik.enterprise.endpoints.connectors.google_chrome.models import GoogleChromeConnector
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class TestGoogleChromeConnector(APITestCase):
def setUp(self):
self.connector = GoogleChromeConnector.objects.create(
name=generate_id(),
credentials={},
)
self.factory = RequestFactory()
self.api_key = generate_id()
def test_generate_challenge(self):
req = self.factory.get("/")
challenge = generate_id()
http = MockHTTP()
http.add_response(
f"https://verifiedaccess.googleapis.com/v2/challenge:generate?key={self.api_key}&alt=json",
{"challenge": challenge},
method="POST",
)
with patch(
"authentik.enterprise.endpoints.connectors.google_chrome.models.GoogleChromeConnector.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
controller = GoogleChromeController(self.connector)
res = controller.generate_challenge(req)
self.assertEqual(
res["Location"],
req.build_absolute_uri(
reverse("authentik_endpoints_connectors_google_chrome:chrome")
),
)
self.assertEqual(res.headers[HEADER_ACCESS_CHALLENGE], dumps({"challenge": challenge}))
def test_validate_challenge(self):
http = MockHTTP()
http.add_response(
f"https://verifiedaccess.googleapis.com/v2/challenge:verify?key={self.api_key}&alt=json",
load_fixture("fixtures/host_macos.json"),
method="POST",
)
with patch(
"authentik.enterprise.endpoints.connectors.google_chrome.models.GoogleChromeConnector.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
controller = GoogleChromeController(self.connector)
controller.validate_challenge(dumps("{}"))
device = Device.objects.get(identifier="Z5DDF07GK6")
self.assertIsNotNone(device)
self.assertEqual(device.cached_facts.data["os"]["family"], OSFamily.macOS)

View File

@@ -1,91 +0,0 @@
from json import dumps
from unittest.mock import MagicMock, patch
from django.urls import reverse
from authentik.core.tests.utils import RequestFactory, create_test_flow
from authentik.endpoints.models import Device, EndpointStage
from authentik.enterprise.endpoints.connectors.google_chrome.models import GoogleChromeConnector
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class TestChromeDTCView(FlowTestCase):
def setUp(self):
self.flow = create_test_flow()
self.connector = GoogleChromeConnector.objects.create(
name=generate_id(),
credentials={},
)
self.factory = RequestFactory()
self.api_key = generate_id()
self.stage = EndpointStage.objects.create(
name=generate_id(),
connector=self.connector,
)
FlowStageBinding.objects.create(
target=self.flow,
stage=self.stage,
order=0,
)
def test_dtc_generate_verify(self):
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
self.assertStageResponse(
res,
self.flow,
component="xak-flow-frame",
url="http://testserver/endpoints/google/chrome/",
)
challenge = generate_id()
http = MockHTTP()
http.add_response(
f"https://verifiedaccess.googleapis.com/v2/challenge:generate?key={self.api_key}&alt=json",
{"challenge": challenge},
method="POST",
)
http.add_response(
f"https://verifiedaccess.googleapis.com/v2/challenge:verify?key={self.api_key}&alt=json",
load_fixture("fixtures/host_macos.json"),
method="POST",
)
with patch(
"authentik.enterprise.endpoints.connectors.google_chrome.models.GoogleChromeConnector.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
# Generate challenge
res = self.client.get(
reverse("authentik_endpoints_connectors_google_chrome:chrome"),
HTTP_X_DEVICE_TRUST="VerifiedAccess",
)
self.assertEqual(res.status_code, 302)
self.assertEqual(
res.headers["X-Verified-Access-Challenge"],
dumps({"challenge": challenge}),
)
# Validate challenge
res = self.client.get(
reverse("authentik_endpoints_connectors_google_chrome:chrome"),
HTTP_X_VERIFIED_ACCESS_CHALLENGE_RESPONSE=dumps({}),
)
self.assertEqual(res.status_code, 200)
device = Device.objects.get(identifier="Z5DDF07GK6")
self.assertIsNotNone(device)
# Continue flow
with self.assertFlowFinishes() as plan:
res = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
self.assertStageRedirects(res, "/")
plan = plan()
plan_device = plan.context[PLAN_CONTEXT_DEVICE]
self.assertEqual(device.pk, plan_device.pk)

View File

@@ -1,16 +0,0 @@
"""API URLs"""
from django.urls import path
from authentik.enterprise.endpoints.connectors.google_chrome.api import GoogleChromeConnectorViewSet
from authentik.enterprise.endpoints.connectors.google_chrome.views.dtc import (
GoogleChromeDeviceTrustConnector,
)
urlpatterns = [
path("chrome/", GoogleChromeDeviceTrustConnector.as_view(), name="chrome"),
]
api_urlpatterns = [
("endpoints/google_chrome/connectors", GoogleChromeConnectorViewSet),
]

View File

@@ -1,46 +0,0 @@
from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from authentik.endpoints.models import EndpointStage
from authentik.enterprise.endpoints.connectors.google_chrome.controller import (
HEADER_ACCESS_CHALLENGE_RESPONSE,
HEADER_DEVICE_TRUST,
GoogleChromeController,
)
from authentik.enterprise.endpoints.connectors.google_chrome.models import GoogleChromeConnector
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
@method_decorator(xframe_options_sameorigin, name="dispatch")
class GoogleChromeDeviceTrustConnector(View):
"""Google Chrome Device-trust connector based endpoint authenticator"""
def get_flow_plan(self) -> FlowPlan:
flow_plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
return flow_plan
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
stage: EndpointStage = self.get_flow_plan().bindings[0].stage
connector = GoogleChromeConnector.objects.filter(pk=stage.connector_id).first()
if not connector:
return HttpResponseBadRequest()
self.controller: GoogleChromeController = connector.controller(connector)
def get(self, request: HttpRequest) -> HttpResponse:
x_device_trust = request.headers.get(HEADER_DEVICE_TRUST)
x_access_challenge_response = request.headers.get(HEADER_ACCESS_CHALLENGE_RESPONSE)
if x_device_trust == "VerifiedAccess" and x_access_challenge_response is None:
return self.controller.generate_challenge(request)
if x_access_challenge_response:
device = self.controller.validate_challenge(x_access_challenge_response)
flow_plan = self.get_flow_plan()
flow_plan.context[PLAN_CONTEXT_DEVICE] = device
self.request.session[SESSION_KEY_PLAN] = flow_plan
return TemplateResponse(request, "flows/frame-submit.html")

View File

@@ -331,7 +331,7 @@ class GoogleWorkspaceGroupTests(TestCase):
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 7)
self.assertEqual(len(http.requests()), 5)
def test_sync_discover_multiple(self):
"""Test group discovery"""
@@ -372,7 +372,7 @@ class GoogleWorkspaceGroupTests(TestCase):
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 7)
self.assertEqual(len(http.requests()), 5)
# Change response to trigger update
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",

View File

@@ -309,7 +309,7 @@ class GoogleWorkspaceUserTests(TestCase):
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 7)
self.assertEqual(len(http.requests()), 5)
def test_sync_discover_multiple(self):
"""Test user discovery, running multiple times"""
@@ -352,7 +352,7 @@ class GoogleWorkspaceUserTests(TestCase):
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 7)
self.assertEqual(len(http.requests()), 5)
# Change response, which will trigger a discovery update
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",

View File

@@ -81,8 +81,6 @@ class SignInProcessor:
self.sign_in_request = sign_in_request
self.saml_processor = AssertionProcessor(self.provider, self.request, AuthNRequest())
self.saml_processor.provider.audience = self.sign_in_request.wtrealm
if self.provider.signing_kp:
self.saml_processor.provider.sign_assertion = True
def create_response_token(self):
root = Element(f"{{{NS_WS_FED_TRUST}}}RequestSecurityTokenResponse", nsmap=NS_MAP)
@@ -150,8 +148,7 @@ class SignInProcessor:
def response(self) -> dict[str, str]:
root = self.create_response_token()
assertion = root.xpath("//saml:Assertion", namespaces=NS_MAP)[0]
if self.provider.signing_kp:
self.saml_processor._sign(assertion)
self.saml_processor._sign(assertion)
str_token = etree.tostring(root).decode("utf-8") # nosec
return delete_none_values(
{

View File

@@ -3,7 +3,8 @@
from django.urls import path
from authentik.enterprise.providers.ws_federation.api.providers import WSFederationProviderViewSet
from authentik.enterprise.providers.ws_federation.views import MetadataDownload, WSFedEntryView
from authentik.enterprise.providers.ws_federation.views import WSFedEntryView
from authentik.providers.saml.views.metadata import MetadataDownload
urlpatterns = [
path(

View File

@@ -4,7 +4,6 @@ TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.endpoints.connectors.agent",
"authentik.enterprise.endpoints.connectors.fleet",
"authentik.enterprise.endpoints.connectors.google_chrome",
"authentik.enterprise.lifecycle",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",

View File

@@ -9,11 +9,6 @@ from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from googleapiclient.discovery import build
from authentik.enterprise.endpoints.connectors.google_chrome.controller import (
HEADER_ACCESS_CHALLENGE,
HEADER_ACCESS_CHALLENGE_RESPONSE,
HEADER_DEVICE_TRUST,
)
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,
EndpointDevice,
@@ -24,6 +19,15 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.user_login.stage import PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE
# Header we get from chrome that initiates verified access
HEADER_DEVICE_TRUST = "X-Device-Trust"
# Header we send to the client with the challenge
HEADER_ACCESS_CHALLENGE = "X-Verified-Access-Challenge"
# Header we get back from the client that we verify with google
HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
# Header value for x-device-trust that initiates the flow
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
PLAN_CONTEXT_METHOD_ARGS_ENDPOINTS = "endpoints"
@@ -90,4 +94,4 @@ class GoogleChromeDeviceTrustConnector(View):
PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE, True
)
request.session[SESSION_KEY_PLAN] = flow_plan
return TemplateResponse(request, "flows/frame-submit.html")
return TemplateResponse(request, "stages/authenticator_endpoint/google_chrome_dtc.html")

View File

@@ -29,12 +29,6 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
visibility = "public"
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
default = False
visibility = "public"
class AuthentikFlowsConfig(ManagedAppConfig):
"""authentik flows app config"""

View File

@@ -166,7 +166,6 @@ storage:
# region: "us-east-1"
# use_ssl: True
# endpoint: "https://s3.us-east-1.amazonaws.com"
# signature_version: "s3v4"
# access_key: ""
# secret_key: ""
# bucket_name: "authentik-data"

View File

@@ -103,7 +103,6 @@ class SyncTasks:
)
users_tasks.run().wait(timeout=provider.get_object_sync_time_limit_ms(User))
group_tasks.run().wait(timeout=provider.get_object_sync_time_limit_ms(Group))
self._sync_cleanup(provider, task)
except TransientSyncException as exc:
self.logger.warning("transient sync exception", exc=exc)
task.warning("Sync encountered a transient exception. Retrying", exc=exc)
@@ -112,35 +111,6 @@ class SyncTasks:
task.error(exc)
return
def _sync_cleanup(self, provider: OutgoingSyncProvider, task: Task):
"""Delete remote objects that are no longer in scope"""
for object_type in (User, Group):
try:
client = provider.client_for_model(object_type)
except TransientSyncException:
continue
in_scope_pks = set(provider.get_object_qs(object_type).values_list("pk", flat=True))
stale = client.connection_type.objects.filter(provider=provider).exclude(
**{f"{client.connection_type_query}__pk__in": in_scope_pks}
)
for connection in stale:
try:
client.delete(connection.scim_id)
task.info(
f"Deleted out-of-scope {object_type._meta.verbose_name}",
scim_id=connection.scim_id,
)
except NotFoundSyncException:
pass
except TransientSyncException as exc:
self.logger.warning("transient error during cleanup", exc=exc)
self.logger.warning(
"Cleanup encountered a transient exception. Retrying", exc=exc
)
raise Retry() from exc
except DryRunRejected as exc:
self.logger.info("Rejected dry-run cleanup event", exc=exc)
def sync_objects(
self,
object_type: str,

View File

@@ -1,119 +0,0 @@
"""Tests for inheritance helpers."""
from contextlib import contextmanager
from django.db import connection, models
from django.test import TransactionTestCase
from django.test.utils import isolate_apps
from authentik.lib.utils.inheritance import get_deepest_child
@contextmanager
def temporary_inheritance_models():
"""Create a temporary multi-table inheritance graph for testing."""
with isolate_apps("authentik.lib.tests"):
class GrandParent(models.Model):
class Meta:
app_label = "tests"
def __str__(self) -> str:
return f"GrandParent({self.pk})"
class Parent(GrandParent):
class Meta:
app_label = "tests"
def __str__(self) -> str:
return f"Parent({self.pk})"
class Child(Parent):
class Meta:
app_label = "tests"
def __str__(self) -> str:
return f"Child({self.pk})"
class GrandChild(Child):
class Meta:
app_label = "tests"
def __str__(self) -> str:
return f"GrandChild({self.pk})"
with connection.schema_editor() as schema_editor:
schema_editor.create_model(GrandParent)
schema_editor.create_model(Parent)
schema_editor.create_model(Child)
schema_editor.create_model(GrandChild)
try:
yield GrandParent, Parent, Child, GrandChild
finally:
with connection.schema_editor() as schema_editor:
schema_editor.delete_model(GrandChild)
schema_editor.delete_model(Child)
schema_editor.delete_model(Parent)
schema_editor.delete_model(GrandParent)
class TestInheritanceUtils(TransactionTestCase):
"""Tests for helper functions in authentik.lib.utils.inheritance."""
def test_get_deepest_child_grandparent_to_parent(self):
"""GrandParent -> Parent."""
with temporary_inheritance_models() as (GrandParent, Parent, _Child, _GrandChild):
parent = Parent.objects.create()
grandparent = GrandParent.objects.get(pk=parent.pk)
resolved = get_deepest_child(grandparent)
self.assertIsInstance(resolved, Parent)
self.assertEqual(resolved.pk, parent.pk)
def test_get_deepest_child_grandparent_to_child(self):
"""GrandParent -> Child."""
with temporary_inheritance_models() as (GrandParent, _Parent, Child, _GrandChild):
child = Child.objects.create()
grandparent = GrandParent.objects.get(pk=child.pk)
resolved = get_deepest_child(grandparent)
self.assertIsInstance(resolved, Child)
self.assertEqual(resolved.pk, child.pk)
def test_get_deepest_child_grandparent_to_grandchild(self):
"""GrandParent -> GrandChild."""
with temporary_inheritance_models() as (GrandParent, _Parent, _Child, GrandChild):
grandchild = GrandChild.objects.create()
grandparent = GrandParent.objects.get(pk=grandchild.pk)
resolved = get_deepest_child(grandparent)
self.assertIsInstance(resolved, GrandChild)
self.assertEqual(resolved.pk, grandchild.pk)
def test_get_deepest_child_parent_to_child(self):
"""Parent -> Child (start from non-root)."""
with temporary_inheritance_models() as (_GrandParent, Parent, Child, _GrandChild):
child = Child.objects.create()
parent = Parent.objects.get(pk=child.pk)
resolved = get_deepest_child(parent)
self.assertIsInstance(resolved, Child)
self.assertEqual(resolved.pk, child.pk)
def test_get_deepest_child_no_queries_with_preloaded_relations(self):
"""No extra queries when the inheritance chain is fully select_related."""
with temporary_inheritance_models() as (GrandParent, _Parent, _Child, GrandChild):
grandchild = GrandChild.objects.create()
grandparent = GrandParent.objects.select_related("parent__child__grandchild").get(
pk=grandchild.pk
)
with self.assertNumQueries(0):
resolved = get_deepest_child(grandparent)
self.assertIsInstance(resolved, GrandChild)

View File

@@ -1,41 +0,0 @@
from django.db.models import Model, OneToOneField, OneToOneRel
def get_deepest_child(parent: Model) -> Model:
"""
In multiple table inheritance, given any ancestor object, get the deepest child object.
See https://docs.djangoproject.com/en/dev/topics/db/models/#multi-table-inheritance
This function does not query the database if `select_related` has been performed on all
subclasses of `parent`'s model.
"""
# Almost verbatim copy from django-model-utils, see
# https://github.com/jazzband/django-model-utils/blob/5.0.0/model_utils/managers.py#L132
one_to_one_rels = [
field for field in parent._meta.get_fields() if isinstance(field, OneToOneRel)
]
submodel_fields = [
rel
for rel in one_to_one_rels
if isinstance(rel.field, OneToOneField)
and issubclass(rel.field.model, parent._meta.model)
and parent._meta.model is not rel.field.model
and rel.parent_link
]
submodel_accessors = [submodel_field.get_accessor_name() for submodel_field in submodel_fields]
# End Copy
child = None
for submodel in submodel_accessors:
try:
child = getattr(parent, submodel)
break
except AttributeError:
continue
if not child:
return parent
return get_deepest_child(child)

View File

@@ -7,6 +7,7 @@ from socket import gethostname
from typing import Any
from urllib.parse import urlparse
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
@@ -158,7 +159,7 @@ def outpost_send_update(pk: Any):
layer = get_channel_layer()
group = build_outpost_group(outpost.pk)
LOGGER.debug("sending update", channel=group, outpost=outpost)
layer.group_send_blocking(group, {"type": "event.update"})
async_to_sync(layer.group_send)(group, {"type": "event.update"})
@actor(description=_("Checks the local environment and create Service connections."))
@@ -209,7 +210,7 @@ def outpost_session_end(session_id: str):
for outpost in Outpost.objects.all():
LOGGER.info("Sending session end signal to outpost", outpost=outpost)
group = build_outpost_group(outpost.pk)
layer.group_send_blocking(
async_to_sync(layer.group_send)(
group,
{
"type": "event.session.end",

View File

@@ -2,7 +2,6 @@
from base64 import b64encode
from json import loads
from urllib.parse import quote
from django.urls import reverse
@@ -97,16 +96,3 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
def test_backchannel_client_id_via_auth_header_urlencoded(self):
"""Test URL-encoded client IDs in Basic auth"""
self.provider.client_id = "test/client+id"
self.provider.save()
creds = b64encode(f"{quote(self.provider.client_id, safe='')}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)

View File

@@ -2,7 +2,6 @@
from base64 import b64encode
from json import dumps
from urllib.parse import quote
from django.test import RequestFactory
from django.urls import reverse
@@ -29,7 +28,6 @@ from authentik.providers.oauth2.models import (
ScopeMapping,
)
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.utils import extract_client_auth
from authentik.providers.oauth2.views.token import TokenParams
@@ -117,20 +115,6 @@ class TestToken(OAuthTestCase):
params = TokenParams.parse(request, provider, provider.client_id, provider.client_secret)
self.assertEqual(params.provider, provider)
def test_extract_client_auth_basic_auth_percent_decodes(self):
"""test percent-decoding of client credentials in Basic auth"""
header = b64encode(
f"{quote('client/id', safe='')}:{quote('secret+/==', safe='')}".encode()
).decode()
request = self.factory.post("/", HTTP_AUTHORIZATION=f"Basic {header}")
self.assertEqual(extract_client_auth(request), ("client/id", "secret+/=="))
def test_extract_client_auth_basic_auth_preserves_raw_plus(self):
"""test compatibility with clients that still send raw plus characters"""
header = b64encode(b"client:secret+plus").decode()
request = self.factory.post("/", HTTP_AUTHORIZATION=f"Basic {header}")
self.assertEqual(extract_client_auth(request), ("client", "secret+plus"))
def test_auth_code_view(self):
"""test request param"""
provider = OAuth2Provider.objects.create(

View File

@@ -2,7 +2,6 @@
from base64 import b64encode
from json import loads
from urllib.parse import quote
from django.test import RequestFactory
from django.urls import reverse
@@ -179,41 +178,6 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
self.assertEqual(jwt["given_name"], self.user.name)
self.assertEqual(jwt["preferred_username"], self.user.username)
def test_successful_basic_auth_urlencoded_client_secret(self):
"""test successful with URL-encoded Basic auth credentials"""
client_secret = b64encode(f"sa:{self.token.key}".encode()).decode()
header = b64encode(
f"{quote(self.provider.client_id, safe='')}:{quote(client_secret, safe='')}".encode()
).decode()
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}",
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
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"], self.user.name)
self.assertEqual(jwt["preferred_username"], self.user.username)
jwt = decode(
body["id_token"],
key=self.provider.signing_key.public_key,
algorithms=[alg],
audience=self.provider.client_id,
)
self.assertEqual(jwt["given_name"], self.user.name)
self.assertEqual(jwt["preferred_username"], self.user.username)
def test_successful_password(self):
"""test successful (password grant)"""
response = self.client.post(

View File

@@ -7,7 +7,7 @@ from binascii import Error
from hashlib import sha256
from hmac import compare_digest
from typing import Any
from urllib.parse import unquote, urlparse
from urllib.parse import urlparse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http.response import HttpResponseRedirect
@@ -122,10 +122,6 @@ def extract_client_auth(request: HttpRequest) -> tuple[str, str]:
try:
user_pass = b64decode(b64_user_pass).decode("utf-8").partition(":")
client_id, _, client_secret = user_pass
# RFC 6749 requires client credentials in Basic auth to be form-encoded first.
# We only percent-decode here so raw `+` characters keep their previous meaning.
client_id = unquote(client_id)
client_secret = unquote(client_secret)
except ValueError, Error:
client_id = client_secret = "" # nosec
else:

View File

@@ -1,5 +1,6 @@
"""proxy provider tasks"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.utils.translation import gettext_lazy as _
from dramatiq.actor import actor
@@ -15,7 +16,7 @@ def proxy_on_logout(session_id: str):
hashed_session_id = hash_session_key(session_id)
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
group = build_outpost_group(outpost.pk)
layer.group_send_blocking(
async_to_sync(layer.group_send)(
group,
{
"type": "event.provider.specific",

View File

@@ -19,18 +19,12 @@ from authentik.common.saml.constants import (
RSA_SHA512,
SAML_NAME_ID_FORMAT_UNSPECIFIED,
)
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.lib.xml import lxml_from_string
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.sources.saml.models import SAMLNameIDPolicy
ERROR_CANNOT_DECODE_REQUEST = "Cannot decode SAML request."
ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = (
"Verification Certificate configured, but request is not signed."
)
ERROR_FAILED_TO_VERIFY = "Failed to verify signature"
@dataclass(slots=True)
class AuthNRequest:
@@ -88,7 +82,7 @@ class AuthNRequestParser:
try:
decoded_xml = b64decode(saml_request.encode())
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request.") from None
verifier = self.provider.verification_kp
if not verifier:
@@ -99,7 +93,9 @@ class AuthNRequestParser:
signature_nodes = root.xpath("/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP)
# No signatures, no verifier configured -> decode xml directly
if len(signature_nodes) < 1:
raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
raise CannotHandleAssertion(
"Verification Certificate configured, but request is not signed."
)
signature_node = signature_nodes[0]
@@ -114,7 +110,7 @@ class AuthNRequestParser:
ctx.key = key
ctx.verify(signature_node)
except xmlsec.Error as exc:
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
raise CannotHandleAssertion("Failed to verify signature") from exc
return self._parse_xml(decoded_xml, relay_state)
@@ -129,14 +125,16 @@ class AuthNRequestParser:
try:
decoded_xml = decode_base64_and_inflate(saml_request)
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request.") from None
verifier = self.provider.verification_kp
if not verifier:
return self._parse_xml(decoded_xml, relay_state)
if verifier and not (signature and sig_alg):
raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT)
raise CannotHandleAssertion(
"Verification Certificate configured, but request is not signed."
)
if signature and sig_alg:
querystring = f"SAMLRequest={quote_plus(saml_request)}&"
@@ -168,11 +166,11 @@ class AuthNRequestParser:
b64decode(signature),
)
except xmlsec.Error as exc:
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
raise CannotHandleAssertion("Failed to verify signature") from exc
try:
return self._parse_xml(decoded_xml, relay_state)
except ParseError as exc:
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
raise CannotHandleAssertion("Failed to verify signature") from exc
def idp_initiated(self) -> AuthNRequest:
"""Create IdP Initiated AuthNRequest"""

View File

@@ -6,9 +6,8 @@ from dataclasses import dataclass
from defusedxml import ElementTree
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
@@ -74,7 +73,7 @@ class LogoutRequestParser:
try:
decoded_xml = b64decode(saml_request.encode())
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request.") from None
return self._parse_xml(decoded_xml, relay_state)
def parse_detached(
@@ -86,6 +85,6 @@ class LogoutRequestParser:
try:
decoded_xml = decode_base64_and_inflate(saml_request)
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request.") from None
return self._parse_xml(decoded_xml, relay_state)

View File

@@ -7,13 +7,15 @@ from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
from authentik.providers.saml.views.flows import (
PLAN_CONTEXT_SAML_RELAY_STATE,
)
@@ -63,6 +65,13 @@ class TestSPInitiatedSLOViews(TestCase):
relay_state="https://sp.example.com/return",
)
# Create a LogoutResponseProcessor for generating valid test responses
self._response_processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=LogoutRequest(id="test-id", issuer="https://sp.example.com"),
destination="https://idp.example.com/sls",
)
def test_redirect_view_handles_logout_request(self):
"""Test that redirect view properly handles a logout request"""
# Generate encoded logout request
@@ -102,7 +111,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_redirect(),
"RelayState": relay_state,
},
)
@@ -125,7 +134,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_redirect(),
"RelayState": relay_state,
},
)
@@ -148,7 +157,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_redirect(),
},
)
# Create a flow plan with the return URL
@@ -171,7 +180,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_redirect(),
},
)
request.session = {}
@@ -239,7 +248,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_post(),
"RelayState": relay_state,
},
)
@@ -262,7 +271,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_post(),
},
)
# Create a flow plan with the return URL
@@ -424,7 +433,7 @@ class TestSPInitiatedSLOViews(TestCase):
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "dummy-response",
"SAMLResponse": self._response_processor.encode_redirect(),
"RelayState": "/some/invalid/path", # Use a path that starts with /
},
)
@@ -725,3 +734,406 @@ class TestSPInitiatedSLOLogoutMethods(TestCase):
# Verify relay state was captured
logout_request = view.plan_context.get("authentik/providers/saml/logout_request")
self.assertEqual(logout_request.relay_state, expected_relay_state)
class TestSignatureVerification(TestCase):
"""Test SAML signature verification for LogoutRequest and LogoutResponse"""
def setUp(self):
"""Set up test fixtures"""
self.factory = RequestFactory()
self.brand = create_test_brand()
self.flow = create_test_flow()
self.invalidation_flow = create_test_flow()
self.cert = create_test_cert()
# Create provider with signing and verification keypairs
self.provider = SAMLProvider.objects.create(
name="test-sig-provider",
authorization_flow=self.flow,
invalidation_flow=self.invalidation_flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
signing_kp=self.cert,
sign_logout_request=True,
sign_logout_response=True,
)
self.application = Application.objects.create(
name="test-sig-app",
slug="test-sig-app",
provider=self.provider,
)
def test_logout_response_redirect_no_verification_kp_accepted(self):
"""LogoutResponse without verification_kp should be accepted without signature"""
# Provider has no verification_kp — should accept unsigned response
self.provider.verification_kp = None
self.provider.save()
# Generate a valid logout response
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination="https://idp.example.com/sls",
)
encoded_response = processor.encode_redirect()
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": encoded_response,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
# Should redirect to relay state (accepted)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "https://sp.example.com/return")
def test_logout_response_redirect_verification_kp_no_signature_rejected(self):
"""LogoutResponse with verification_kp but no signature should be rejected"""
self.provider.verification_kp = self.cert
self.provider.save()
# Generate an unsigned logout response
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination="https://idp.example.com/sls",
)
# encode_redirect() does NOT add signature to XML (it's detached for redirect)
encoded_response = processor.encode_redirect()
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": encoded_response,
"RelayState": "https://sp.example.com/return",
# No Signature or SigAlg params
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
# Should redirect to root (rejected)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
def test_logout_response_redirect_valid_signature_accepted(self):
"""LogoutResponse with valid detached signature should be accepted"""
self.provider.verification_kp = self.cert
self.provider.save()
# Generate a signed logout response URL (has Signature + SigAlg params)
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination=f"https://idp.example.com/slo/redirect/{self.application.slug}/",
relay_state="https://sp.example.com/return",
)
redirect_url = processor.get_redirect_url()
# Parse the URL to get query params
from urllib.parse import parse_qs, urlparse
parsed = urlparse(redirect_url)
params = parse_qs(parsed.query)
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": params["SAMLResponse"][0],
"RelayState": params["RelayState"][0],
"Signature": params["Signature"][0],
"SigAlg": params["SigAlg"][0],
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
# Should redirect to relay state (accepted)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "https://sp.example.com/return")
def test_logout_response_redirect_garbage_rejected(self):
"""LogoutResponse with garbage SAMLResponse should be rejected gracefully"""
self.provider.verification_kp = None
self.provider.save()
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLResponse": "not-valid-base64-!!!",
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
# Should redirect to root (rejected gracefully)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
def test_logout_request_redirect_verification_kp_no_signature_rejected(self):
"""LogoutRequest with verification_kp but no Signature/SigAlg should be rejected"""
self.provider.verification_kp = self.cert
self.provider.save()
# Generate an unsigned logout request
processor = LogoutRequestProcessor(
provider=self.provider,
user=None,
destination="https://idp.example.com/sls",
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
)
encoded_request = processor.encode_redirect()
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLRequest": encoded_request,
"RelayState": "https://sp.example.com/return",
# No Signature or SigAlg params
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
view.resolve_provider_application()
result = view.check_saml_request()
self.assertIsNotNone(result)
self.assertEqual(result.status_code, 400)
def test_logout_request_redirect_valid_signature_accepted(self):
"""LogoutRequest with valid detached signature should be accepted"""
self.provider.verification_kp = self.cert
self.provider.save()
# Generate a signed logout request URL
processor = LogoutRequestProcessor(
provider=self.provider,
user=None,
destination="https://idp.example.com/sls",
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
relay_state="https://sp.example.com/return",
)
redirect_url = processor.get_redirect_url()
# Parse the URL to get query params
from urllib.parse import parse_qs, urlparse
parsed = urlparse(redirect_url)
params = parse_qs(parsed.query)
request = self.factory.get(
f"/slo/redirect/{self.application.slug}/",
{
"SAMLRequest": params["SAMLRequest"][0],
"RelayState": params["RelayState"][0],
"Signature": params["Signature"][0],
"SigAlg": params["SigAlg"][0],
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingRedirectView()
view.setup(request, application_slug=self.application.slug)
view.resolve_provider_application()
result = view.check_saml_request()
self.assertIsNone(result) # None means success
def test_logout_response_post_no_verification_kp_accepted(self):
"""POST LogoutResponse without verification_kp should be accepted"""
self.provider.verification_kp = None
self.provider.save()
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination="https://idp.example.com/sls",
)
encoded_response = processor.encode_post()
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLResponse": encoded_response,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingPOSTView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "https://sp.example.com/return")
def test_logout_response_post_verification_kp_no_signature_rejected(self):
"""POST LogoutResponse with verification_kp but no enveloped signature should fail"""
self.provider.verification_kp = self.cert
# Disable signing so the response won't have a signature
self.provider.sign_logout_response = False
self.provider.save()
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination="https://idp.example.com/sls",
)
encoded_response = processor.encode_post()
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLResponse": encoded_response,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingPOSTView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
# Should redirect to root (rejected)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
def test_logout_response_post_valid_signature_accepted(self):
"""POST LogoutResponse with valid enveloped signature should be accepted"""
self.provider.verification_kp = self.cert
self.provider.sign_logout_response = True
self.provider.save()
logout_request = LogoutRequest(id="test-id", issuer="https://sp.example.com")
processor = LogoutResponseProcessor(
provider=self.provider,
logout_request=logout_request,
destination="https://idp.example.com/sls",
)
encoded_response = processor.encode_post()
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLResponse": encoded_response,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingPOSTView()
view.setup(request, application_slug=self.application.slug)
response = view.dispatch(request, application_slug=self.application.slug)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "https://sp.example.com/return")
def test_logout_request_post_verification_kp_no_signature_rejected(self):
"""POST LogoutRequest with verification_kp but no signature should be rejected"""
self.provider.verification_kp = self.cert
# Disable signing so the request won't have a signature
self.provider.sign_logout_request = False
self.provider.save()
processor = LogoutRequestProcessor(
provider=self.provider,
user=None,
destination="https://idp.example.com/sls",
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
)
encoded_request = processor.encode_post()
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLRequest": encoded_request,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingPOSTView()
view.setup(request, application_slug=self.application.slug)
view.resolve_provider_application()
result = view.check_saml_request()
self.assertIsNotNone(result)
self.assertEqual(result.status_code, 400)
def test_logout_request_post_valid_signature_accepted(self):
"""POST LogoutRequest with valid enveloped signature should be accepted"""
self.provider.verification_kp = self.cert
self.provider.sign_logout_request = True
self.provider.save()
processor = LogoutRequestProcessor(
provider=self.provider,
user=None,
destination="https://idp.example.com/sls",
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
)
encoded_request = processor.encode_post()
request = self.factory.post(
f"/slo/post/{self.application.slug}/",
{
"SAMLRequest": encoded_request,
"RelayState": "https://sp.example.com/return",
},
)
request.session = {}
request.brand = self.brand
view = SPInitiatedSLOBindingPOSTView()
view.setup(request, application_slug=self.application.slug)
view.resolve_provider_application()
result = view.check_saml_request()
self.assertIsNone(result) # None means success

View File

@@ -1,5 +1,7 @@
"""SP-initiated SAML Single Logout Views"""
from base64 import b64decode
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
@@ -7,6 +9,12 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
from authentik.common.saml.parsers.verify import (
verify_detached_signature,
verify_enveloped_signature,
)
from authentik.core.models import Application, AuthenticatedSession
from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage
@@ -16,7 +24,6 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import (
SAMLBindings,
SAMLLogoutMethods,
@@ -36,6 +43,8 @@ from authentik.providers.saml.views.flows import (
REQUEST_KEY_RELAY_STATE,
REQUEST_KEY_SAML_REQUEST,
REQUEST_KEY_SAML_RESPONSE,
REQUEST_KEY_SAML_SIG_ALG,
REQUEST_KEY_SAML_SIGNATURE,
)
LOGGER = get_logger()
@@ -203,6 +212,35 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
# IDP SLO, so we want to redirect to our next provider
if REQUEST_KEY_SAML_RESPONSE in request.GET:
relay_state = request.GET.get(REQUEST_KEY_RELAY_STATE, "")
# Resolve provider for signature verification
try:
application = Application.objects.get(slug=kwargs.get("application_slug", ""))
provider = SAMLProvider.objects.get(pk=application.provider_id)
except Application.DoesNotExist, SAMLProvider.DoesNotExist:
return redirect("authentik_core:root-redirect")
# Parse and verify LogoutResponse
try:
parser = LogoutResponseParser()
logout_response = parser.parse_detached(
request.GET[REQUEST_KEY_SAML_RESPONSE],
relay_state=relay_state or None,
)
parser.verify_status(logout_response)
if provider.verification_kp:
verify_detached_signature(
"SAMLResponse",
request.GET[REQUEST_KEY_SAML_RESPONSE],
relay_state or None,
request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
provider.verification_kp,
)
except CannotHandleAssertion as exc:
LOGGER.warning("Failed to verify SAML LogoutResponse", exc=str(exc))
return redirect("authentik_core:root-redirect")
if relay_state:
return redirect(relay_state)
@@ -230,6 +268,15 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
self.request.GET[REQUEST_KEY_SAML_REQUEST],
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
)
if self.provider.verification_kp:
verify_detached_signature(
"SAMLRequest",
self.request.GET[REQUEST_KEY_SAML_REQUEST],
self.request.GET.get(REQUEST_KEY_RELAY_STATE),
self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
self.provider.verification_kp,
)
self.plan_context[PLAN_CONTEXT_SAML_LOGOUT_REQUEST] = logout_request
except CannotHandleAssertion as exc:
Event.new(
@@ -254,6 +301,32 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
# IDP SLO, so we want to redirect to our next provider
if REQUEST_KEY_SAML_RESPONSE in request.POST:
relay_state = request.POST.get(REQUEST_KEY_RELAY_STATE, "")
# Resolve provider for signature verification
try:
application = Application.objects.get(slug=kwargs.get("application_slug", ""))
provider = SAMLProvider.objects.get(pk=application.provider_id)
except Application.DoesNotExist, SAMLProvider.DoesNotExist:
return redirect("authentik_core:root-redirect")
# Parse and verify LogoutResponse
try:
parser = LogoutResponseParser()
logout_response = parser.parse(
request.POST[REQUEST_KEY_SAML_RESPONSE],
relay_state=relay_state or None,
)
parser.verify_status(logout_response)
if provider.verification_kp:
verify_enveloped_signature(
b64decode(request.POST[REQUEST_KEY_SAML_RESPONSE].encode()),
provider.verification_kp,
"/samlp:LogoutResponse/ds:Signature",
)
except CannotHandleAssertion as exc:
LOGGER.warning("Failed to verify SAML LogoutResponse", exc=str(exc))
return redirect("authentik_core:root-redirect")
if relay_state:
return redirect(relay_state)
@@ -282,6 +355,12 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
payload[REQUEST_KEY_SAML_REQUEST],
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
)
if self.provider.verification_kp:
verify_enveloped_signature(
b64decode(payload[REQUEST_KEY_SAML_REQUEST].encode()),
self.provider.verification_kp,
"/samlp:LogoutRequest/ds:Signature",
)
self.plan_context[PLAN_CONTEXT_SAML_LOGOUT_REQUEST] = logout_request
except CannotHandleAssertion as exc:
LOGGER.info(str(exc))

View File

@@ -8,6 +8,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
@@ -16,7 +17,6 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
from authentik.providers.saml.views.flows import (

View File

@@ -10,7 +10,6 @@ from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.lib.generators import generate_id
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderGroup
from authentik.providers.scim.tasks import scim_sync
class SCIMGroupTests(TestCase):
@@ -206,80 +205,3 @@ class SCIMGroupTests(TestCase):
self.assertEqual(mock.request_history[1].method, "POST")
self.assertEqual(mock.request_history[2].method, "GET")
self.assertNotIn("PUT", [req.method for req in mock.request_history])
def _create_stale_provider_group(self, scim_id: str) -> Group:
"""Create a group that is outside the provider's scope (via group_filters) with an
existing SCIMProviderGroup, simulating a previously synced group now out of scope."""
self.app.backchannel_providers.remove(self.provider)
anchor = Group.objects.create(name=generate_id())
stale = Group.objects.create(name=generate_id())
self.app.backchannel_providers.add(self.provider)
self.provider.group_filters.set([anchor])
SCIMProviderGroup.objects.create(provider=self.provider, group=stale, scim_id=scim_id)
return stale
@Mocker()
def test_sync_cleanup_stale_group_delete(self, mock: Mocker):
"""Stale (out-of-scope) groups are deleted during full sync cleanup"""
scim_id = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.post("https://localhost/Groups", json={"id": generate_id()})
mock.delete(f"https://localhost/Groups/{scim_id}", status_code=204)
self._create_stale_provider_group(scim_id)
scim_sync.send(self.provider.pk).get_result()
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
self.assertEqual(delete_reqs[0].url, f"https://localhost/Groups/{scim_id}")
self.assertFalse(
SCIMProviderGroup.objects.filter(provider=self.provider, scim_id=scim_id).exists()
)
@Mocker()
def test_sync_cleanup_stale_group_not_found(self, mock: Mocker):
"""Stale group cleanup handles 404 from the remote gracefully"""
scim_id = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.post("https://localhost/Groups", json={"id": generate_id()})
mock.delete(f"https://localhost/Groups/{scim_id}", status_code=404)
self._create_stale_provider_group(scim_id)
scim_sync.send(self.provider.pk).get_result()
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
self.assertFalse(
SCIMProviderGroup.objects.filter(provider=self.provider, scim_id=scim_id).exists()
)
@Mocker()
def test_sync_cleanup_stale_group_transient_error(self, mock: Mocker):
"""Stale group cleanup logs and retries on transient HTTP errors"""
scim_id = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.post("https://localhost/Groups", json={"id": generate_id()})
mock.delete(f"https://localhost/Groups/{scim_id}", status_code=429)
self._create_stale_provider_group(scim_id)
scim_sync.send(self.provider.pk)
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
@Mocker()
def test_sync_cleanup_stale_group_dry_run(self, mock: Mocker):
"""Stale group cleanup skips HTTP DELETE in dry_run mode"""
self.provider.dry_run = True
self.provider.save()
scim_id = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
self._create_stale_provider_group(scim_id)
scim_sync.send(self.provider.pk)
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 0)

View File

@@ -1,19 +1,17 @@
"""SCIM User tests"""
from json import loads
from unittest.mock import patch
from django.test import TestCase
from jsonschema import validate
from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User, UserTypes
from authentik.core.models import Application, Group, User
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.base import SAFE_METHODS
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderUser
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects, sync_tasks
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects
from authentik.tasks.models import Task
from authentik.tenants.models import Tenant
@@ -539,104 +537,3 @@ class SCIMUserTests(TestCase):
self.assertEqual(mock.call_count, 2)
self.assertEqual(mock.request_history[0].method, "GET")
self.assertEqual(mock.request_history[1].method, "POST")
def _create_stale_provider_user(self, scim_id: str, uid: str) -> User:
"""Create a service-account user (excluded from provider scope) with an existing
SCIMProviderUser, simulating a previously synced user that is now out of scope."""
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
type=UserTypes.SERVICE_ACCOUNT,
)
SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
return user
@Mocker()
def test_sync_cleanup_stale_user_delete(self, mock: Mocker):
"""Stale (out-of-scope) users are deleted during full sync cleanup"""
scim_id = generate_id()
uid = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
self._create_stale_provider_user(scim_id, uid)
scim_sync.send(self.provider.pk).get_result()
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
self.assertEqual(delete_reqs[0].url, f"https://localhost/Users/{scim_id}")
self.assertFalse(
SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
)
@Mocker()
def test_sync_cleanup_stale_user_not_found(self, mock: Mocker):
"""Stale user cleanup handles 404 from the remote gracefully"""
scim_id = generate_id()
uid = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.delete(f"https://localhost/Users/{scim_id}", status_code=404)
self._create_stale_provider_user(scim_id, uid)
scim_sync.send(self.provider.pk).get_result()
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
self.assertFalse(
SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
)
@Mocker()
def test_sync_cleanup_stale_user_transient_error(self, mock: Mocker):
"""Stale user cleanup logs and retries on transient HTTP errors"""
scim_id = generate_id()
uid = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
mock.delete(f"https://localhost/Users/{scim_id}", status_code=429)
self._create_stale_provider_user(scim_id, uid)
scim_sync.send(self.provider.pk)
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 1)
@Mocker()
def test_sync_cleanup_stale_user_dry_run(self, mock: Mocker):
"""Stale user cleanup skips HTTP DELETE in dry_run mode"""
self.provider.dry_run = True
self.provider.save()
scim_id = generate_id()
uid = generate_id()
mock.get("https://localhost/ServiceProviderConfig", json={})
self._create_stale_provider_user(scim_id, uid)
scim_sync.send(self.provider.pk)
delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
self.assertEqual(len(delete_reqs), 0)
def test_sync_cleanup_client_for_model_transient(self):
"""Cleanup silently skips an object type when client_for_model raises
TransientSyncException"""
with Mocker() as mock:
mock.get("https://localhost/ServiceProviderConfig", json={})
with patch.object(
SCIMProvider,
"client_for_model",
side_effect=TransientSyncException("connection failed"),
):
scim_sync.send(self.provider.pk).get_result()
def test_sync_transient_exception(self):
"""TransientSyncException in _sync_cleanup is caught by sync() which then
schedules a retry"""
with Mocker() as mock:
mock.get("https://localhost/ServiceProviderConfig", json={})
with patch.object(
sync_tasks,
"_sync_cleanup",
side_effect=TransientSyncException("connection failed"),
):
scim_sync.send(self.provider.pk)

View File

@@ -89,7 +89,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
sentry_init()
self.logger.debug("Test environment configured")
self.task_broker = use_test_broker()
use_test_broker()
# Send startup signals
pre_startup.send(sender=self, mode="test")
@@ -185,9 +185,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
self.logger.info("Running tests", test_files=self.args)
with patch("guardian.shortcuts._get_ct_cached", patched__get_ct_cached):
try:
ret = pytest.main(self.args)
self.task_broker.close()
return ret
except Exception as exc: # noqa
self.logger.error("Error running tests", exc=exc, test_files=self.args)
return pytest.main(self.args)
except Exception as e: # noqa
self.logger.error("Error running tests", error=str(e), test_files=self.args)
return 1

View File

@@ -14,7 +14,6 @@ from django.utils.translation import gettext_lazy as _
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.core.models import (
Group,
@@ -32,7 +31,6 @@ from authentik.tasks.schedules.common import ScheduleSpec
LDAP_TIMEOUT = 15
LDAP_UNIQUENESS = "ldap_uniq"
LDAP_DISTINGUISHED_NAME = "distinguishedName"
LOGGER = get_logger()
def flatten(value: Any) -> Any:
@@ -270,7 +268,6 @@ class LDAPSource(IncomingSyncSource):
)
if self.start_tls:
LOGGER.debug("Connection StartTLS", source=self)
conn.start_tls(read_server_info=False)
try:
successful = conn.bind()
@@ -281,9 +278,7 @@ class LDAPSource(IncomingSyncSource):
# See https://github.com/goauthentik/authentik/issues/4590
# See also https://github.com/goauthentik/authentik/issues/3399
if server_kwargs.get("get_info", ALL) == NONE:
LOGGER.warning("Failed to connect after schema downgrade", source=self, exc=exc)
raise exc
LOGGER.warning("Downgrading connection to no schema info", source=self, exc=exc)
server_kwargs["get_info"] = NONE
return self.connection(server, server_kwargs, connection_kwargs)
finally:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -99,7 +99,6 @@ class IdentificationChallenge(Challenge):
password_fields = BooleanField()
allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False)
application_pre_launch = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices)
captcha_stage = CaptchaChallenge(required=False, allow_null=True)
@@ -349,12 +348,9 @@ class IdentificationStageView(ChallengeStageView):
# If the user has been redirected to us whilst trying to access an
# application, PLAN_CONTEXT_APPLICATION is set in the flow plan
if PLAN_CONTEXT_APPLICATION in self.executor.plan.context:
app: Application = self.executor.plan.context.get(
challenge.initial_data["application_pre"] = self.executor.plan.context.get(
PLAN_CONTEXT_APPLICATION, Application()
)
challenge.initial_data["application_pre"] = app.name
if launch_url := app.get_launch_url():
challenge.initial_data["application_pre_launch"] = launch_url
).name
if (
PLAN_CONTEXT_DEVICE in self.executor.plan.context
and PLAN_CONTEXT_DEVICE_AUTH_TOKEN in self.executor.plan.context

44
authentik/tasks/forks.py Normal file
View File

@@ -0,0 +1,44 @@
from signal import pause
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
LOGGER = get_logger()
def worker_healthcheck():
import authentik.tasks.setup # noqa
from authentik.tasks.middleware import WorkerHealthcheckMiddleware
host, _, port = CONFIG.get("listen.http").rpartition(":")
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
WorkerHealthcheckMiddleware.run(host, port)
pause()
def worker_status():
import authentik.tasks.setup # noqa
from authentik.tasks.middleware import WorkerStatusMiddleware
WorkerStatusMiddleware.run()
def worker_metrics():
import authentik.tasks.setup # noqa
from authentik.tasks.middleware import MetricsMiddleware
addr, _, port = CONFIG.get("listen.metrics").rpartition(":")
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
MetricsMiddleware.run(addr, port)
pause()

View File

@@ -1,37 +1,29 @@
import socket
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler
from threading import Event as TEvent
from threading import Thread, current_thread
from time import sleep
from typing import Any, cast
import pglock
from django.db import OperationalError, connections, transaction
from django.db import OperationalError, connections
from django.utils.timezone import now
from django_dramatiq_postgres.middleware import (
CurrentTask as BaseCurrentTask,
)
from django_dramatiq_postgres.middleware import (
HTTPServer,
HTTPServerThread,
)
from django_dramatiq_postgres.middleware import HTTPServer
from django_dramatiq_postgres.middleware import (
MetricsMiddleware as BaseMetricsMiddleware,
)
from django_dramatiq_postgres.middleware import (
_MetricsHandler as BaseMetricsHandler,
)
from dramatiq import Worker
from dramatiq.broker import Broker
from dramatiq.message import Message
from dramatiq.middleware import Middleware
from psycopg.errors import Error
from setproctitle import setthreadtitle
from structlog.stdlib import get_logger
from authentik import authentik_full_version
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.reflection import class_to_path
from authentik.root.monitoring import monitoring_set
@@ -221,39 +213,17 @@ class _healthcheck_handler(BaseHTTPRequestHandler):
class WorkerHealthcheckMiddleware(Middleware):
thread: HTTPServerThread | None
@property
def forks(self):
from authentik.tasks.forks import worker_healthcheck
def __init__(self):
host, _, port = CONFIG.get("listen.http").rpartition(":")
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
self.host, self.port = host, port
def after_worker_boot(self, broker: Broker, worker: Worker):
self.thread = HTTPServerThread(
target=WorkerHealthcheckMiddleware.run, args=(self.host, self.port)
)
self.thread.start()
def before_worker_shutdown(self, broker: Broker, worker: Worker):
server = self.thread.server
if server:
server.shutdown()
LOGGER.debug("Stopping WorkerHealthcheckMiddleware")
self.thread.join()
return [worker_healthcheck]
@staticmethod
def run(addr: str, port: int):
setthreadtitle("authentik Worker Healthcheck server")
try:
server = HTTPServer((addr, port), _healthcheck_handler)
thread = cast(HTTPServerThread, current_thread())
thread.server = server
server.serve_forever()
httpd = HTTPServer((addr, port), _healthcheck_handler)
httpd.serve_forever()
except OSError as exc:
get_logger(__name__, type(WorkerHealthcheckMiddleware)).warning(
"Port is already in use, not starting healthcheck server",
@@ -262,50 +232,36 @@ class WorkerHealthcheckMiddleware(Middleware):
class WorkerStatusMiddleware(Middleware):
thread: Thread | None
thread_event: TEvent | None
@property
def forks(self):
from authentik.tasks.forks import worker_status
def after_worker_boot(self, broker: Broker, worker: Worker):
self.thread_event = TEvent()
self.thread = Thread(target=WorkerStatusMiddleware.run, args=(self.thread_event,))
self.thread.start()
def before_worker_shutdown(self, broker: Broker, worker: Worker):
self.thread_event.set()
LOGGER.debug("Stopping WorkerStatusMiddleware")
self.thread.join()
return [worker_status]
@staticmethod
def run(event: TEvent):
setthreadtitle("authentik Worker status")
with transaction.atomic():
hostname = socket.gethostname()
WorkerStatus.objects.filter(hostname=hostname).delete()
status, _ = WorkerStatus.objects.update_or_create(
hostname=hostname,
version=authentik_full_version(),
)
while not event.is_set():
def run():
status = WorkerStatus.objects.create(
hostname=socket.gethostname(),
version=authentik_full_version(),
)
while True:
try:
WorkerStatusMiddleware.keep(event, status)
WorkerStatusMiddleware.keep(status)
except DB_ERRORS: # pragma: no cover
event.wait(10)
sleep(10)
try:
connections.close_all()
except DB_ERRORS:
pass
@staticmethod
def keep(event: TEvent, status: WorkerStatus):
def keep(status: WorkerStatus):
lock_id = f"goauthentik.io/worker/status/{status.pk}"
with pglock.advisory(lock_id, side_effect=pglock.Raise):
while not event.is_set():
status.refresh_from_db()
old_last_seen = status.last_seen
while True:
status.last_seen = now()
if old_last_seen != status.last_seen:
status.save(update_fields=("last_seen",))
event.wait(30)
status.save(update_fields=("last_seen",))
sleep(30)
class _MetricsHandler(BaseMetricsHandler):
@@ -315,26 +271,10 @@ class _MetricsHandler(BaseMetricsHandler):
class MetricsMiddleware(BaseMetricsMiddleware):
thread: HTTPServerThread | None
handler_class = _MetricsHandler
@property
def forks(self) -> list[Callable[[], None]]:
return []
def forks(self):
from authentik.tasks.forks import worker_metrics
def after_worker_boot(self, broker: Broker, worker: Worker):
addr, _, port = CONFIG.get("listen.metrics").rpartition(":")
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
self.thread = HTTPServerThread(target=MetricsMiddleware.run, args=(addr, port))
self.thread.start()
def before_worker_shutdown(self, broker: Broker, worker: Worker):
server = self.thread.server
if server:
server.shutdown()
LOGGER.debug("Stopping MetricsMiddleware")
self.thread.join()
return [worker_metrics]

View File

@@ -10,26 +10,24 @@ from dramatiq.results.middleware import Results
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
from authentik.tasks.broker import PostgresBroker
from authentik.tasks.middleware import WorkerHealthcheckMiddleware
TESTING_QUEUE = "testing"
from authentik.tasks.middleware import MetricsMiddleware
class TestWorker(Worker):
def __init__(self, broker: Broker):
def __init__(self, queue_name: str, broker: Broker):
super().__init__(broker=broker)
self.work_queue = PriorityQueue()
self.consumers = {
TESTING_QUEUE: _ConsumerThread(
queue_name: _ConsumerThread(
broker=self.broker,
queue_name=TESTING_QUEUE,
queue_name=queue_name,
prefetch=2,
work_queue=self.work_queue,
worker_timeout=1,
),
}
self.consumers[TESTING_QUEUE].consumer = self.broker.consume(
queue_name=TESTING_QUEUE,
self.consumers[queue_name].consumer = self.broker.consume(
queue_name=queue_name,
prefetch=2,
timeout=1,
)
@@ -42,29 +40,18 @@ class TestWorker(Worker):
self.broker.emit_before("worker_boot", self)
self.broker.emit_after("worker_boot", self)
self.broker.emit_after("process_boot")
def process_message(self, message: MessageProxy):
self.work_queue.put((0, message))
self.consumers[TESTING_QUEUE].consumer.in_processing.add(message.message_id)
self.work_queue.put(message)
self.consumers[message.queue_name].consumer.in_processing.add(message.message_id)
self._worker.process_message(message)
class TestBroker(PostgresBroker):
worker: TestWorker | None = None
def start(self):
self.worker = TestWorker(broker=self)
def close(self):
self.emit_before("worker_shutdown", self)
return super().close()
def enqueue(self, *args, **kwargs):
message = super().enqueue(*args, **kwargs).copy(queue_name=TESTING_QUEUE)
if not self.worker:
return message
self.worker.process_message(MessageProxy(message))
message = super().enqueue(*args, **kwargs)
worker = TestWorker(message.queue_name, broker=self)
worker.process_message(MessageProxy(message))
return message
@@ -82,8 +69,8 @@ def use_test_broker():
middleware: Middleware = import_string(middleware_class)(
**middleware_kwargs,
)
if isinstance(middleware, WorkerHealthcheckMiddleware):
middleware.port = 9102
if isinstance(middleware, MetricsMiddleware):
continue
if isinstance(middleware, Retries):
middleware.max_retries = 0
if isinstance(middleware, Results):
@@ -93,6 +80,4 @@ def use_test_broker():
)
broker.add_middleware(middleware)
broker.start()
set_broker(broker)
return broker

View File

@@ -1,7 +1,10 @@
from json import loads
from django.test import TestCase
from django.urls import reverse
from authentik.core.tests.utils import create_test_admin_user
from authentik.core.models import Group, User
from authentik.lib.generators import generate_id
class TestAdminAPI(TestCase):
@@ -9,13 +12,15 @@ class TestAdminAPI(TestCase):
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.user = User.objects.create(username=generate_id())
self.group = Group.objects.create(name=generate_id(), is_superuser=True)
self.group.users.add(self.user)
self.group.save()
self.client.force_login(self.user)
def test_workers(self):
"""Test Workers API"""
response = self.client.get(reverse("authentik_api:tasks_workers"))
self.assertEqual(response.status_code, 200)
# Disabled for flakiness
# body = loads(response.content)
# self.assertEqual(len(body), 1)
body = loads(response.content)
self.assertEqual(len(body), 0)

View File

@@ -1,52 +0,0 @@
from django.test import TestCase
from dramatiq import actor, get_broker
from authentik.tasks.middleware import CurrentTask
from authentik.tasks.models import Task, TaskLog
class TestWorkerMiddleware(TestCase):
def test_task_log(self):
@actor
def test_task():
self = CurrentTask.get_task()
self.info("foo")
test_task.send()
task = Task.objects.filter(actor_name=test_task.actor_name).first()
logs = list(
TaskLog.objects.filter(task=task).order_by("timestamp").values_list("event", flat=True)
)
self.assertEqual(
logs,
[
"Task has been queued",
"Task is being processed",
"foo",
"Task finished processing without errors",
],
)
broker = get_broker()
del broker.actors[test_task.actor_name]
def test_task_exceptions(self):
@actor
def test_task():
raise ValueError("foo")
test_task.send()
task = Task.objects.filter(actor_name=test_task.actor_name).first()
logs = list(
TaskLog.objects.filter(task=task).order_by("timestamp").values_list("event", flat=True)
)
self.assertEqual(
logs,
[
"Task has been queued",
"Task is being processed",
"foo",
],
)
broker = get_broker()
del broker.actors[test_task.actor_name]

View File

@@ -696,46 +696,6 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_endpoints_connectors_google_chrome.googlechromeconnector"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_endpoints_connectors_google_chrome.googlechromeconnector_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_endpoints_connectors_google_chrome.googlechromeconnector"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_endpoints_connectors_google_chrome.googlechromeconnector"
}
}
},
{
"type": "object",
"required": [
@@ -5674,10 +5634,6 @@
"authentik_endpoints_connectors_fleet.change_fleetconnector",
"authentik_endpoints_connectors_fleet.delete_fleetconnector",
"authentik_endpoints_connectors_fleet.view_fleetconnector",
"authentik_endpoints_connectors_google_chrome.add_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.change_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.delete_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.view_googlechromeconnector",
"authentik_enterprise.add_license",
"authentik_enterprise.add_licenseusage",
"authentik_enterprise.change_license",
@@ -6814,57 +6770,6 @@
}
}
},
"model_authentik_endpoints_connectors_google_chrome.googlechromeconnector": {
"type": "object",
"properties": {
"connector_uuid": {
"type": "string",
"format": "uuid",
"title": "Connector uuid"
},
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"enabled": {
"type": "boolean",
"title": "Enabled"
},
"credentials": {
"type": "object",
"additionalProperties": true,
"title": "Credentials"
}
},
"required": []
},
"model_authentik_endpoints_connectors_google_chrome.googlechromeconnector_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_googlechromeconnector",
"change_googlechromeconnector",
"delete_googlechromeconnector",
"view_googlechromeconnector"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_lifecycle.lifecycleiteration": {
"type": "object",
"properties": {
@@ -8914,7 +8819,6 @@
"authentik.enterprise.audit",
"authentik.enterprise.endpoints.connectors.agent",
"authentik.enterprise.endpoints.connectors.fleet",
"authentik.enterprise.endpoints.connectors.google_chrome",
"authentik.enterprise.lifecycle",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
@@ -9045,7 +8949,6 @@
"authentik_brands.brand",
"authentik_blueprints.blueprintinstance",
"authentik_endpoints_connectors_fleet.fleetconnector",
"authentik_endpoints_connectors_google_chrome.googlechromeconnector",
"authentik_lifecycle.lifecyclerule",
"authentik_lifecycle.lifecycleiteration",
"authentik_lifecycle.review",
@@ -11323,10 +11226,6 @@
"authentik_endpoints_connectors_fleet.change_fleetconnector",
"authentik_endpoints_connectors_fleet.delete_fleetconnector",
"authentik_endpoints_connectors_fleet.view_fleetconnector",
"authentik_endpoints_connectors_google_chrome.add_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.change_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.delete_googlechromeconnector",
"authentik_endpoints_connectors_google_chrome.view_googlechromeconnector",
"authentik_enterprise.add_license",
"authentik_enterprise.add_licenseusage",
"authentik_enterprise.change_license",

View File

@@ -1,289 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/refs/heads/main/cspell.schema.json",
"name": "authentik-cspell",
"description": "authentik's monorepo spell checker configuration",
"version": "0.2",
"readonly": true,
"language": "en-US",
"cache": {
"useCache": true,
"cacheLocation": "./.cspellcache",
"cacheStrategy": "content"
},
"reporters": [
"default",
["@cspell/cspell-json-reporter", { "outFile": "./cspell-report.json" }]
],
"dictionaryDefinitions": [
{
"name": "en-x-authentik-software-terms",
"path": "./locale/en/dictionaries/software-terms.txt",
"description": "English software-related terms",
"addWords": true
},
{
"name": "en-x-authentik-idp",
"path": "./locale/en/dictionaries/idp.txt",
"description": "English IdP words",
"addWords": true
},
{
"name": "en-x-authentik-python",
"path": "./locale/en/dictionaries/python.txt",
"addWords": true
},
{
"name": "en-x-authentik-golang",
"path": "./locale/en/dictionaries/golang.txt",
"addWords": true
},
{
"name": "en-x-authentik-people",
"path": "./locale/en/dictionaries/people.txt",
"description": "People names relevant to authentik",
"addWords": true
},
{
"name": "en-x-authentik-integrations",
"path": "./locale/en/dictionaries/integrations.txt",
"description": "English integration names",
"addWords": true
},
{
"name": "en-x-authentik-ignore",
"path": "./locale/en/dictionaries/ignore.txt",
"description": "English ignore list for authentik",
"addWords": true,
"noSuggest": true
}
],
"dictionaries": [
"en-x-authentik-software-terms",
"en-x-authentik-idp",
"en-x-authentik-ignore",
"en-x-authentik-people",
"en-x-authentik-integrations",
"node",
"softwareTerms",
"software-tools",
"computing-acronyms",
"companies",
"cpp-compound-words"
],
"allowCompoundWords": true,
"patterns": [
{
"name": "EncodedURI",
"description": "Encoded URIs, which are common in authentik's codebase and often contain many false positives.",
"pattern": "[a-zA-Z]+%3A%2F%2F.+"
},
{
"name": "ConfSuffix",
"description": "Variables with `conf` or `config` suffix",
"pattern": ["\\w+(conf|config)\\b", "\\b(conf|config)\\w+"]
}
],
"ignoreRegExpList": [
// DB Migrations
"authentik_c_\\w+_[0-9a-fA-F]+_idx",
// Google Analytics
"/G-[0-9A-Z]+/",
// Github Usernames
"@[a-zA-Z0-9_-]+",
// GitHub repositories
"github\\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+",
// Docker images
"docker\\.io/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+",
// Suffix "change", which is common in migration files
"\\w+change\\b",
// Prefix "on", which is common in event handlers
"\\bon\\w+\\b",
// Prefix "pg", which is common in PostgreSQL-related code
"\\bpg\\w+\\b",
// Prefix "pf", which is common in PatternFly-related code
"\\bpf\\w+\\b",
// Prefix "ws", which is common in WebSocket-related code
"\\bws\\w+\\b",
// Suffix "propertymapping"
"\\w+propertymapping\\b",
// Words that end with "source", "provider", "user", "group", or "connection",
// which are common in authentik's codebase and often contain many false positives.
"\\w+(source|provider)(user|group|connection)\\b",
"\\w+(source|provider)(user|group|connection)",
// Basic auth header
"Basic [a-zA-Z0-9+/=]+",
// "ify" suffix, e.g. "stringify", "classify".
"\\w+l?ify\\b",
// "ified" suffix, e.g. "stringified", "classified".
"\\w+l?ified\\b",
// "ifying" suffix, e.g. "stringifying", "classifying".
"\\w+l?ifying\\b",
"SpellCheckerIgnoreInDocSetting",
"EncodedURI",
"Urls",
"href",
"Base64",
"PublicKey",
"RsaCert",
"SshRsa",
"UnicodeRef",
"Email",
"HashStrings"
],
"languageSettings": [
{
"languageId": "markdown,mdx",
"dictionaries": ["en-x-authentik-python", "en-x-authentik-golang"],
"ignoreRegExpList": [
// Fenced code blocks
"/^\\s*```[\\s\\S]*?^\\s*```/gm",
// Markdown inline codeblocks
"`[^`\\s]+`",
"`\\w+[^`]*?\\w+`"
]
},
{
"languageId": "typescript,javascript,typescriptreact,javascriptreact,mdx,astro",
"ignoreRegExpList": [
// Event handlers e.g. onClick, onmouseover
"\\bon\\w+\\b",
// Custom web component tags e.g. <ak-button>, <ak-toggle-group>
"</?ak-[a-z0-9-]+",
// Scoped import paths, e.g. @webcomponents/webcomponentsjs
"@[a-z0-9-]+/[a-z0-9-]+",
// Import paths that end with "js", which are often false positives
// and not worth the effort of creating a custom dictionary for.
"[a-z0-9-]+js",
"ConfSuffix",
"js-hex-escape",
"js-unicode-escape",
"js-regexp-flags",
"js-hex-number"
]
},
{
"languageId": "python",
"dictionaries": ["en-x-authentik-python"],
"includeRegExpList": ["comments"]
},
{
"languageId": "go",
"dictionaries": ["en-x-authentik-golang"]
},
{
"languageId": "makefile",
"dictionaries": ["en-x-authentik-python", "en-x-authentik-golang"]
},
{
"languageId": "css,scss",
"ignoreRegExpList": [
// data URIs, which are common in CSS and often contain many false positives.
"data:.+"
]
}
],
"ignorePaths": [
//#region i18n
"{cspell.*,cSpell.*,.cspell.*,cspell.config.*}", // CSpell configuration files
"cspell-report.{json,html,txt}", // CSpell report files
"dictionaries", // Custom dictionary files
"ignore.txt", // Custom ignore list files
"./locale", // Locale files (Django, CSpell)
"web/xliff", // XLIFF translation files
"web/src/locales", // Generated TypeScript locale
//#endregion
//#region Monorepo
"CODEOWNERS", // GitHub code owners file
"LICENSE", // License file
".gitignore", // Git ignore file
".gitattributes", // Git attributes file
"*-lock.{json,yaml}", // Lock files (NPM, Yarn, Pip, Cargo)
"CHANGELOG*.md", // Changelog files
".vscode/**", // VSCode configuration
"out", // TypeScript type-checking output
"dist", // Distributed build output
"coverage/**", // Coverage output
".env", // Environment files
"package-lock.json", // NPM package lock
"schema.yml", // OpenAPI schema
"./blueprints/schema.json", // Generated blueprint schema
"custom-elements.json", // TypeScript custom element definitions
"./gen-*-api", // Generated API Client
"./schemas/**", // XML Schemas
"./authentik/sources/**/schemas", // Source schemas
"**vendored**", // Vendored files
"fixtures", // Test fixtures
"tests/e2e/**/*.php", // PHP fixtures
"compose.yml", // Docker Compose files
//#region JavaScript/TypeScript
".eslintignore", // ESLint ignore file
".prettierignore", // Prettier ignore file
".yarn", // Yarn cache and configuration
"node_modules", // Node modules
"playwright-report", // Playwright test output
"package.json", // Package manifest file
"storybook-static", // Storybook build output
"sampleData.{js,ts}", // Storybook sample data files
"*.stories.{ts,tsx}", // Storybook stories
"*.min.{js,css}", // Minified JS and CSS files
"*.min.{js,css}.map", // Source maps for minified files
//#region Python
"pyproject.toml",
"unittest.xml", // Pytest output
".venv", // Python virtual environment
"venv", // Python virtual environment
"./lifecycle",
"blueprints",
"mds",
//#endregion
//#region Rust
"./target", // Rust compilation artifacts
//#endregion
//#region Docusaurus
"*.api.mdx", // Generated API docs
".docusaurus/**", // Cache
"./{docs,website}/build", // Topic docs build output
"./{docs,website}/**/build", // Workspaces output
//#endregion
//#region Golang
"go.mod", // Go module file
"go.sum", // Go module file
"htmlcov", // Coverage HTML output
"coverage.txt", // Coverage text output
//#endregion
//#region Media
"./data", // Media files
"./media", // Legacy media files
"*.{png,jpg,pdf,svg}" // Binary files
//#endregion
],
"useGitignore": true,
"features": {
"weighted-suggestions": true
}
// "failFast": true,
}

61
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/getsentry/sentry-go v0.43.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-openapi/runtime v0.29.3
github.com/go-openapi/runtime v0.29.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
@@ -30,10 +30,10 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a
goauthentik.io/api/v3 v3.2026020.17-0.20260223141659-4c1444ee54d9
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
@@ -52,24 +52,24 @@ require (
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.24.3 // indirect
github.com/go-openapi/errors v0.22.7 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/loads v0.23.3 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/strfmt v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/fileutils v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/mangling v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-openapi/validate v0.25.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-openapi/analysis v0.24.1 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/loads v0.23.2 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/mangling v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-openapi/validate v0.25.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -79,22 +79,23 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

143
go.sum
View File

@@ -41,50 +41,50 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk=
github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw=
github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA=
github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ=
github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA=
github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y=
github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/strfmt v0.26.0 h1:SDdQLyOEqu8W96rO1FRG1fuCtVyzmukky0zcD6gMGLU=
github.com/go-openapi/strfmt v0.26.0/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk=
github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw=
github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q=
github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw=
github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0=
github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM=
github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84=
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4=
github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY=
github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0=
github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw=
github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -154,9 +154,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s=
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
@@ -197,31 +196,33 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
goauthentik.io/api/v3 v3.2026020.17-0.20260304104333-840924fe52c4 h1:zjmi1QNVQPABt0Yx5hws1lXR3tuTI23Ae7MwXffbP/s=
goauthentik.io/api/v3 v3.2026020.17-0.20260304104333-840924fe52c4/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a h1:CipAaiYqzzyhQDO6xg3YfEC0saoyVCFFbUjRfAsJrxs=
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
goauthentik.io/api/v3 v3.2026020.17-0.20260217173516-3a500f6eed7d h1:Gb26L41O+Q7l+57wkXI1BaG+lCWRteZ9tlaabjMkb3U=
goauthentik.io/api/v3 v3.2026020.17-0.20260217173516-3a500f6eed7d/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
goauthentik.io/api/v3 v3.2026020.17-0.20260223141659-4c1444ee54d9 h1:tuvgm4e1nV0ZPZy24wOeJcuAbMnhbJA09BuI2fzBHRk=
goauthentik.io/api/v3 v3.2026020.17-0.20260223141659-4c1444ee54d9/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab h1:628ME69lBm9C6JY2wXhAph/yjN3jezx1z7BIDLUwxjo=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -231,15 +232,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -249,8 +250,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -262,8 +263,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -26,6 +26,7 @@ import (
"goauthentik.io/api/v3"
"goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/hs256"
"goauthentik.io/internal/outpost/proxyv2/metrics"
"goauthentik.io/internal/outpost/proxyv2/templates"
@@ -293,16 +294,22 @@ func (a *Application) Stop() {
func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) {
redirect := a.endpoint.EndSessionEndpoint
cc := a.getClaimsFromSession(rw, r)
if cc == nil {
s, err := a.sessions.Get(r, a.SessionName())
if err != nil {
a.redirectToStart(rw, r)
return
}
c, exists := s.Values[constants.SessionClaims]
if c == nil && !exists {
a.redirectToStart(rw, r)
return
}
cc := c.(types.Claims)
uv := url.Values{
"id_token_hint": []string{cc.RawToken},
}
redirect += "?" + uv.Encode()
err := a.Logout(r.Context(), func(c types.Claims) bool {
err = a.Logout(r.Context(), func(c types.Claims) bool {
return c.Sub == cc.Sub
})
if err != nil {

View File

@@ -187,7 +187,10 @@ func BuildConnConfig(cfg config.PostgreSQLConfig) (*pgx.ConnConfig, error) {
if connConfig.RuntimeParams == nil {
connConfig.RuntimeParams = make(map[string]string)
}
effectiveSearchPath := cfg.DefaultSchema
if cfg.DefaultSchema != "" {
connConfig.RuntimeParams["search_path"] = cfg.DefaultSchema
}
// Parse and apply connection options if specified
if cfg.ConnOptions != "" {
@@ -195,39 +198,12 @@ func BuildConnConfig(cfg config.PostgreSQLConfig) (*pgx.ConnConfig, error) {
if err != nil {
return nil, fmt.Errorf("failed to parse connection options: %w", err)
}
// search_path from ConnOptions is not supported here; Django controls schema selection.
// Always remove it so it cannot end up in startup RuntimeParams via applyConnOptions.
delete(connOpts, "search_path")
if err := applyConnOptions(connConfig, connOpts); err != nil {
return nil, fmt.Errorf("failed to apply connection options: %w", err)
}
}
// search_path may already be present via pgx/libpq inherited defaults (e.g. service files).
// Always remove it from startup RuntimeParams; apply it via AfterConnect instead.
if inheritedSearchPath, hasInheritedSearchPath := connConfig.RuntimeParams["search_path"]; hasInheritedSearchPath {
if effectiveSearchPath == "" {
effectiveSearchPath = inheritedSearchPath
}
delete(connConfig.RuntimeParams, "search_path")
}
// Set search_path after connection startup to avoid startup-parameter issues with PgBouncer.
if effectiveSearchPath != "" {
connConfig.AfterConnect = func(ctx context.Context, pgConn *pgconn.PgConn) error {
result := pgConn.ExecParams(
ctx,
"select pg_catalog.set_config('search_path', $1, false)",
[][]byte{[]byte(effectiveSearchPath)},
nil,
nil,
nil,
).Read()
return result.Err
}
}
return connConfig, nil
}

View File

@@ -700,7 +700,7 @@ func TestBuildConnConfig(t *testing.T) {
DefaultSchema: "custom_schema",
},
validate: func(t *testing.T, cc *pgx.ConnConfig) {
assert.NotNil(t, cc.AfterConnect)
assert.Equal(t, "custom_schema", cc.RuntimeParams["search_path"])
},
},
{
@@ -756,7 +756,7 @@ func TestBuildConnConfig(t *testing.T) {
assert.Equal(t, "admin", cc.User)
assert.Equal(t, "my super secret password!@#", cc.Password)
assert.Equal(t, "production", cc.Database)
assert.NotNil(t, cc.AfterConnect)
assert.Equal(t, "app_schema", cc.RuntimeParams["search_path"])
assert.Equal(t, "authentik", cc.RuntimeParams["application_name"])
},
},
@@ -863,7 +863,7 @@ func TestBuildConnConfig_WithSSLCertificates(t *testing.T) {
assert.Equal(t, "db.example.com", cc.TLSConfig.ServerName)
assert.NotNil(t, cc.TLSConfig.RootCAs)
assert.Len(t, cc.TLSConfig.Certificates, 1)
assert.NotNil(t, cc.AfterConnect)
assert.Equal(t, "app_schema", cc.RuntimeParams["search_path"])
assert.Equal(t, "authentik", cc.RuntimeParams["application_name"])
},
},
@@ -1357,83 +1357,6 @@ func TestBuildConnConfig_WithBase64EncodedConnOptions(t *testing.T) {
}
}
// Verifies DefaultSchema is applied via AfterConnect and never via startup RuntimeParams.
func TestBuildConnConfig_SearchPath_DefaultSchema(t *testing.T) {
cfg := config.PostgreSQLConfig{
Host: "localhost",
Port: "5432",
User: "authentik",
Name: "authentik",
DefaultSchema: "default_schema",
}
connConfig, err := BuildConnConfig(cfg)
require.NoError(t, err)
require.NotNil(t, connConfig.AfterConnect)
_, hasSearchPath := connConfig.RuntimeParams["search_path"]
assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams")
}
// Verifies ConnOptions search_path is ignored and excluded from startup RuntimeParams.
func TestBuildConnConfig_SearchPath_ConnOptions(t *testing.T) {
cfg := config.PostgreSQLConfig{
Host: "localhost",
Port: "5432",
User: "authentik",
Name: "authentik",
ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"search_path":"connopt_schema"}`)),
}
connConfig, err := BuildConnConfig(cfg)
require.NoError(t, err)
assert.Nil(t, connConfig.AfterConnect)
_, hasSearchPath := connConfig.RuntimeParams["search_path"]
assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams")
}
// Verifies ConnOptions search_path does not override DefaultSchema and other conn options still apply.
func TestBuildConnConfig_SearchPath_ConnOptionsIgnoredWhenDefaultSchemaSet(t *testing.T) {
cfg := config.PostgreSQLConfig{
Host: "localhost",
Port: "5432",
User: "authentik",
Name: "authentik",
DefaultSchema: "default_schema",
ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"search_path":"override_schema","application_name":"authentik-proxy"}`)),
}
connConfig, err := BuildConnConfig(cfg)
require.NoError(t, err)
require.NotNil(t, connConfig.AfterConnect)
assert.Equal(t, "authentik-proxy", connConfig.RuntimeParams["application_name"])
_, hasSearchPath := connConfig.RuntimeParams["search_path"]
assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams")
}
// Verifies inherited search_path from pgx/libpq defaults is removed from startup RuntimeParams.
func TestBuildConnConfig_SearchPath_InheritedServiceSetting(t *testing.T) {
serviceFile := filepath.Join(t.TempDir(), "pg_service.conf")
err := os.WriteFile(serviceFile, []byte("[authentik-test]\nsearch_path=service_schema\n"), 0o600)
require.NoError(t, err)
t.Setenv("PGSERVICE", "authentik-test")
t.Setenv("PGSERVICEFILE", serviceFile)
cfg := config.PostgreSQLConfig{
Host: "localhost",
Port: "5432",
User: "authentik",
Name: "authentik",
}
connConfig, err := BuildConnConfig(cfg)
require.NoError(t, err)
require.NotNil(t, connConfig.AfterConnect)
_, hasSearchPath := connConfig.RuntimeParams["search_path"]
assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams")
}
// TestBuildConnConfig_TargetSessionAttrs demonstrates how target_session_attrs
// should be properly handled using pgx's ValidateConnect callback
func TestBuildConnConfig_TargetSessionAttrs(t *testing.T) {

View File

@@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1109.0",
"aws-cdk": "^2.1107.0",
"cross-env": "^10.1.0"
},
"engines": {
@@ -25,9 +25,9 @@
"license": "MIT"
},
"node_modules/aws-cdk": {
"version": "2.1109.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1109.0.tgz",
"integrity": "sha512-K0jvr5ne9kvDrFfdzbPee/s2rH/iXdGoMHTp/0jaj1qFMOh49RkLWTnURa0sBpJJ0uB2sMzIx7YRmAn55wAy1Q==",
"version": "2.1107.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1107.0.tgz",
"integrity": "sha512-7GKCq7p/33Jw+C+Ohwl4LnnKjvI/MzemeNZlTu/Kg8IwuZx5WEXEi32YLOlxbE1JOvleDslCWK5AIkBZ0omx/Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -7,7 +7,7 @@
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
},
"devDependencies": {
"aws-cdk": "^2.1109.0",
"aws-cdk": "^2.1107.0",
"cross-env": "^10.1.0"
},
"engines": {

View File

@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.0-trixie@sha256:d0a3e4b733ecc47e92a7e7f0fa141392e5a2349e288470aad1ffd82552da5139 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -78,9 +78,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.10.9@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 AS uv
FROM ghcr.io/astral-sh/uv:0.10.6@sha256:2f2ccd27bbf953ec7a9e3153a4563705e41c852a5e1912b438fc44d88d6cb52c AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.14.3-slim-trixie-fips@sha256:b481db20729091baf12e3641ae49c9d7240902d48d4454658f2cdeb2828b5709 AS python-base
FROM ghcr.io/goauthentik/fips-python:3.14.3-slim-trixie-fips@sha256:de8ad649ed77baa64c07deb0dba2151e18dcb0408fe6ff37bdef236aabb9a576 AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.0-trixie@sha256:d0a3e4b733ecc47e92a7e7f0fa141392e5a2349e288470aad1ffd82552da5139 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/ldap ./cmd/ldap
# Stage 2: Run
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:6c9197b97d80904ad9f64a9b89fef4f6f30e95ba1c015b1185b96ed2483dc9c3
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7b82e2433395fed1e400120bcd1686de2faba9f59251e19b60dd7dd1ed9efe42
ARG VERSION
ARG GIT_BUILD_HASH

View File

@@ -17,7 +17,7 @@ COPY web .
RUN npm run build-proxy
# Stage 2: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.0-trixie@sha256:d0a3e4b733ecc47e92a7e7f0fa141392e5a2349e288470aad1ffd82552da5139 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -47,7 +47,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/proxy ./cmd/proxy
# Stage 3: Run
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:6c9197b97d80904ad9f64a9b89fef4f6f30e95ba1c015b1185b96ed2483dc9c3
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7b82e2433395fed1e400120bcd1686de2faba9f59251e19b60dd7dd1ed9efe42
ARG VERSION
ARG GIT_BUILD_HASH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.0-trixie@sha256:d0a3e4b733ecc47e92a7e7f0fa141392e5a2349e288470aad1ffd82552da5139 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.1-trixie@sha256:ab8c4944b04c6f97c2b5bffce471b7f3d55f2228badc55eae6cce87596d5710b AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.0-trixie@sha256:d0a3e4b733ecc47e92a7e7f0fa141392e5a2349e288470aad1ffd82552da5139 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/radius ./cmd/radius
# Stage 2: Run
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:6c9197b97d80904ad9f64a9b89fef4f6f30e95ba1c015b1185b96ed2483dc9c3
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7b82e2433395fed1e400120bcd1686de2faba9f59251e19b60dd7dd1ed9efe42
ARG VERSION
ARG GIT_BUILD_HASH

View File

@@ -42,8 +42,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
preload_app = True
max_requests = CONFIG.get_int("web.max_requests", 1000)
max_requests_jitter = CONFIG.get_int("web.max_requests_jitter", 50)
max_requests = 1000
max_requests_jitter = 50
logconfig_dict = get_logger_config()

View File

@@ -1,20 +0,0 @@
# Golang-specific terms
gounicorn
pems
connm
Debugf
Infof
Warnf
layeh
Warningf
goldap
goauthentikio
singlevg
accsp
uapisp
GORMDB
golangci
gorm
gorm
gorm*
logger

View File

@@ -1,6 +0,0 @@
# IdP-specific terms
authentik
Yubi
Yubikey
Yubikeys
mycorp

Some files were not shown because too many files have changed in this diff Show More