mirror of
https://github.com/goauthentik/authentik
synced 2026-05-11 17:36:35 +02:00
Compare commits
164 Commits
sdko/learn
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2feaeff5db | ||
|
|
8fcc47e047 | ||
|
|
7a6408cc67 | ||
|
|
2da88028da | ||
|
|
fa91404895 | ||
|
|
460fce7279 | ||
|
|
995128955c | ||
|
|
85536abbcf | ||
|
|
5249546862 | ||
|
|
bf91348c05 | ||
|
|
63136f0180 | ||
|
|
faffabf938 | ||
|
|
0b180b15a2 | ||
|
|
07af6de74f | ||
|
|
ddfef91ea5 | ||
|
|
cefbf5e6ae | ||
|
|
e53d3d2486 | ||
|
|
32a3eed521 | ||
|
|
f05cc6e75a | ||
|
|
c68c36fdeb | ||
|
|
888f969fc7 | ||
|
|
82535e4671 | ||
|
|
ed2957e4e6 | ||
|
|
a5abe85148 | ||
|
|
8d2c31fa25 | ||
|
|
2637ce2474 | ||
|
|
319008dec8 | ||
|
|
8beb2fac18 | ||
|
|
ac7b28d0b0 | ||
|
|
073acf92c2 | ||
|
|
ad107c19af | ||
|
|
d285fcd8a7 | ||
|
|
84066cab48 | ||
|
|
e623d93ff5 | ||
|
|
1d0628dfbe | ||
|
|
996645105c | ||
|
|
63d7ca6ef0 | ||
|
|
5b24f4ad80 | ||
|
|
ed2e6cfb9c | ||
|
|
a1431ea48e | ||
|
|
b30e77b363 | ||
|
|
2f50cdd9fe | ||
|
|
494bdcaa09 | ||
|
|
e36ce1789e | ||
|
|
5a72ed83e0 | ||
|
|
f72d257e43 | ||
|
|
cbedb16cc4 | ||
|
|
6fc1b5ce90 | ||
|
|
57b0fa48c1 | ||
|
|
84a344ed87 | ||
|
|
f864cb56ab | ||
|
|
692735f9e1 | ||
|
|
e24fb300b1 | ||
|
|
f0e90d6873 | ||
|
|
0cf45835a0 | ||
|
|
69d35c1d26 | ||
|
|
ac803b210d | ||
|
|
c9728b4607 | ||
|
|
6e45584563 | ||
|
|
59a2e84b35 | ||
|
|
6025dbb9c9 | ||
|
|
d07bcd5025 | ||
|
|
e80655d285 | ||
|
|
e0d3d4d38c | ||
|
|
62112404ee | ||
|
|
1c9e12fcd9 | ||
|
|
42c6c257ec | ||
|
|
41bd9d7913 | ||
|
|
2c84935732 | ||
|
|
819c13a9bc | ||
|
|
0d8f366af8 | ||
|
|
093e60c753 | ||
|
|
af646f32d2 | ||
|
|
de4afc7322 | ||
|
|
bc1983106f | ||
|
|
8c2c1474f1 | ||
|
|
0dccbd4193 | ||
|
|
6a70894e01 | ||
|
|
2f5eb9b2e4 | ||
|
|
12aedb3a9e | ||
|
|
303dc93514 | ||
|
|
fbb217db57 | ||
|
|
4de253653f | ||
|
|
4154c06831 | ||
|
|
4750ed5e2a | ||
|
|
361017127d | ||
|
|
0ca5a54307 | ||
|
|
ef1aad5dbb | ||
|
|
29d880920e | ||
|
|
fc6f8374e6 | ||
|
|
a8668bbac4 | ||
|
|
d686932166 | ||
|
|
feceb220b1 | ||
|
|
937df6e07f | ||
|
|
48e6b968a6 | ||
|
|
cd89c45e75 | ||
|
|
e53995e2c1 | ||
|
|
33d5f11f0e | ||
|
|
565e16eca7 | ||
|
|
9a0164b722 | ||
|
|
8af491630b | ||
|
|
8e25e7a213 | ||
|
|
4d183657da | ||
|
|
be89b6052d | ||
|
|
ad5d2bb611 | ||
|
|
8d30fb3d25 | ||
|
|
cea3fbfa9b | ||
|
|
151d889ff4 | ||
|
|
58ca3ecbd5 | ||
|
|
1a6c7082a3 | ||
|
|
1dc60276f9 | ||
|
|
de045c6d7b | ||
|
|
850728e9bb | ||
|
|
84a605a4ba | ||
|
|
1780bb0cf0 | ||
|
|
cd75fe235d | ||
|
|
e6e62e9de1 | ||
|
|
ac7a4f8a22 | ||
|
|
0290ed3342 | ||
|
|
e367525794 | ||
|
|
93c319baee | ||
|
|
1d02ee7d74 | ||
|
|
93439b5742 | ||
|
|
6682a6664e | ||
|
|
0b5bac74e9 | ||
|
|
062823f1b2 | ||
|
|
a17fe58971 | ||
|
|
422ea893b1 | ||
|
|
15c9f93851 | ||
|
|
e2202d498b | ||
|
|
9ea9a86ad3 | ||
|
|
4bac1edd61 | ||
|
|
24726be3c9 | ||
|
|
411f06756f | ||
|
|
4bdcab48c3 | ||
|
|
00dbd377a7 | ||
|
|
a01c0575db | ||
|
|
6e51d044bb | ||
|
|
6d1b168dc4 | ||
|
|
43675c2b22 | ||
|
|
8645273eaf | ||
|
|
eb6f4712fe | ||
|
|
7b9505242e | ||
|
|
3dda20ebc7 | ||
|
|
dfd2bc5c3c | ||
|
|
06a270913c | ||
|
|
430507fc72 | ||
|
|
847af7f9ea | ||
|
|
8f1cb636e8 | ||
|
|
e61c876002 | ||
|
|
33c0d3df0a | ||
|
|
3a03e1ebfd | ||
|
|
1e41b77761 | ||
|
|
6c1662f99f | ||
|
|
bb5bc5c8da | ||
|
|
30670c9070 | ||
|
|
fdbf9ffedc | ||
|
|
2ec433d724 | ||
|
|
55297b9e6a | ||
|
|
f9dda6582c | ||
|
|
3394c17bfd | ||
|
|
a37d101b10 | ||
|
|
4774b4db87 | ||
|
|
fdb52c9394 |
23
.github/actions/cherry-pick/action.yml
vendored
23
.github/actions/cherry-pick/action.yml
vendored
@@ -115,20 +115,13 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
|
||||
REASON: ${{ steps.should_run.outputs.reason }}
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
|
||||
|
||||
# Get PR details
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
|
||||
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine which labels to process
|
||||
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
|
||||
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
|
||||
# Only process the specific label that was just added
|
||||
if [ "${{ github.event_name }}" = "issues" ]; then
|
||||
LABEL_NAME="${{ github.event.label.name }}"
|
||||
@@ -152,13 +145,13 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
LABELS: '${{ steps.pr_details.outputs.labels }}'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER='${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA='${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE='${{ steps.pr_details.outputs.pr_title }}'
|
||||
PR_AUTHOR='${{ steps.pr_details.outputs.pr_author }}'
|
||||
LABELS='${{ steps.pr_details.outputs.labels }}'
|
||||
|
||||
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
|
||||
echo "Found backport labels: $LABELS"
|
||||
|
||||
@@ -89,6 +89,8 @@ if should_push:
|
||||
_cache_tag = "buildcache"
|
||||
if image_arch:
|
||||
_cache_tag += f"-{image_arch}"
|
||||
if is_release:
|
||||
_cache_tag += f"-{version_family}"
|
||||
cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"
|
||||
|
||||
|
||||
|
||||
40
.github/actions/setup/action.yml
vendored
40
.github/actions/setup/action.yml
vendored
@@ -8,45 +8,61 @@ inputs:
|
||||
postgresql_version:
|
||||
description: "Optional postgresql image tag"
|
||||
default: "16"
|
||||
working-directory:
|
||||
description: |
|
||||
Optional working directory if this repo isn't in the root of the actions workspace.
|
||||
When set, needs to contain a trailing slash
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install apt deps & cleanup
|
||||
- name: Cleanup apt
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: sudo apt-get remove --purge man-db
|
||||
- name: Install apt deps
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
|
||||
with:
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
||||
update: true
|
||||
upgrade: false
|
||||
install-recommends: false
|
||||
- name: Make space on disk
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get remove --purge man-db
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo mkdir -p /tmp/empty/
|
||||
sudo rsync -a --delete /tmp/empty/ /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
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
python-version-file: "${{ inputs.working-directory }}pyproject.toml"
|
||||
- name: Install Python deps
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup node
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
node-version-file: ${{ inputs.working-directory }}web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
cache-dependency-path: ${{ inputs.working-directory }}web/package-lock.json
|
||||
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"
|
||||
go-version-file: "${{ inputs.working-directory }}go.mod"
|
||||
- name: Setup docker cache
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||
@@ -55,6 +71,7 @@ runs:
|
||||
- name: Setup dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/compose.yml up -d
|
||||
@@ -62,6 +79,7 @@ runs:
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: uv run python {0}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
from authentik.lib.generators import generate_id
|
||||
from yaml import safe_dump
|
||||
|
||||
17
.github/dependabot.yml
vendored
17
.github/dependabot.yml
vendored
@@ -38,21 +38,6 @@ updates:
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rust
|
||||
|
||||
- package-ecosystem: rust-toolchain
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
#endregion
|
||||
|
||||
#region Web
|
||||
|
||||
- package-ecosystem: npm
|
||||
@@ -249,7 +234,7 @@ updates:
|
||||
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- /lifecycle/container
|
||||
- /
|
||||
- /website
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # 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:
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,14 +90,14 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@8aac06098a12365ccdf99372dcfb453ccce8a0b0 # v2
|
||||
- uses: int128/docker-manifest-create-action@1a059c021f1d5e9f2bd39de745d5dd3a0ef6df90 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/api-ts-publish.yml
vendored
2
.github/workflows/api-ts-publish.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/ci-api-docs.yml
vendored
4
.github/workflows/ci-api-docs.yml
vendored
@@ -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,7 +67,7 @@ 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
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -96,7 +96,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # 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:
|
||||
|
||||
16
.github/workflows/ci-main-daily.yml
vendored
16
.github/workflows/ci-main-daily.yml
vendored
@@ -6,10 +6,6 @@ on:
|
||||
schedule:
|
||||
# Every night at 3am
|
||||
- cron: "0 3 * * *"
|
||||
pull_request:
|
||||
paths:
|
||||
# Needs to refer to itself
|
||||
- .github/workflows/ci-main-daily.yml
|
||||
|
||||
jobs:
|
||||
test-container:
|
||||
@@ -19,14 +15,14 @@ jobs:
|
||||
matrix:
|
||||
version:
|
||||
- docs
|
||||
- version-2025-12
|
||||
- version-2025-10
|
||||
- version-2025-4
|
||||
- version-2025-2
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- run: |
|
||||
current="$(pwd)"
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
mkdir -p "${dir}/lifecycle/container"
|
||||
cd "${dir}"
|
||||
wget "https://${{ matrix.version }}.goauthentik.io/docker-compose.yml" -O "${dir}/lifecycle/container/compose.yml"
|
||||
"${current}/scripts/test_docker.sh"
|
||||
mkdir -p $dir
|
||||
cd $dir
|
||||
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
|
||||
${current}/scripts/test_docker.sh
|
||||
|
||||
15
.github/workflows/ci-main.yml
vendored
15
.github/workflows/ci-main.yml
vendored
@@ -42,16 +42,6 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
- name: run job
|
||||
run: uv run make ci-${{ matrix.job }}
|
||||
test-gen-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: generate schema
|
||||
run: make migrate gen-build
|
||||
- name: ensure schema is up-to-date
|
||||
run: git diff --exit-code -- schema.yml blueprints/schema.json
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -170,7 +160,7 @@ jobs:
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0
|
||||
- name: run integration
|
||||
run: |
|
||||
uv run coverage run manage.py test tests/integration
|
||||
@@ -279,7 +269,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/
|
||||
@@ -287,7 +277,6 @@ jobs:
|
||||
if: always()
|
||||
needs:
|
||||
- lint
|
||||
- test-gen-build
|
||||
- test-migrations
|
||||
- test-migrations-from-stable
|
||||
- test-unittest
|
||||
|
||||
10
.github/workflows/ci-outpost.yml
vendored
10
.github/workflows/ci-outpost.yml
vendored
@@ -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
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: make gen-client-go
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # 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,7 +148,7 @@ 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
|
||||
|
||||
2
.github/workflows/gen-image-compress.yml
vendored
2
.github/workflows/gen-image-compress.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
2
.github/workflows/gh-cherry-pick.yml
vendored
2
.github/workflows/gh-cherry-pick.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
if: ${{ env.GH_APP_ID != '' }}
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
|
||||
2
.github/workflows/gh-ghcr-retention.yml
vendored
2
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
|
||||
with:
|
||||
|
||||
3
.github/workflows/packages-npm-publish.yml
vendored
3
.github/workflows/packages-npm-publish.yml
vendored
@@ -29,7 +29,6 @@ jobs:
|
||||
- packages/eslint-config
|
||||
- packages/prettier-config
|
||||
- packages/docusaurus-config
|
||||
- packages/logger-js
|
||||
- packages/esbuild-plugin-live-reload
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
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@8cba46e29c11878d930bca7870bb54394d3e8b21 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
|
||||
4
.github/workflows/release-branch-off.yml
vendored
4
.github/workflows/release-branch-off.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
|
||||
14
.github/workflows/release-publish.yml
vendored
14
.github/workflows/release-publish.yml
vendored
@@ -51,14 +51,14 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # 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,7 +84,7 @@ 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # 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,7 +152,7 @@ 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@6044e13b5dc448c55e2357c09f80417699197238 # v5
|
||||
@@ -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 }}
|
||||
|
||||
11
.github/workflows/release-tag.yml
vendored
11
.github/workflows/release-tag.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
@@ -91,6 +91,7 @@ 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
|
||||
@@ -117,7 +118,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
@@ -159,7 +160,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
@@ -174,7 +175,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}" \
|
||||
@@ -186,7 +187,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}" \
|
||||
|
||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -18,8 +18,8 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
|
||||
@@ -34,7 +34,6 @@ packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
packages/prettier-config @goauthentik/frontend
|
||||
packages/logger-js @goauthentik/frontend
|
||||
packages/tsconfig @goauthentik/frontend
|
||||
# Web
|
||||
web/ @goauthentik/frontend
|
||||
|
||||
18
Makefile
18
Makefile
@@ -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 \
|
||||
|
||||
42
SECURITY.md
42
SECURITY.md
@@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
(.x being the latest patch release for each version)
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ---------- |
|
||||
| 2025.12.x | ✅ |
|
||||
| 2026.2.x | ✅ |
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2025.12.x | ✅ |
|
||||
| 2026.2.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -60,6 +60,40 @@ authentik reserves the right to reclassify CVSS as necessary. To determine sever
|
||||
| 7.0 – 8.9 | High |
|
||||
| 9.0 – 10.0 | Critical |
|
||||
|
||||
## Intended functionality
|
||||
|
||||
The following capabilities are part of intentional system design and should not be reported as security vulnerabilities:
|
||||
|
||||
- Expressions (property mappings/policies/prompts) can execute arbitrary Python code without safeguards.
|
||||
|
||||
This is expected behavior. Any user with permission to create or modify objects containing expression fields can write code that is executed within authentik. If a vulnerability allows a user without the required permissions to write or modify code and have it executed, that would be a valid security report.
|
||||
|
||||
However, the fact that expressions are executed as part of normal operations is not considered a privilege escalation or security vulnerability.
|
||||
|
||||
- Blueprints can access all files on the filesystem.
|
||||
|
||||
This access is intentional to allow legitimate configuration and deployment tasks. It does not represent a security problem by itself.
|
||||
|
||||
- Importing blueprints allows arbitrary modification of application objects.
|
||||
|
||||
This is intended functionality. This behavior reflects the privileged design of blueprint imports. It is "exploitable" when importing blueprints from untrusted sources without reviewing the blueprint beforehand. However, any method to create, modify or execute blueprints without the required permissions would be a valid security report.
|
||||
|
||||
- Flow imports may contain objects other than flows (such as policies, users, groups, etc.)
|
||||
|
||||
This is expected behavior as flow imports are blueprint files.
|
||||
|
||||
- Prompt HTML is not escaped.
|
||||
|
||||
Prompts intentionally allow raw HTML, including script tags, so they can be used to create interactive or customized user interface elements. Because of this, scripts within prompts may affect or interact with the surrounding page as designed.
|
||||
|
||||
- Open redirects that do not include tokens or other sensitive information are not considered a security vulnerability.
|
||||
|
||||
Redirects that only change navigation flow and do not expose session tokens, API keys, or other confidential data are considered acceptable and do not require reporting.
|
||||
|
||||
- Outgoing network requests are not filtered.
|
||||
|
||||
The destinations of outgoing network requests (HTTP, TCP, etc.) made by authentik to configurable endpoints through objects such as OAuth Sources, SSO Providers, and others are not validated. Depending on your threat model, these requests should be restricted at the network level using appropriate firewall or network policies.
|
||||
|
||||
## Disclosure process
|
||||
|
||||
1. Report from Github or Issue is reported via Email as listed above.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.5.0-rc1"
|
||||
VERSION = "2026.2.3-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class Backend:
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
request: Optional Django HttpRequest for fully qualified URL building
|
||||
request: Optional Django HttpRequest for fully qualifed URL building
|
||||
use_cache: whether to retrieve the URL from cache
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -71,7 +71,7 @@ def postprocess_schema_responses(
|
||||
def postprocess_schema_query_params(
|
||||
result: dict[str, Any], generator: SchemaGenerator, **kwargs
|
||||
) -> dict[str, Any]:
|
||||
"""Optimize pagination parameters, instead of redeclaring parameters for each endpoint
|
||||
"""Optimise pagination parameters, instead of redeclaring parameters for each endpoint
|
||||
declare them globally and refer to them"""
|
||||
LOGGER.debug("Deduplicating query parameters")
|
||||
for path in result["paths"].values():
|
||||
|
||||
@@ -272,7 +272,7 @@ class Importer:
|
||||
and entry.state != BlueprintEntryDesiredState.MUST_CREATED
|
||||
):
|
||||
self.logger.debug(
|
||||
"Initialize serializer with instance",
|
||||
"Initialise serializer with instance",
|
||||
model=model,
|
||||
instance=model_instance,
|
||||
pk=model_instance.pk,
|
||||
@@ -290,7 +290,7 @@ class Importer:
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Initialized new serializer instance",
|
||||
"Initialised new serializer instance",
|
||||
model=model,
|
||||
**cleanse_dict(updated_identifiers),
|
||||
)
|
||||
|
||||
@@ -47,7 +47,12 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"""Application Serializer"""
|
||||
|
||||
launch_url = SerializerMethodField()
|
||||
provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True)
|
||||
provider_obj = ProviderSerializer(
|
||||
source="get_provider",
|
||||
required=False,
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
backchannel_providers_obj = ProviderSerializer(
|
||||
source="backchannel_providers", required=False, read_only=True, many=True
|
||||
)
|
||||
@@ -154,14 +159,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
return queryset
|
||||
|
||||
def _get_allowed_applications(
|
||||
self, paginated_apps: Iterator[Application], user: User | None = None
|
||||
self, pagined_apps: Iterator[Application], user: User | None = None
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
request = self.request._request
|
||||
if user:
|
||||
request = copy(request)
|
||||
request.user = user
|
||||
for application in paginated_apps:
|
||||
for application in pagined_apps:
|
||||
engine = PolicyEngine(application, request.user, request)
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
|
||||
@@ -1115,7 +1115,11 @@ class ExpiringModel(models.Model):
|
||||
default the object is deleted. This is less efficient compared
|
||||
to bulk deleting objects, but classes like Token() need to change
|
||||
values instead of being deleted."""
|
||||
return self.delete(*args, **kwargs)
|
||||
try:
|
||||
return self.delete(*args, **kwargs)
|
||||
except self.DoesNotExist:
|
||||
# Object has already been deleted, so this should be fine
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet[Self]:
|
||||
|
||||
@@ -24,7 +24,8 @@ from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest,
|
||||
# stage: Stage, context: dict[str, any]
|
||||
login_failed = Signal()
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestPropertyMappingAPI(APITestCase):
|
||||
PropertyMappingSerializer().validate_expression("/")
|
||||
|
||||
def test_types(self):
|
||||
"""Test PropertyMapping's types endpoint"""
|
||||
"""Test PropertyMappigns's types endpoint"""
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:propertymapping-types"),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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.models import EndpointStage
|
||||
from authentik.endpoints.controller import Capabilities
|
||||
from authentik.endpoints.models import Connector, EndpointStage
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
|
||||
|
||||
@@ -11,6 +14,13 @@ 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 + [
|
||||
|
||||
@@ -18,7 +18,10 @@ from authentik.rbac.decorators import permission_required
|
||||
class EnrollmentTokenSerializer(ModelSerializer):
|
||||
|
||||
device_group_obj = DeviceAccessGroupSerializer(
|
||||
source="device_group", read_only=True, required=False
|
||||
source="device_group",
|
||||
read_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
|
||||
@@ -37,6 +37,8 @@ class AgentEnrollmentAuth(BaseAuthentication):
|
||||
token = EnrollmentToken.filter_not_expired(key=key).first()
|
||||
if not token:
|
||||
raise PermissionDenied()
|
||||
if not token.connector.enabled:
|
||||
raise PermissionDenied()
|
||||
CTX_AUTH_VIA.set("endpoint_token_enrollment")
|
||||
return (DeviceUser(), token)
|
||||
|
||||
@@ -51,6 +53,8 @@ class AgentAuth(BaseAuthentication):
|
||||
device_token = DeviceToken.filter_not_expired(key=key).first()
|
||||
if not device_token:
|
||||
raise PermissionDenied()
|
||||
if not device_token.device.connector.enabled:
|
||||
raise PermissionDenied()
|
||||
if device_token.device.device.is_expired:
|
||||
raise PermissionDenied()
|
||||
CTX_AUTH_VIA.set("endpoint_token")
|
||||
|
||||
@@ -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
|
||||
from authentik.endpoints.controller import BaseController, Capabilities
|
||||
from authentik.endpoints.facts import OSFamily
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
def vendor_identifier() -> str:
|
||||
return "goauthentik.io/platform"
|
||||
|
||||
def supported_enrollment_methods(self):
|
||||
return []
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return [Capabilities.STAGE_ENDPOINTS]
|
||||
|
||||
def generate_mdm_config(
|
||||
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken
|
||||
|
||||
@@ -58,6 +58,16 @@ class TestAgentAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_enroll_disabled(self):
|
||||
self.connector.enabled = False
|
||||
self.connector.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-enroll"),
|
||||
data={"device_serial": generate_id(), "device_name": "bar"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_enroll_token_delete(self):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-enroll"),
|
||||
@@ -104,6 +114,16 @@ class TestAgentAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_config_disabled(self):
|
||||
self.connector.enabled = False
|
||||
self.connector.save()
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:agentconnector-agent-config"),
|
||||
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_check_in(self):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-check-in"),
|
||||
@@ -112,6 +132,16 @@ class TestAgentAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_check_in_disabled(self):
|
||||
self.connector.enabled = False
|
||||
self.connector.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-check-in"),
|
||||
data=CHECK_IN_DATA_VALID,
|
||||
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_check_in_token_expired(self):
|
||||
self.device_token.expiring = True
|
||||
self.device_token.expires = now() - timedelta(hours=1)
|
||||
|
||||
@@ -8,13 +8,15 @@ from authentik.lib.sentry import SentryIgnoredException
|
||||
MERGED_VENDOR = "goauthentik.io/@merged"
|
||||
|
||||
|
||||
class EnrollmentMethods(models.TextChoices):
|
||||
class Capabilities(models.TextChoices):
|
||||
# Automatically enrolled through user action
|
||||
AUTOMATIC_USER = "automatic_user"
|
||||
ENROLL_AUTOMATIC_USER = "enroll_automatic_user"
|
||||
# Automatically enrolled through connector integration
|
||||
AUTOMATIC_API = "automatic_api"
|
||||
ENROLL_AUTOMATIC_API = "enroll_automatic_api"
|
||||
# Manually enrolled with user interaction (user scanning a QR code for example)
|
||||
MANUAL_USER = "manual_user"
|
||||
ENROLL_MANUAL_USER = "enroll_manual_user"
|
||||
# Supported for use with Endpoints stage
|
||||
STAGE_ENDPOINTS = "stage_endpoints"
|
||||
|
||||
|
||||
class ConnectorSyncException(SentryIgnoredException):
|
||||
@@ -34,7 +36,7 @@ class BaseController[T: "Connector"]:
|
||||
def vendor_identifier() -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return []
|
||||
|
||||
def stage_view_enrollment(self) -> StageView | None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from authentik.endpoints.models import EndpointStage, StageMode
|
||||
from authentik.endpoints.models import Connector, EndpointStage, StageMode
|
||||
from authentik.flows.stage import StageView
|
||||
|
||||
PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
|
||||
@@ -8,7 +8,10 @@ class EndpointStageView(StageView):
|
||||
|
||||
def _get_inner(self) -> StageView | None:
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
inner_stage: type[StageView] | None = stage.connector.stage
|
||||
connector: Connector = stage.connector
|
||||
if not connector.enabled:
|
||||
return None
|
||||
inner_stage: type[StageView] | None = connector.stage
|
||||
if not inner_stage:
|
||||
return None
|
||||
return inner_stage(self.executor, request=self.request)
|
||||
|
||||
@@ -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 EnrollmentMethods
|
||||
from authentik.endpoints.controller import Capabilities
|
||||
from authentik.endpoints.models import Connector
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -17,11 +17,11 @@ def endpoints_sync(connector_pk: Any):
|
||||
connector: Connector | None = (
|
||||
Connector.objects.filter(pk=connector_pk).select_subclasses().first()
|
||||
)
|
||||
if not connector:
|
||||
if not connector or not connector.enabled:
|
||||
return
|
||||
controller = connector.controller
|
||||
ctrl = controller(connector)
|
||||
if EnrollmentMethods.AUTOMATIC_API not in ctrl.supported_enrollment_methods():
|
||||
if Capabilities.AUTOMATIC_API not in ctrl.capabilities():
|
||||
return
|
||||
LOGGER.info("Syncing connector", connector=connector.name)
|
||||
ctrl.sync_endpoints()
|
||||
|
||||
41
authentik/endpoints/tests/test_api.py
Normal file
41
authentik/endpoints/tests/test_api.py
Normal file
@@ -0,0 +1,41 @@
|
||||
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."]}
|
||||
)
|
||||
@@ -3,6 +3,7 @@ from hmac import compare_digest
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
|
||||
|
||||
from authentik.common.oauth.constants import QS_LOGIN_HINT
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
@@ -14,7 +15,7 @@ from authentik.enterprise.policy import EnterprisePolicyAccessView
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
||||
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
||||
|
||||
QS_AGENT_IA_TOKEN = "ak-auth-ia-token" # nosec
|
||||
@@ -64,14 +65,14 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
|
||||
|
||||
planner = FlowPlanner(self.connector.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
context = {
|
||||
PLAN_CONTEXT_DEVICE: self.device,
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
|
||||
}
|
||||
if QS_LOGIN_HINT in request.GET:
|
||||
context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = request.GET[QS_LOGIN_HINT]
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_DEVICE: self.device,
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
|
||||
},
|
||||
)
|
||||
plan = planner.plan(self.request, context)
|
||||
except FlowNonApplicableException:
|
||||
return self.handle_no_permission_authenticated()
|
||||
plan.append_stage(in_memory_stage(AgentAuthFulfillmentStage))
|
||||
@@ -84,7 +85,6 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
|
||||
|
||||
|
||||
class AgentAuthFulfillmentStage(StageView):
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
device: Device = self.executor.plan.context.pop(PLAN_CONTEXT_DEVICE)
|
||||
auth_token: DeviceAuthenticationToken = self.executor.plan.context.pop(
|
||||
|
||||
@@ -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, ConnectorSyncException, EnrollmentMethods
|
||||
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
|
||||
from authentik.endpoints.facts import (
|
||||
DeviceFacts,
|
||||
OSFamily,
|
||||
@@ -43,8 +43,8 @@ class FleetController(BaseController[DBC]):
|
||||
def vendor_identifier() -> str:
|
||||
return "fleetdm.com"
|
||||
|
||||
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
|
||||
return [EnrollmentMethods.AUTOMATIC_API]
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return [Capabilities.ENROLL_AUTOMATIC_API]
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.connector.url}{path}"
|
||||
|
||||
@@ -331,7 +331,7 @@ class GoogleWorkspaceGroupTests(TestCase):
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
|
||||
self.assertEqual(len(http.requests()), 5)
|
||||
self.assertEqual(len(http.requests()), 7)
|
||||
|
||||
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()), 5)
|
||||
self.assertEqual(len(http.requests()), 7)
|
||||
# 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",
|
||||
|
||||
@@ -309,7 +309,7 @@ class GoogleWorkspaceUserTests(TestCase):
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
|
||||
self.assertEqual(len(http.requests()), 5)
|
||||
self.assertEqual(len(http.requests()), 7)
|
||||
|
||||
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()), 5)
|
||||
self.assertEqual(len(http.requests()), 7)
|
||||
# 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",
|
||||
|
||||
@@ -93,11 +93,13 @@ def on_login_failed(
|
||||
credentials: dict[str, str],
|
||||
request: HttpRequest,
|
||||
stage: Stage | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Failed Login, authentik custom event"""
|
||||
user = User.objects.filter(username=credentials.get("username")).first()
|
||||
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(
|
||||
context = context or {}
|
||||
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **context).from_http(
|
||||
request, user
|
||||
)
|
||||
|
||||
|
||||
@@ -207,3 +207,9 @@ class TestEvents(TestCase):
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""Test creating an event with invalid unicode string data"""
|
||||
event = Event.new("unittest", foo="foo bar \u0000 baz")
|
||||
event.save()
|
||||
self.assertEqual(event.context["foo"], "foo bar baz")
|
||||
|
||||
@@ -36,6 +36,10 @@ ALLOWED_SPECIAL_KEYS = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def cleanse_str(raw: Any) -> str:
|
||||
return str(raw).replace("\u0000", "")
|
||||
|
||||
|
||||
def cleanse_item(key: str, value: Any) -> Any:
|
||||
"""Cleanse a single item"""
|
||||
if isinstance(value, dict):
|
||||
@@ -66,7 +70,7 @@ def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
|
||||
def model_to_dict(model: Model) -> dict[str, Any]:
|
||||
"""Convert model to dict"""
|
||||
name = str(model)
|
||||
name = cleanse_str(model)
|
||||
if hasattr(model, "name"):
|
||||
name = model.name
|
||||
return {
|
||||
@@ -133,11 +137,11 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
if isinstance(value, ASN):
|
||||
return ASN_CONTEXT_PROCESSOR.asn_to_dict(value)
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, Exception):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, YAMLTag):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, type):
|
||||
@@ -161,7 +165,7 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
raise ValueError("JSON can't represent timezone-aware times.")
|
||||
return value.isoformat()
|
||||
if isinstance(value, timedelta):
|
||||
return str(value.total_seconds())
|
||||
return cleanse_str(value.total_seconds())
|
||||
if callable(value):
|
||||
return {
|
||||
"type": "callable",
|
||||
@@ -174,8 +178,8 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
try:
|
||||
return DjangoJSONEncoder().default(value)
|
||||
except TypeError:
|
||||
return str(value)
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
return cleanse_str(value)
|
||||
|
||||
|
||||
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
|
||||
@@ -29,6 +29,12 @@ 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"""
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -183,3 +182,5 @@ storage:
|
||||
# backend: file # or s3
|
||||
# file: {}
|
||||
# s3: {}
|
||||
|
||||
skip_migrations: false
|
||||
|
||||
@@ -103,6 +103,7 @@ 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)
|
||||
@@ -111,6 +112,35 @@ 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,
|
||||
|
||||
@@ -185,8 +185,10 @@ class KubernetesObjectReconciler[T]:
|
||||
|
||||
patch = self.get_patch()
|
||||
if patch is not None:
|
||||
current_json = ApiClient().sanitize_for_serialization(current)
|
||||
|
||||
try:
|
||||
current_json = ApiClient().sanitize_for_serialization(current)
|
||||
except AttributeError:
|
||||
current_json = asdict(current)
|
||||
try:
|
||||
if apply_patch(current_json, patch) != current_json:
|
||||
raise NeedsUpdate()
|
||||
|
||||
@@ -163,4 +163,5 @@ def outpost_pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def outpost_logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
outpost_session_end.send(instance.session.session_key)
|
||||
if Outpost.objects.exists():
|
||||
outpost_session_end.send(instance.session.session_key)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 _
|
||||
@@ -159,7 +158,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)
|
||||
async_to_sync(layer.group_send)(group, {"type": "event.update"})
|
||||
layer.group_send_blocking(group, {"type": "event.update"})
|
||||
|
||||
|
||||
@actor(description=_("Checks the local environment and create Service connections."))
|
||||
@@ -210,7 +209,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)
|
||||
async_to_sync(layer.group_send)(
|
||||
layer.group_send_blocking(
|
||||
group,
|
||||
{
|
||||
"type": "event.session.end",
|
||||
|
||||
@@ -57,9 +57,11 @@ class PolicyBindingSerializer(ModelSerializer):
|
||||
required=True,
|
||||
)
|
||||
|
||||
policy_obj = PolicySerializer(required=False, read_only=True, source="policy")
|
||||
group_obj = PartialGroupSerializer(required=False, read_only=True, source="group")
|
||||
user_obj = PartialUserSerializer(required=False, read_only=True, source="user")
|
||||
policy_obj = PolicySerializer(required=False, allow_null=True, read_only=True, source="policy")
|
||||
group_obj = PartialGroupSerializer(
|
||||
required=False, allow_null=True, read_only=True, source="group"
|
||||
)
|
||||
user_obj = PartialUserSerializer(required=False, allow_null=True, read_only=True, source="user")
|
||||
|
||||
class Meta:
|
||||
model = PolicyBinding
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"""Shared logout stages for SAML and OIDC providers"""
|
||||
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.fields import CharField, ListField
|
||||
from rest_framework.fields import CharField, DictField, ListField
|
||||
|
||||
from authentik.common.oauth.constants import PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.providers.saml.views.flows import PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS
|
||||
|
||||
|
||||
class LogoutURL(PassiveSerializer):
|
||||
"""Data for a single logout URL"""
|
||||
|
||||
url = CharField()
|
||||
provider_name = CharField(required=False, allow_null=True)
|
||||
binding = CharField(required=False, allow_null=True)
|
||||
saml_request = CharField(required=False, allow_null=True)
|
||||
saml_response = CharField(required=False, allow_null=True)
|
||||
saml_relay_state = CharField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class IframeLogoutChallenge(Challenge):
|
||||
"""Challenge for iframe logout"""
|
||||
|
||||
component = CharField(default="ak-provider-iframe-logout")
|
||||
logout_urls = ListField(child=LogoutURL(), default=list)
|
||||
logout_urls = ListField(child=DictField(), default=list)
|
||||
|
||||
|
||||
class IframeLogoutChallengeResponse(ChallengeResponse):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from base64 import b64encode
|
||||
from json import loads
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -96,3 +97,16 @@ 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)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from base64 import b64encode
|
||||
from json import dumps
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
@@ -28,6 +29,7 @@ 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
|
||||
|
||||
|
||||
@@ -115,6 +117,20 @@ 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(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from base64 import b64encode
|
||||
from json import loads
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
@@ -178,6 +179,41 @@ 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(
|
||||
|
||||
@@ -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 urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.http.response import HttpResponseRedirect
|
||||
@@ -122,6 +122,10 @@ 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:
|
||||
|
||||
@@ -432,7 +432,7 @@ class AuthorizationFlowInitView(BufferedPolicyAccessView):
|
||||
return response
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||
# Activate language before parsing params (error messages should be localized)
|
||||
# Activate language before parsing params (error messages should be localised)
|
||||
return self.dispatch_with_language(request, *args, **kwargs)
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
|
||||
@@ -368,7 +368,7 @@ class TokenParams:
|
||||
) -> tuple[dict, OAuthSource] | tuple[None, None]:
|
||||
# Fully decode the JWT without verifying the signature, so we can get access to
|
||||
# the header.
|
||||
# Get the Key ID from the header, and use that to optimize our source query to only find
|
||||
# Get the Key ID from the header, and use that to optimise our source query to only find
|
||||
# sources that have a JWK for that Key ID
|
||||
# The Key ID doesn't have a fixed format, but must match between an issued JWT
|
||||
# and whatever is returned by the JWKS endpoint
|
||||
|
||||
@@ -27,6 +27,8 @@ class TraefikMiddlewareSpecForwardAuth:
|
||||
|
||||
trustForwardHeader: bool = field(default=True)
|
||||
|
||||
maxResponseBodySize: int = field(default=1024 * 1024 * 4)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraefikMiddlewareSpec:
|
||||
@@ -140,6 +142,7 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]
|
||||
],
|
||||
authResponseHeadersRegex="",
|
||||
trustForwardHeader=True,
|
||||
maxResponseBodySize=1024 * 1024 * 4,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Proxy provider signals"""
|
||||
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
from authentik.providers.proxy.tasks import proxy_on_logout
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
proxy_on_logout.send(instance.session.session_key)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""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
|
||||
|
||||
from authentik.outposts.consumer import build_outpost_group
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
|
||||
|
||||
@actor(description=_("Terminate session on Proxy outpost."))
|
||||
def proxy_on_logout(session_id: str):
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||
group = build_outpost_group(outpost.pk)
|
||||
async_to_sync(layer.group_send)(
|
||||
group,
|
||||
{
|
||||
"type": "event.provider.specific",
|
||||
"sub_type": "logout",
|
||||
"session_id": hashed_session_id,
|
||||
},
|
||||
)
|
||||
@@ -213,7 +213,6 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"sign_assertion",
|
||||
"sign_response",
|
||||
"sign_logout_request",
|
||||
"sign_logout_response",
|
||||
"sp_binding",
|
||||
"sls_binding",
|
||||
"logout_method",
|
||||
@@ -232,8 +231,8 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
class SAMLMetadataSerializer(PassiveSerializer):
|
||||
"""SAML Provider Metadata serializer"""
|
||||
|
||||
metadata = CharField(read_only=True)
|
||||
download_url = CharField(read_only=True, required=False)
|
||||
metadata = CharField()
|
||||
download_url = CharField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class SAMLProviderImportSerializer(PassiveSerializer):
|
||||
@@ -315,7 +314,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
return response
|
||||
return Response({"metadata": metadata}, content_type="application/json")
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return Response({"metadata": ""}, content_type="application/json")
|
||||
raise Http404 from None
|
||||
|
||||
@permission_required(
|
||||
None,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 18:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0020_samlprovider_logout_method_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="sign_logout_response",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -227,7 +227,6 @@ class SAMLProvider(Provider):
|
||||
sign_assertion = models.BooleanField(default=True)
|
||||
sign_response = models.BooleanField(default=False)
|
||||
sign_logout_request = models.BooleanField(default=False)
|
||||
sign_logout_response = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def launch_url(self) -> str | None:
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""SAML Logout stages for automatic injection"""
|
||||
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse, HttpChallengeResponse
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.providers.saml.models import SAMLBindings
|
||||
from authentik.providers.saml.views.flows import PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -20,16 +19,13 @@ class NativeLogoutChallenge(Challenge):
|
||||
"""Challenge for native browser logout"""
|
||||
|
||||
component = CharField(default="ak-provider-saml-native-logout")
|
||||
provider_name = CharField(required=False)
|
||||
is_complete = BooleanField(required=False, default=False)
|
||||
|
||||
post_url = CharField(required=False)
|
||||
redirect_url = CharField(required=False)
|
||||
|
||||
saml_binding = ChoiceField(choices=SAMLBindings.choices, required=False)
|
||||
saml_request = CharField(required=False)
|
||||
saml_response = CharField(required=False)
|
||||
saml_relay_state = CharField(required=False)
|
||||
relay_state = CharField(required=False)
|
||||
provider_name = CharField(required=False)
|
||||
binding = CharField(required=False)
|
||||
redirect_url = CharField(required=False)
|
||||
is_complete = BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class NativeLogoutChallengeResponse(ChallengeResponse):
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
"""LogoutResponse processor"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import xmlsec
|
||||
from lxml import etree
|
||||
from lxml.etree import Element, SubElement
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
|
||||
|
||||
class LogoutResponseProcessor:
|
||||
"""Generate a SAML LogoutResponse"""
|
||||
|
||||
provider: SAMLProvider
|
||||
logout_request: LogoutRequest
|
||||
destination: str | None
|
||||
relay_state: str | None
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: SAMLProvider,
|
||||
logout_request: LogoutRequest,
|
||||
destination: str | None = None,
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.logout_request = logout_request
|
||||
self.destination = destination
|
||||
self.relay_state = relay_state or (logout_request.relay_state if logout_request else None)
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.provider.issuer
|
||||
return issuer
|
||||
|
||||
def build(self, status: str = "Success") -> Element:
|
||||
"""Build a SAML LogoutResponse as etree Element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["ID"] = self._response_id
|
||||
|
||||
if self.destination:
|
||||
response.attrib["Destination"] = self.destination
|
||||
|
||||
if self.logout_request and self.logout_request.id:
|
||||
response.attrib["InResponseTo"] = self.logout_request.id
|
||||
|
||||
response.append(self.get_issuer())
|
||||
|
||||
# Add Status element
|
||||
status_element = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = SubElement(status_element, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = f"urn:oasis:names:tc:SAML:2.0:status:{status}"
|
||||
|
||||
return response
|
||||
|
||||
def build_response(self, status: str = "Success") -> str:
|
||||
"""Build and sign LogoutResponse, return as XML string (not encoded)"""
|
||||
response = self.build(status)
|
||||
if self.provider.signing_kp and self.provider.sign_logout_response:
|
||||
self._add_signature(response)
|
||||
self._sign_response(response)
|
||||
return etree.tostring(response).decode()
|
||||
|
||||
def encode_post(self, status: str = "Success") -> str:
|
||||
"""Encode LogoutResponse for POST binding"""
|
||||
response = self.build(status)
|
||||
if self.provider.signing_kp and self.provider.sign_logout_response:
|
||||
self._add_signature(response)
|
||||
self._sign_response(response)
|
||||
return base64.b64encode(etree.tostring(response)).decode()
|
||||
|
||||
def encode_redirect(self, status: str = "Success") -> str:
|
||||
"""Encode LogoutResponse for Redirect binding"""
|
||||
response = self.build(status)
|
||||
# Note: For redirect binding, signatures are added as query parameters, not in XML
|
||||
xml_str = etree.tostring(response, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self, status: str = "Success") -> str:
|
||||
"""Build complete logout response URL for redirect binding with signature if needed"""
|
||||
encoded_response = self.encode_redirect(status)
|
||||
params = {
|
||||
"SAMLResponse": encoded_response,
|
||||
}
|
||||
|
||||
if self.relay_state:
|
||||
params["RelayState"] = self.relay_state
|
||||
|
||||
if self.provider.signing_kp and self.provider.sign_logout_response:
|
||||
sig_alg = self.provider.signature_algorithm
|
||||
params["SigAlg"] = sig_alg
|
||||
|
||||
# Build the string to sign
|
||||
query_string = self._build_signable_query_string(params)
|
||||
|
||||
signature = self._sign_query_string(query_string)
|
||||
params["Signature"] = base64.b64encode(signature).decode()
|
||||
|
||||
# Some SP's use query params on their sls endpoint
|
||||
if not self.destination:
|
||||
raise ValueError("destination is required for redirect URL")
|
||||
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def _add_signature(self, element: Element):
|
||||
"""Add signature placeholder to element"""
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
element,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
sign_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
element.insert(1, signature) # Insert after Issuer
|
||||
|
||||
def _sign_response(self, response: Element):
|
||||
"""Sign the response element"""
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
|
||||
xmlsec.tree.add_ids(response, ["ID"])
|
||||
signature_node = xmlsec.tree.find_node(response, xmlsec.constants.NodeSignature)
|
||||
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + response.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
key_info = xmlsec.template.ensure_key_info(signature_node)
|
||||
xmlsec.template.add_x509_data(key_info)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = xmlsec.Key.from_memory(
|
||||
self.provider.signing_kp.key_data, # Use key_data for the private key
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
)
|
||||
ctx.key.load_cert_from_memory(
|
||||
self.provider.signing_kp.certificate_data, xmlsec.constants.KeyDataFormatPem
|
||||
)
|
||||
ctx.sign(signature_node)
|
||||
|
||||
def _build_signable_query_string(self, params: dict) -> str:
|
||||
"""Build query string for signing (order matters per SAML spec)"""
|
||||
# SAML spec requires specific order: SAMLResponse, RelayState, SigAlg
|
||||
# Values must be URL-encoded individually before concatenation
|
||||
ordered = []
|
||||
if "SAMLResponse" in params:
|
||||
ordered.append(f"SAMLResponse={quote(params['SAMLResponse'], safe='')}")
|
||||
if "RelayState" in params:
|
||||
ordered.append(f"RelayState={quote(params['RelayState'], safe='')}")
|
||||
if "SigAlg" in params:
|
||||
ordered.append(f"SigAlg={quote(params['SigAlg'], safe='')}")
|
||||
return "&".join(ordered)
|
||||
|
||||
def _sign_query_string(self, query_string: str) -> bytes:
|
||||
"""Sign the query string for redirect binding"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha256
|
||||
)
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.provider.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = key
|
||||
|
||||
return ctx.sign_binary(query_string.encode("utf-8"), signature_algorithm_transform)
|
||||
@@ -175,16 +175,16 @@ def handle_flow_pre_user_logout(
|
||||
logout_data = {
|
||||
"post_url": session.provider.sls_url,
|
||||
"saml_request": form_data["SAMLRequest"],
|
||||
"saml_relay_state": form_data["RelayState"],
|
||||
"relay_state": form_data["RelayState"],
|
||||
"provider_name": session.provider.name,
|
||||
"saml_binding": SAMLBindings.POST,
|
||||
"binding": SAMLBindings.POST,
|
||||
}
|
||||
else:
|
||||
logout_url = processor.get_redirect_url()
|
||||
logout_data = {
|
||||
"redirect_url": logout_url,
|
||||
"provider_name": session.provider.name,
|
||||
"saml_binding": SAMLBindings.REDIRECT,
|
||||
"binding": SAMLBindings.REDIRECT,
|
||||
}
|
||||
|
||||
native_sessions.append(logout_data)
|
||||
|
||||
@@ -5,11 +5,8 @@ from django.contrib.auth import get_user_model
|
||||
from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.saml.models import 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
|
||||
|
||||
LOGGER = get_logger()
|
||||
User = get_user_model()
|
||||
@@ -81,86 +78,3 @@ def send_post_logout_request(provider: SAMLProvider, processor: LogoutRequestPro
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@actor(description="Send SAML LogoutResponse to a Service Provider (backchannel)")
|
||||
def send_saml_logout_response(
|
||||
provider_pk: int,
|
||||
sls_url: str,
|
||||
logout_request_id: str | None = None,
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
"""Send SAML LogoutResponse to a Service Provider using backchannel (server-to-server)"""
|
||||
provider = SAMLProvider.objects.filter(pk=provider_pk).first()
|
||||
if not provider:
|
||||
LOGGER.error(
|
||||
"Provider not found for SAML logout response",
|
||||
provider_pk=provider_pk,
|
||||
)
|
||||
return False
|
||||
|
||||
LOGGER.debug(
|
||||
"Sending backchannel SAML logout response",
|
||||
provider=provider.name,
|
||||
sls_url=sls_url,
|
||||
)
|
||||
|
||||
# Create a minimal LogoutRequest object for the response processor
|
||||
# We only need the ID and relay_state for building the response
|
||||
logout_request = None
|
||||
if logout_request_id:
|
||||
logout_request = LogoutRequest()
|
||||
logout_request.id = logout_request_id
|
||||
logout_request.relay_state = relay_state
|
||||
|
||||
# Build the logout response
|
||||
processor = LogoutResponseProcessor(
|
||||
provider=provider,
|
||||
logout_request=logout_request,
|
||||
destination=sls_url,
|
||||
relay_state=relay_state,
|
||||
)
|
||||
|
||||
encoded_response = processor.encode_post()
|
||||
|
||||
form_data = {
|
||||
"SAMLResponse": encoded_response,
|
||||
}
|
||||
|
||||
if relay_state:
|
||||
form_data["RelayState"] = relay_state
|
||||
|
||||
# Send the logout response to the SP
|
||||
try:
|
||||
response = requests.post(
|
||||
sls_url,
|
||||
data=form_data,
|
||||
timeout=10,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
LOGGER.info(
|
||||
"Successfully sent backchannel logout response to SP",
|
||||
provider=provider.name,
|
||||
sls_url=sls_url,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to send backchannel logout response to SP",
|
||||
provider=provider.name,
|
||||
sls_url=sls_url,
|
||||
error=str(exc),
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
provider=provider,
|
||||
message=f"Backchannel logout response failed: {str(exc)}",
|
||||
).save()
|
||||
return False
|
||||
|
||||
@@ -157,7 +157,7 @@ class TestSAMLProviderAPI(APITestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(404, response.status_code)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}),
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ class TestNativeLogoutStageView(TestCase):
|
||||
{
|
||||
"redirect_url": "https://sp1.example.com/sls?SAMLRequest=encoded",
|
||||
"provider_name": "test-provider-1",
|
||||
"saml_binding": "redirect",
|
||||
"binding": "redirect",
|
||||
}
|
||||
]
|
||||
stage_view = NativeLogoutStageView(
|
||||
@@ -85,7 +85,7 @@ class TestNativeLogoutStageView(TestCase):
|
||||
|
||||
# Should return a NativeLogoutChallenge
|
||||
self.assertIsInstance(challenge, NativeLogoutChallenge)
|
||||
self.assertEqual(challenge.initial_data["saml_binding"], "redirect")
|
||||
self.assertEqual(challenge.initial_data["binding"], "redirect")
|
||||
self.assertEqual(challenge.initial_data["provider_name"], "test-provider-1")
|
||||
self.assertIn("redirect_url", challenge.initial_data)
|
||||
|
||||
@@ -102,9 +102,9 @@ class TestNativeLogoutStageView(TestCase):
|
||||
{
|
||||
"post_url": "https://sp2.example.com/sls",
|
||||
"saml_request": "encoded_saml_request",
|
||||
"saml_relay_state": "https://idp.example.com/flow/test-flow",
|
||||
"relay_state": "https://idp.example.com/flow/test-flow",
|
||||
"provider_name": "test-provider-2",
|
||||
"saml_binding": "post",
|
||||
"binding": "post",
|
||||
}
|
||||
]
|
||||
stage_view = NativeLogoutStageView(
|
||||
@@ -120,11 +120,11 @@ class TestNativeLogoutStageView(TestCase):
|
||||
|
||||
# Should return a NativeLogoutChallenge
|
||||
self.assertIsInstance(challenge, NativeLogoutChallenge)
|
||||
self.assertEqual(challenge.initial_data["saml_binding"], "post")
|
||||
self.assertEqual(challenge.initial_data["binding"], "post")
|
||||
self.assertEqual(challenge.initial_data["provider_name"], "test-provider-2")
|
||||
self.assertEqual(challenge.initial_data["post_url"], "https://sp2.example.com/sls")
|
||||
self.assertIn("saml_request", challenge.initial_data)
|
||||
self.assertIn("saml_relay_state", challenge.initial_data)
|
||||
self.assertIn("relay_state", challenge.initial_data)
|
||||
|
||||
def test_get_challenge_all_complete(self):
|
||||
"""Test get_challenge when all providers are done"""
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""logout response tests"""
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.common.saml.constants import (
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
)
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
|
||||
|
||||
class TestLogoutResponse(TestCase):
|
||||
"""Test LogoutResponse processor"""
|
||||
|
||||
@apply_blueprint("system/providers-saml.yaml")
|
||||
def setUp(self):
|
||||
cert = create_test_cert()
|
||||
self.provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
authorization_flow=create_test_flow(),
|
||||
acs_url="http://testserver/source/saml/provider/acs/",
|
||||
sls_url="http://testserver/source/saml/provider/sls/",
|
||||
signing_kp=cert,
|
||||
verification_kp=cert,
|
||||
)
|
||||
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||
self.provider.save()
|
||||
|
||||
def test_build_response(self):
|
||||
"""Test building a LogoutResponse"""
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="test-relay-state",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider, logout_request, destination=self.provider.sls_url
|
||||
)
|
||||
response_xml = processor.build_response(status="Success")
|
||||
|
||||
# Parse and verify
|
||||
root = ElementTree.fromstring(response_xml)
|
||||
self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutResponse")
|
||||
self.assertEqual(root.attrib["Version"], "2.0")
|
||||
self.assertEqual(root.attrib["Destination"], self.provider.sls_url)
|
||||
self.assertEqual(root.attrib["InResponseTo"], "test-request-id")
|
||||
|
||||
# Check Issuer
|
||||
issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
self.assertEqual(issuer.text, self.provider.issuer)
|
||||
|
||||
# Check Status
|
||||
status = root.find(f".//{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
self.assertEqual(status.attrib["Value"], "urn:oasis:names:tc:SAML:2.0:status:Success")
|
||||
|
||||
def test_build_response_signed(self):
|
||||
"""Test building a signed LogoutResponse"""
|
||||
self.provider.sign_logout_response = True
|
||||
self.provider.save()
|
||||
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="test-relay-state",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider, logout_request, destination=self.provider.sls_url
|
||||
)
|
||||
response_xml = processor.build_response(status="Success")
|
||||
|
||||
# Parse and verify signature is present
|
||||
root = ElementTree.fromstring(response_xml)
|
||||
signature = root.find(f".//{{{NS_SIGNATURE}}}Signature")
|
||||
self.assertIsNotNone(signature)
|
||||
|
||||
# Verify signature structure
|
||||
signed_info = signature.find(f"{{{NS_SIGNATURE}}}SignedInfo")
|
||||
self.assertIsNotNone(signed_info)
|
||||
signature_value = signature.find(f"{{{NS_SIGNATURE}}}SignatureValue")
|
||||
self.assertIsNotNone(signature_value)
|
||||
self.assertIsNotNone(signature_value.text)
|
||||
|
||||
def test_no_inresponseto(self):
|
||||
"""Test building response without a logout request omits InResponseTo attribute"""
|
||||
processor = LogoutResponseProcessor(self.provider, None, destination=self.provider.sls_url)
|
||||
response_xml = processor.build_response(status="Success")
|
||||
|
||||
root = ElementTree.fromstring(response_xml)
|
||||
self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutResponse")
|
||||
self.assertNotIn("InResponseTo", root.attrib)
|
||||
|
||||
def test_no_destination(self):
|
||||
"""Test building response without destination"""
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(self.provider, logout_request, destination=None)
|
||||
response_xml = processor.build_response(status="Success")
|
||||
|
||||
root = ElementTree.fromstring(response_xml)
|
||||
self.assertNotIn("Destination", root.attrib)
|
||||
|
||||
def test_relay_state_from_logout_request(self):
|
||||
"""Test that relay_state is taken from logout_request if not provided"""
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="request-relay-state",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider, logout_request, destination=self.provider.sls_url
|
||||
)
|
||||
self.assertEqual(processor.relay_state, "request-relay-state")
|
||||
|
||||
def test_relay_state_override(self):
|
||||
"""Test that explicit relay_state overrides logout_request relay_state"""
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="request-relay-state",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
relay_state="explicit-relay-state",
|
||||
)
|
||||
self.assertEqual(processor.relay_state, "explicit-relay-state")
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Tests for SAML provider tasks"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from requests.exceptions import ConnectionError, HTTPError
|
||||
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.tasks import (
|
||||
send_post_logout_request,
|
||||
send_saml_logout_request,
|
||||
send_saml_logout_response,
|
||||
)
|
||||
|
||||
|
||||
class TestSendSamlLogoutResponse(TestCase):
|
||||
"""Tests for send_saml_logout_response task"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.cert = create_test_cert()
|
||||
self.flow = create_test_flow()
|
||||
|
||||
self.provider = SAMLProvider.objects.create(
|
||||
name="test-provider",
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_successful_logout_response(self, mock_post):
|
||||
"""Test successful POST to SP returns True"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = send_saml_logout_response(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
logout_request_id="test-request-id",
|
||||
relay_state="https://sp.example.com/return",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Verify the POST was made with correct data
|
||||
call_kwargs = mock_post.call_args[1]
|
||||
self.assertEqual(call_kwargs["timeout"], 10)
|
||||
self.assertEqual(
|
||||
call_kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded"
|
||||
)
|
||||
|
||||
# Verify form data contains SAMLResponse and RelayState
|
||||
form_data = call_kwargs["data"]
|
||||
self.assertIn("SAMLResponse", form_data)
|
||||
self.assertIn("RelayState", form_data)
|
||||
self.assertEqual(form_data["RelayState"], "https://sp.example.com/return")
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_successful_logout_response_no_relay_state(self, mock_post):
|
||||
"""Test successful POST without relay_state"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = send_saml_logout_response(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
logout_request_id="test-request-id",
|
||||
relay_state=None,
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify form data does not contain RelayState
|
||||
form_data = mock_post.call_args[1]["data"]
|
||||
self.assertIn("SAMLResponse", form_data)
|
||||
self.assertNotIn("RelayState", form_data)
|
||||
|
||||
def test_provider_not_found(self):
|
||||
"""Test returns False when provider doesn't exist"""
|
||||
result = send_saml_logout_response(
|
||||
provider_pk=99999, # Non-existent provider
|
||||
sls_url="https://sp.example.com/sls",
|
||||
logout_request_id="test-request-id",
|
||||
relay_state=None,
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch("authentik.providers.saml.tasks.Event")
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_http_error_creates_event(self, mock_post, mock_event_class):
|
||||
"""Test HTTP error creates an error event"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = HTTPError("500 Server Error")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event_class.new.return_value = mock_event
|
||||
|
||||
result = send_saml_logout_response(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
logout_request_id="test-request-id",
|
||||
relay_state=None,
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error event was created
|
||||
mock_event_class.new.assert_called_once()
|
||||
call_kwargs = mock_event_class.new.call_args[1]
|
||||
self.assertIn("Backchannel logout response failed", call_kwargs["message"])
|
||||
mock_event.save.assert_called_once()
|
||||
|
||||
|
||||
class TestSendSamlLogoutRequest(TestCase):
|
||||
"""Tests for send_saml_logout_request task"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.cert = create_test_cert()
|
||||
self.flow = create_test_flow()
|
||||
|
||||
self.provider = SAMLProvider.objects.create(
|
||||
name="test-provider",
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_successful_logout_request(self, mock_post):
|
||||
"""Test successful POST logout request returns True"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = send_saml_logout_request(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Verify the POST was made with correct data
|
||||
call_kwargs = mock_post.call_args[1]
|
||||
self.assertEqual(call_kwargs["timeout"], 10)
|
||||
self.assertEqual(
|
||||
call_kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded"
|
||||
)
|
||||
|
||||
# Verify form data contains SAMLRequest
|
||||
form_data = call_kwargs["data"]
|
||||
self.assertIn("SAMLRequest", form_data)
|
||||
|
||||
def test_provider_not_found(self):
|
||||
"""Test returns False when provider doesn't exist"""
|
||||
result = send_saml_logout_request(
|
||||
provider_pk=99999, # Non-existent provider
|
||||
sls_url="https://sp.example.com/sls",
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_http_error_raises(self, mock_post):
|
||||
"""Test HTTP error raises exception (no try/catch in send_post_logout_request)"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = HTTPError("500 Server Error")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with self.assertRaises(HTTPError):
|
||||
send_saml_logout_request(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
)
|
||||
|
||||
|
||||
class TestSendPostLogoutRequest(TestCase):
|
||||
"""Tests for send_post_logout_request function"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.cert = create_test_cert()
|
||||
self.flow = create_test_flow()
|
||||
|
||||
self.provider = SAMLProvider.objects.create(
|
||||
name="test-provider",
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_successful_post(self, mock_post):
|
||||
"""Test successful POST returns True"""
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
provider=self.provider,
|
||||
user=None,
|
||||
destination=self.provider.sls_url,
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
)
|
||||
|
||||
result = send_post_logout_request(self.provider, processor)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_with_relay_state(self, mock_post):
|
||||
"""Test POST includes RelayState when present"""
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
provider=self.provider,
|
||||
user=None,
|
||||
destination=self.provider.sls_url,
|
||||
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",
|
||||
)
|
||||
|
||||
result = send_post_logout_request(self.provider, processor)
|
||||
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify RelayState is included
|
||||
form_data = mock_post.call_args[1]["data"]
|
||||
self.assertIn("RelayState", form_data)
|
||||
self.assertEqual(form_data["RelayState"], "https://sp.example.com/return")
|
||||
|
||||
@patch("authentik.providers.saml.tasks.requests.post")
|
||||
def test_connection_error_raises(self, mock_post):
|
||||
"""Test connection error raises exception"""
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
|
||||
mock_post.side_effect = ConnectionError("Connection refused")
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
provider=self.provider,
|
||||
user=None,
|
||||
destination=self.provider.sls_url,
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
)
|
||||
|
||||
with self.assertRaises(ConnectionError):
|
||||
send_post_logout_request(self.provider, processor)
|
||||
@@ -14,9 +14,7 @@ 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.views.flows import (
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE,
|
||||
)
|
||||
from authentik.providers.saml.views.flows import PLAN_CONTEXT_SAML_RELAY_STATE
|
||||
from authentik.providers.saml.views.sp_slo import (
|
||||
SPInitiatedSLOBindingPOSTView,
|
||||
SPInitiatedSLOBindingRedirectView,
|
||||
@@ -93,32 +91,33 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer)
|
||||
self.assertEqual(logout_request.session_index, "test-session-123")
|
||||
|
||||
def test_redirect_view_handles_logout_response_with_relay_state(self):
|
||||
"""Test that redirect view handles logout response with RelayState"""
|
||||
# Use raw URL (no encoding needed)
|
||||
relay_state = "https://idp.example.com/flow/return"
|
||||
def test_redirect_view_handles_logout_response_with_plan_context(self):
|
||||
"""Test that redirect view always redirects to plan context URL, ignoring RelayState"""
|
||||
plan_relay_state = "https://idp.example.com/flow/return"
|
||||
|
||||
# Create request with SAML logout response
|
||||
request = self.factory.get(
|
||||
f"/slo/redirect/{self.application.slug}/",
|
||||
{
|
||||
"SAMLResponse": "dummy-response",
|
||||
"RelayState": relay_state,
|
||||
"RelayState": "https://somewhere-else.example.com/return",
|
||||
},
|
||||
)
|
||||
request.session = {}
|
||||
plan = FlowPlan(flow_pk="test-flow")
|
||||
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
|
||||
request.session = {SESSION_KEY_PLAN: plan}
|
||||
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 URL
|
||||
# Should redirect to plan context URL, not the request's RelayState
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, relay_state)
|
||||
self.assertEqual(response.url, plan_relay_state)
|
||||
|
||||
def test_redirect_view_handles_logout_response_plain_relay_state(self):
|
||||
"""Test that redirect view handles logout response with plain RelayState"""
|
||||
def test_redirect_view_ignores_relay_state_without_plan(self):
|
||||
"""Test that redirect view ignores RelayState and falls back to root when no plan context"""
|
||||
relay_state = "https://sp.example.com/plain"
|
||||
|
||||
# Create request with SAML logout response
|
||||
@@ -136,9 +135,9 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
view.setup(request, application_slug=self.application.slug)
|
||||
response = view.dispatch(request, application_slug=self.application.slug)
|
||||
|
||||
# Should redirect to plain relay state
|
||||
# Should ignore relay_state and redirect to root (no plan context)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, relay_state)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
def test_redirect_view_handles_logout_response_no_relay_state_with_plan_context(self):
|
||||
"""Test that redirect view uses plan context fallback when no RelayState"""
|
||||
@@ -230,29 +229,30 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer)
|
||||
self.assertEqual(logout_request.session_index, "test-session-123")
|
||||
|
||||
def test_post_view_handles_logout_response_with_relay_state(self):
|
||||
"""Test that POST view handles logout response with RelayState"""
|
||||
# Use raw URL (no encoding needed)
|
||||
relay_state = "https://idp.example.com/flow/return"
|
||||
def test_post_view_handles_logout_response_with_plan_context(self):
|
||||
"""Test that POST view always redirects to plan context URL, ignoring RelayState"""
|
||||
plan_relay_state = "https://idp.example.com/flow/return"
|
||||
|
||||
# Create POST request with SAML logout response
|
||||
request = self.factory.post(
|
||||
f"/slo/post/{self.application.slug}/",
|
||||
{
|
||||
"SAMLResponse": "dummy-response",
|
||||
"RelayState": relay_state,
|
||||
"RelayState": "https://somewhere-else.example.com/return",
|
||||
},
|
||||
)
|
||||
request.session = {}
|
||||
plan = FlowPlan(flow_pk="test-flow")
|
||||
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
|
||||
request.session = {SESSION_KEY_PLAN: plan}
|
||||
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 relay state URL
|
||||
# Should redirect to plan context URL, not the request's RelayState
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, relay_state)
|
||||
self.assertEqual(response.url, plan_relay_state)
|
||||
|
||||
def test_post_view_handles_logout_response_no_relay_state_with_plan_context(self):
|
||||
"""Test that POST view uses plan context fallback when no RelayState"""
|
||||
@@ -419,7 +419,7 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
view.resolve_provider_application()
|
||||
|
||||
def test_relay_state_decoding_failure(self):
|
||||
"""Test handling of RelayState that's a path"""
|
||||
"""Test that arbitrary path RelayState is ignored and redirects to root"""
|
||||
# Create request with relay state that is a path
|
||||
request = self.factory.get(
|
||||
f"/slo/redirect/{self.application.slug}/",
|
||||
@@ -435,9 +435,73 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
view.setup(request, application_slug=self.application.slug)
|
||||
response = view.dispatch(request, application_slug=self.application.slug)
|
||||
|
||||
# Should treat it as plain URL and redirect to it
|
||||
# Should ignore relay_state and redirect to root (no plan context)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/some/invalid/path")
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
def test_redirect_view_blocks_external_relay_state(self):
|
||||
"""Test that redirect view ignores external malicious URL and redirects to root"""
|
||||
request = self.factory.get(
|
||||
f"/slo/redirect/{self.application.slug}/",
|
||||
{
|
||||
"SAMLResponse": "dummy-response",
|
||||
"RelayState": "https://evil.com/phishing",
|
||||
},
|
||||
)
|
||||
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 ignore relay_state and redirect to root (no plan context)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
def test_redirect_view_ignores_relay_state_uses_plan_context(self):
|
||||
"""Test that redirect view always uses plan context URL regardless of RelayState"""
|
||||
plan_relay_state = "https://authentik.example.com/if/flow/logout/"
|
||||
|
||||
request = self.factory.get(
|
||||
f"/slo/redirect/{self.application.slug}/",
|
||||
{
|
||||
"SAMLResponse": "dummy-response",
|
||||
"RelayState": "https://evil.com/phishing",
|
||||
},
|
||||
)
|
||||
plan = FlowPlan(flow_pk="test-flow")
|
||||
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = plan_relay_state
|
||||
request.session = {SESSION_KEY_PLAN: plan}
|
||||
request.brand = self.brand
|
||||
|
||||
view = SPInitiatedSLOBindingRedirectView()
|
||||
view.setup(request, application_slug=self.application.slug)
|
||||
response = view.dispatch(request, application_slug=self.application.slug)
|
||||
|
||||
# Should always use plan context value, ignoring malicious RelayState
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, plan_relay_state)
|
||||
|
||||
def test_post_view_ignores_external_relay_state(self):
|
||||
"""Test that POST view ignores external RelayState and redirects to root"""
|
||||
request = self.factory.post(
|
||||
f"/slo/post/{self.application.slug}/",
|
||||
{
|
||||
"SAMLResponse": "dummy-response",
|
||||
"RelayState": "https://evil.com/phishing",
|
||||
},
|
||||
)
|
||||
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 ignore relay_state and redirect to root (no plan context)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
|
||||
class TestSPInitiatedSLOLogoutMethods(TestCase):
|
||||
|
||||
@@ -15,22 +15,10 @@ from authentik.flows.stage import SessionEndStage
|
||||
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,
|
||||
SAMLProvider,
|
||||
SAMLSession,
|
||||
)
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.providers.saml.models import SAMLProvider, SAMLSession
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.tasks import send_saml_logout_response
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
from authentik.providers.saml.views.flows import (
|
||||
PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS,
|
||||
PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS,
|
||||
PLAN_CONTEXT_SAML_LOGOUT_REQUEST,
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE,
|
||||
REQUEST_KEY_RELAY_STATE,
|
||||
@@ -41,6 +29,24 @@ from authentik.providers.saml.views.flows import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def _get_redirect_url(request: HttpRequest, relay_state: str = "") -> str:
|
||||
"""Get the safe redirect URL from the plan context, logging a warning if the
|
||||
incoming relay_state doesn't match the stored value."""
|
||||
stored_relay_state = ""
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
stored_relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE, "")
|
||||
|
||||
if relay_state and relay_state != stored_relay_state:
|
||||
LOGGER.warning(
|
||||
"SAML logout relay_state mismatch, possible open redirect attempt",
|
||||
received_relay_state=relay_state,
|
||||
stored_relay_state=stored_relay_state,
|
||||
)
|
||||
|
||||
return stored_relay_state
|
||||
|
||||
|
||||
class SPInitiatedSLOView(PolicyAccessView):
|
||||
"""Handle SP-initiated SAML Single Logout requests"""
|
||||
|
||||
@@ -80,102 +86,7 @@ class SPInitiatedSLOView(PolicyAccessView):
|
||||
**self.plan_context,
|
||||
},
|
||||
)
|
||||
|
||||
if self.provider.sls_url:
|
||||
# Get logout request and extract relay state
|
||||
logout_request = self.plan_context.get(PLAN_CONTEXT_SAML_LOGOUT_REQUEST)
|
||||
relay_state = logout_request.relay_state if logout_request else None
|
||||
|
||||
# Store relay state for the logout response
|
||||
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
|
||||
|
||||
if self.provider.logout_method == SAMLLogoutMethods.FRONTCHANNEL_NATIVE:
|
||||
# Native mode - user will be redirected/posted away from authentik
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
)
|
||||
|
||||
if self.provider.sls_binding == SAMLBindings.POST:
|
||||
logout_response = processor.encode_post()
|
||||
logout_data = {
|
||||
"post_url": self.provider.sls_url,
|
||||
"saml_response": logout_response,
|
||||
"saml_relay_state": relay_state,
|
||||
"provider_name": self.provider.name,
|
||||
"saml_binding": SAMLBindings.POST,
|
||||
}
|
||||
else:
|
||||
logout_url = processor.get_redirect_url()
|
||||
logout_data = {
|
||||
"redirect_url": logout_url,
|
||||
"provider_name": self.provider.name,
|
||||
"saml_binding": SAMLBindings.REDIRECT,
|
||||
}
|
||||
|
||||
plan.context[PLAN_CONTEXT_SAML_LOGOUT_NATIVE_SESSIONS] = [logout_data]
|
||||
plan.append_stage(in_memory_stage(NativeLogoutStageView))
|
||||
elif self.provider.logout_method == SAMLLogoutMethods.BACKCHANNEL:
|
||||
# Backchannel mode - server sends logout response directly to SP in background
|
||||
# No user interaction needed
|
||||
if self.provider.sls_binding != SAMLBindings.POST:
|
||||
LOGGER.warning(
|
||||
"Backchannel logout requires POST binding, but provider is configured "
|
||||
"with %s binding",
|
||||
self.provider.sls_binding,
|
||||
provider=self.provider,
|
||||
)
|
||||
|
||||
# Queue the logout response to be sent in the background
|
||||
# This doesn't block the user's logout from completing
|
||||
send_saml_logout_response.send(
|
||||
provider_pk=self.provider.pk,
|
||||
sls_url=self.provider.sls_url,
|
||||
logout_request_id=logout_request.id if logout_request else None,
|
||||
relay_state=relay_state,
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
"Queued backchannel logout response",
|
||||
provider=self.provider,
|
||||
sls_url=self.provider.sls_url,
|
||||
)
|
||||
|
||||
# Just end the session - no user interaction needed
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
else:
|
||||
# Iframe mode (default for FRONTCHANNEL_IFRAME) - user stays on authentik
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
)
|
||||
|
||||
logout_response = processor.build_response()
|
||||
|
||||
if self.provider.sls_binding == SAMLBindings.POST:
|
||||
logout_data = {
|
||||
"url": self.provider.sls_url,
|
||||
"saml_response": nice64(logout_response),
|
||||
"saml_relay_state": relay_state,
|
||||
"provider_name": self.provider.name,
|
||||
"binding": SAMLBindings.POST,
|
||||
}
|
||||
else:
|
||||
logout_url = processor.get_redirect_url()
|
||||
logout_data = {
|
||||
"url": logout_url,
|
||||
"provider_name": self.provider.name,
|
||||
"binding": SAMLBindings.REDIRECT,
|
||||
}
|
||||
|
||||
plan.context[PLAN_CONTEXT_SAML_LOGOUT_IFRAME_SESSIONS] = [logout_data]
|
||||
plan.append_stage(in_memory_stage(IframeLogoutStageView))
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
else:
|
||||
# No SLS URL configured, just end session
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
|
||||
# Remove samlsession from database
|
||||
auth_session = AuthenticatedSession.from_request(self.request, self.request.user)
|
||||
@@ -203,17 +114,9 @@ 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, "")
|
||||
if relay_state:
|
||||
return redirect(relay_state)
|
||||
|
||||
# No RelayState provided, try to get return URL from plan context
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE)
|
||||
if relay_state:
|
||||
return redirect(relay_state)
|
||||
|
||||
# No relay state and no plan context - redirect to root
|
||||
redirect_url = _get_redirect_url(request, relay_state)
|
||||
if redirect_url:
|
||||
return redirect(redirect_url)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
# For SAML logout requests, use the parent dispatch with auth checks
|
||||
@@ -254,17 +157,9 @@ 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, "")
|
||||
if relay_state:
|
||||
return redirect(relay_state)
|
||||
|
||||
# No RelayState provided, try to get return URL from plan context
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE)
|
||||
if relay_state:
|
||||
return redirect(relay_state)
|
||||
|
||||
# No relay state and no plan context - redirect to root
|
||||
redirect_url = _get_redirect_url(request, relay_state)
|
||||
if redirect_url:
|
||||
return redirect(redirect_url)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
# For SAML logout requests, use the parent dispatch with auth checks
|
||||
|
||||
@@ -65,7 +65,7 @@ class EnterpriseUser(BaseModel):
|
||||
employeeNumber: str | None = Field(
|
||||
None,
|
||||
description="Numeric or alphanumeric identifier assigned to a person, "
|
||||
"typically based on order of hire or association with an organization.",
|
||||
"typically based on order of hire or association with anorganization.",
|
||||
)
|
||||
costCenter: str | None = Field(None, description="Identifies the name of a cost center.")
|
||||
organization: str | None = Field(None, description="Identifies the name of an organization.")
|
||||
@@ -73,7 +73,7 @@ class EnterpriseUser(BaseModel):
|
||||
department: str | None = Field(
|
||||
None,
|
||||
description="Numeric or alphanumeric identifier assigned to a person,"
|
||||
" typically based on order of hire or association with an organization.",
|
||||
" typically based on order of hire or association with anorganization.",
|
||||
)
|
||||
manager: Manager | None = Field(
|
||||
None,
|
||||
|
||||
@@ -52,10 +52,10 @@ class SCIMApplicationPoliciesTests(TestCase):
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
|
||||
self.users[1].groups.add(self.group1)
|
||||
self.users[2].groups.add(self.group2)
|
||||
self.users[4].groups.add(self.group1)
|
||||
self.users[4].groups.add(self.group2)
|
||||
self.users[1].ak_groups.add(self.group1)
|
||||
self.users[2].ak_groups.add(self.group2)
|
||||
self.users[4].ak_groups.add(self.group1)
|
||||
self.users[4].ak_groups.add(self.group2)
|
||||
|
||||
def test_no_group_policy(self):
|
||||
"""Test with no group policy set"""
|
||||
|
||||
@@ -10,6 +10,7 @@ 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):
|
||||
@@ -205,3 +206,80 @@ 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)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""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
|
||||
from authentik.core.models import Application, Group, User, UserTypes
|
||||
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
|
||||
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects, sync_tasks
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -537,3 +539,104 @@ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,11 +60,7 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
sources = sources.exclude(pk=self.instance.pk)
|
||||
if sources.exists():
|
||||
raise ValidationError(
|
||||
{
|
||||
"sync_users_password": _(
|
||||
"Only a single LDAP Source with password synchronization is allowed"
|
||||
)
|
||||
}
|
||||
_("Only a single LDAP Source with password synchronization is allowed")
|
||||
)
|
||||
return sync_users_password
|
||||
|
||||
@@ -221,7 +217,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
for sync_class in SYNC_CLASSES:
|
||||
class_name = sync_class.name()
|
||||
all_objects.setdefault(class_name, [])
|
||||
for page in sync_class(source).get_objects(size_limit=10):
|
||||
for page in sync_class(source, Task()).get_objects(size_limit=10):
|
||||
for obj in page:
|
||||
obj: dict
|
||||
obj.pop("raw_attributes", None)
|
||||
|
||||
@@ -27,7 +27,7 @@ from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.models import Task
|
||||
|
||||
LOGGER = get_logger()
|
||||
SYNC_CLASSES = [
|
||||
SYNC_CLASSES: list[type[BaseLDAPSynchronizer]] = [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
"""LDAP Source API tests"""
|
||||
|
||||
from json import loads
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.sources.ldap.api import LDAPSourceSerializer
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
|
||||
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
|
||||
|
||||
LDAP_PASSWORD = generate_key()
|
||||
|
||||
@@ -26,12 +35,13 @@ class LDAPAPITests(APITestCase):
|
||||
}
|
||||
)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
self.assertEqual(serializer.errors, {})
|
||||
|
||||
def test_sync_users_password_invalid(self):
|
||||
"""Ensure only a single source with password sync can be created"""
|
||||
LDAPSource.objects.create(
|
||||
name="foo",
|
||||
slug="foo",
|
||||
slug=generate_id(),
|
||||
server_uri="ldaps://1.2.3.4",
|
||||
bind_cn="",
|
||||
bind_password=LDAP_PASSWORD,
|
||||
@@ -41,15 +51,26 @@ class LDAPAPITests(APITestCase):
|
||||
serializer = LDAPSourceSerializer(
|
||||
data={
|
||||
"name": "foo",
|
||||
"slug": " foo",
|
||||
"slug": generate_id(),
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": LDAP_PASSWORD,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_users_password": False,
|
||||
"sync_users_password": True,
|
||||
}
|
||||
)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"sync_users_password": [
|
||||
ErrorDetail(
|
||||
string="Only a single LDAP Source with password synchronization is allowed",
|
||||
code="invalid",
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_sync_users_mapping_empty(self):
|
||||
"""Check that when sync_users is enabled, property mappings must be set"""
|
||||
@@ -82,3 +103,32 @@ class LDAPAPITests(APITestCase):
|
||||
}
|
||||
)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
|
||||
@apply_blueprint("system/sources-ldap.yaml")
|
||||
def test_sync_debug(self):
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
source: LDAPSource = LDAPSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
base_dn="dc=goauthentik,dc=io",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
source.user_property_mappings.set(
|
||||
LDAPSourcePropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
|
||||
)
|
||||
)
|
||||
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:ldapsource-debug", kwargs={"slug": source.slug})
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content.decode())
|
||||
self.assertIn("users", body)
|
||||
self.assertIn("groups", body)
|
||||
self.assertIn("membership", body)
|
||||
|
||||
@@ -59,7 +59,11 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
session = get_http_session()
|
||||
source_type = registry.find_type(attrs["provider_type"])
|
||||
provider_type_name = attrs.get(
|
||||
"provider_type",
|
||||
self.instance.provider_type if self.instance else None,
|
||||
)
|
||||
source_type = registry.find_type(provider_type_name)
|
||||
|
||||
well_known = attrs.get("oidc_well_known_url") or source_type.oidc_well_known_url
|
||||
inferred_oidc_jwks_url = None
|
||||
@@ -101,16 +105,15 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
config = jwks_config.json()
|
||||
attrs["oidc_jwks"] = config
|
||||
|
||||
provider_type = registry.find_type(attrs.get("provider_type", ""))
|
||||
for url in [
|
||||
"authorization_url",
|
||||
"access_token_url",
|
||||
"profile_url",
|
||||
]:
|
||||
if getattr(provider_type, url, None) is None:
|
||||
if getattr(source_type, url, None) is None:
|
||||
if url not in attrs:
|
||||
raise ValidationError(
|
||||
f"{url} is required for provider {provider_type.verbose_name}"
|
||||
f"{url} is required for provider {source_type.verbose_name}"
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
31
authentik/sources/oauth/tests/test_api.py
Normal file
31
authentik/sources/oauth/tests/test_api.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class TestOAuthSourceAPI(APITestCase):
|
||||
def setUp(self):
|
||||
self.source = OAuthSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
authorization_url="",
|
||||
profile_url="",
|
||||
consumer_key=generate_id(),
|
||||
)
|
||||
self.user = create_test_admin_user()
|
||||
|
||||
def test_patch_no_type(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:oauthsource-detail", kwargs={"slug": self.source.slug}),
|
||||
{
|
||||
"authorization_url": f"https://{generate_id()}",
|
||||
"profile_url": f"https://{generate_id()}",
|
||||
"access_token_url": f"https://{generate_id()}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
@@ -14,24 +14,6 @@ class SAMLException(SentryIgnoredException):
|
||||
return self.default_message
|
||||
|
||||
|
||||
class InvalidEncryption(SAMLException):
|
||||
"""Encryption of XML Object is either missing or invalid."""
|
||||
|
||||
default_message = "The encryption of the SAML object is either missing or invalid."
|
||||
|
||||
|
||||
class InvalidSignature(SAMLException):
|
||||
"""Signature of XML Object is either missing or invalid."""
|
||||
|
||||
default_message = "The signature of the SAML object is either missing or invalid."
|
||||
|
||||
|
||||
class MismatchedRequestID(SAMLException):
|
||||
"""Exception raised when the returned request ID doesn't match the saved ID."""
|
||||
|
||||
default_message = "The SAML Response ID does not match the original request ID."
|
||||
|
||||
|
||||
class MissingSAMLResponse(SAMLException):
|
||||
"""Exception raised when request does not contain SAML Response."""
|
||||
|
||||
@@ -42,3 +24,21 @@ class UnsupportedNameIDFormat(SAMLException):
|
||||
"""Exception raised when SAML Response contains NameID Format not supported."""
|
||||
|
||||
default_message = "The NameID Format in the SAML Response is not supported."
|
||||
|
||||
|
||||
class MismatchedRequestID(SAMLException):
|
||||
"""Exception raised when the returned request ID doesn't match the saved ID."""
|
||||
|
||||
default_message = "The SAML Response ID does not match the original request ID."
|
||||
|
||||
|
||||
class InvalidEncryption(SAMLException):
|
||||
"""Encryption of XML Object is either missing or invalid."""
|
||||
|
||||
default_message = "The encryption of the SAML object is either missing or invalid."
|
||||
|
||||
|
||||
class InvalidSignature(SAMLException):
|
||||
"""Signature of XML Object is either missing or invalid."""
|
||||
|
||||
default_message = "The signature of the SAML object is either missing or invalid."
|
||||
|
||||
@@ -4,7 +4,6 @@ from urllib.parse import parse_qsl, urlparse, urlunparse
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
@@ -38,9 +37,7 @@ from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSI
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
from authentik.sources.saml.exceptions import (
|
||||
InvalidEncryption,
|
||||
InvalidSignature,
|
||||
MismatchedRequestID,
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
@@ -159,15 +156,7 @@ class ACSView(View):
|
||||
processor = ResponseProcessor(source, request)
|
||||
try:
|
||||
processor.parse()
|
||||
except (
|
||||
InvalidEncryption,
|
||||
InvalidSignature,
|
||||
MismatchedRequestID,
|
||||
MissingSAMLResponse,
|
||||
SuspiciousOperation,
|
||||
VerificationError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
except (InvalidSignature, MissingSAMLResponse, VerificationError, ValueError) as exc:
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
try:
|
||||
|
||||
@@ -38,6 +38,7 @@ from authentik.stages.authenticator_validate.models import AuthenticatorValidate
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@@ -143,7 +144,11 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device_class=DeviceClasses.TOTP.value,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device_class": DeviceClasses.TOTP.value,
|
||||
}
|
||||
},
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Invalid Token. Please ensure the time on your device is accurate and try again.")
|
||||
@@ -215,9 +220,13 @@ def validate_challenge_webauthn(
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device=device,
|
||||
device_class=DeviceClasses.WEBAUTHN.value,
|
||||
device_type=device.device_type,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device": device,
|
||||
"device_class": DeviceClasses.WEBAUTHN.value,
|
||||
"device_type": device.device_type,
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ValidationError("Assertion failed") from exc
|
||||
|
||||
@@ -267,8 +276,12 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device_class=DeviceClasses.DUO.value,
|
||||
duo_response=response,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device_class": DeviceClasses.DUO.value,
|
||||
"duo_response": response,
|
||||
}
|
||||
},
|
||||
)
|
||||
raise ValidationError("Duo denied access", code="denied")
|
||||
return device
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -58,7 +58,7 @@ def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: s
|
||||
# [reCAPTCHA](https://developers.google.com/recaptcha/docs/verify#error_code_reference)
|
||||
# [hCaptcha](https://docs.hcaptcha.com/#siteverify-error-codes-table)
|
||||
# [Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes)
|
||||
retryable_error_codes = [
|
||||
retriable_error_codes = [
|
||||
"missing-input-response",
|
||||
"invalid-input-response",
|
||||
"timeout-or-duplicate",
|
||||
@@ -66,7 +66,7 @@ def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: s
|
||||
"already-seen-response",
|
||||
]
|
||||
|
||||
if set(error_codes).issubset(set(retryable_error_codes)):
|
||||
if set(error_codes).issubset(set(retriable_error_codes)):
|
||||
error_message = _("Invalid captcha response. Retrying may solve this issue.")
|
||||
else:
|
||||
error_message = _("Invalid captcha response")
|
||||
|
||||
@@ -80,7 +80,7 @@ class ConsentStageView(ChallengeStageView):
|
||||
|
||||
def should_always_prompt(self) -> bool:
|
||||
"""Check if the current request should require a prompt for non consent reasons,
|
||||
i.e. this stage injected from another stage, mode is always required or no application
|
||||
i.e. this stage injected from another stage, mode is always requireed or no application
|
||||
is set."""
|
||||
current_stage: ConsentStage = self.executor.current_stage
|
||||
# Make this StageView work when injected, in which case `current_stage` is an instance
|
||||
|
||||
@@ -40,10 +40,6 @@ class EmailTemplates(models.TextChoices):
|
||||
"email/event_notification.html",
|
||||
_("Event Notification"),
|
||||
)
|
||||
INVITATION = (
|
||||
"email/invitation.html",
|
||||
_("Invitation"),
|
||||
)
|
||||
|
||||
|
||||
def get_template_choices():
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "email/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block content %}
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h1>
|
||||
{% blocktrans %}
|
||||
You're Invited!
|
||||
{% endblocktrans %}
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td align="center" style="max-width: 300px; padding: 20px 0; color: #212124;">
|
||||
{% blocktrans %}
|
||||
You have been invited to join {{ host }}. Click the button below to get started.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if expires %}
|
||||
<tr>
|
||||
<td align="center" style="max-width: 300px; padding: 10px 0; color: #212124; font-size: 12px;">
|
||||
{% blocktrans with expires=expires|naturaltime %}
|
||||
This invitation expires {{ expires }}.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td align="center" class="btn btn-primary">
|
||||
<a id="confirm" href="{{ url }}" rel="noopener noreferrer" target="_blank">{% trans 'Accept Invitation' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_content %}
|
||||
<tr>
|
||||
<td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
|
||||
{% blocktrans %}
|
||||
If you cannot click the button above, please copy and paste the following URL into your browser:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
<a href="{{ url }}" rel="noopener noreferrer" target="_blank">{{ url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user