Compare commits

..

16 Commits

Author SHA1 Message Date
Connor Peshek
cf1392a89e alter how relaystate is handled 2026-04-29 04:51:09 -05:00
Connor Peshek
69a86cf258 update to main 2026-04-29 04:30:25 -05:00
Connor Peshek
f79b1ba41e fix to set sign logout request to true by default 2026-03-12 23:52:29 -05:00
Connor Peshek
772db03b4b fix merge and lint 2026-03-11 14:03:55 -05:00
Connor Peshek
724f3cc59c merge main fix conflicts 2026-03-11 13:54:32 -05:00
Connor Peshek
99bf2ac131 make sp init saml native logout work with this flow 2026-02-11 17:57:37 -06:00
Connor Peshek
dc9b302628 merge main and clean up imports 2026-02-11 14:49:32 -06:00
Connor Peshek
764e7a520c clean up shared exceptions 2026-02-11 05:23:21 -06:00
Connor Peshek
02e3baa84d fix order so full single logout works when sp init happens when authentik is idp and sp 2026-02-11 05:18:26 -06:00
Connor Peshek
46f17d23e9 fix imports 2026-02-11 04:53:30 -06:00
Connor Peshek
3f832913dc make work 2026-02-11 04:36:37 -06:00
Connor Peshek
f449335ad1 move parsers to common 2026-02-11 03:49:18 -06:00
Connor Peshek
4215e76b74 clean up logout firing and order 2026-02-10 23:53:01 -06:00
Connor Peshek
ca63ee0142 move logoutrequest parser to its own file 2026-02-10 23:30:37 -06:00
Connor Peshek
63326b22bd broadcast post in metadata and clean up 2026-02-09 19:27:49 -06:00
Connor Peshek
8e3cff2769 sources/saml: add sp init frontchannel logout 2026-02-09 17:35:55 -06:00
222 changed files with 3728 additions and 9514 deletions

View File

@@ -1,81 +0,0 @@
name: "Setup Node.js and NPM"
description: "Sets up Node.js with a specific NPM version via Corepack"
inputs:
working-directory:
description: "Path to the working directory containing the package.json file"
required: false
default: "."
dependencies:
required: false
description: "List of dependencies to setup"
default: "monorepo,working-directory"
node-version-file:
description: "Path to file containing the Node.js version"
required: false
default: "package.json"
cache-dependency-path:
description: "Path to dependency lock file for caching"
required: false
default: "package-lock.json"
cache:
description: "Package manager to cache"
default: "npm"
registry-url:
description: "npm registry URL"
default: "https://registry.npmjs.org"
runs:
using: "composite"
steps:
- name: Setup Node.js (Corepack bootstrap)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.node-version-file }}
registry-url: ${{ inputs.registry-url }}
# The setup-node action will attempt to create a cache using a version of
# npm that may not be compatible with the range specified in package.json.
# This can be enabled **after** corepack is installed and the correct npm version is available.
package-manager-cache: false
- name: Install Corepack
working-directory: ${{ github.workspace}}
shell: bash
run: | #shell
node ./scripts/node/lint-runtime.mjs
node ./scripts/node/setup-corepack.mjs --force
corepack enable
- name: Lint Node.js and NPM versions
shell: bash
run: node ./scripts/node/lint-runtime.mjs
- name: Setup Node.js (Monorepo Root)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.node-version-file }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
registry-url: ${{ inputs.registry-url }}
- name: Install monorepo dependencies
if: ${{ contains(inputs.dependencies, 'monorepo') }}
shell: bash
run: | #shell
node ./scripts/node/lint-lockfile.mjs
corepack npm ci
- name: Setup Node.js (Working Directory)
if: ${{ contains(inputs.dependencies, 'working-directory') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.working-directory }}/${{ inputs.node-version-file }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.working-directory }}/${{ inputs.cache-dependency-path }}
registry-url: ${{ inputs.registry-url }}
- name: Install working directory dependencies
if: ${{ contains(inputs.dependencies, 'working-directory') }}
shell: bash
run: | # shell
corepack install
echo "node version: $(node --version)"
echo "npm version: $(corepack npm --version)"
node ./scripts/node/lint-lockfile.mjs ${{ inputs.working-directory }}
corepack npm ci --prefix ${{ inputs.working-directory }}

View File

@@ -18,24 +18,19 @@ runs:
using: "composite"
steps:
- name: Cleanup apt
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
'python') }}
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') }}
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
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') }}
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo mkdir -p /tmp/empty/
@@ -56,8 +51,7 @@ runs:
working-directory: ${{ inputs.working-directory }}
run: uv sync --all-extras --dev --frozen
- name: Setup rust (stable)
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies,
'rust-nightly') }}
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1
with:
rustflags: ""
@@ -70,14 +64,30 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (root, web)
- name: Setup node (web)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: ./.github/actions/setup-node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
working-directory: web
node-version-file: "${{ inputs.working-directory }}web/package.json"
cache: "npm"
cache-dependency-path: "${{ inputs.working-directory }}web/package-lock.json"
registry-url: "https://registry.npmjs.org"
- name: Setup node (root)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: "${{ inputs.working-directory }}package.json"
cache: "npm"
cache-dependency-path: "${{ inputs.working-directory }}package-lock.json"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
if: ${{ contains(inputs.dependencies, 'node') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5
@@ -87,17 +97,15 @@ runs:
if: ${{ contains(inputs.dependencies, 'runtime') }}
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
with:
key: docker-images-${{ runner.os }}-${{
hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{
inputs.postgresql_version }}
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- 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 --wait
corepack npm ci --prefix web
docker compose -f .github/actions/setup/compose.yml up -d
cd web && npm ci
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
shell: uv run python {0}

View File

@@ -8,14 +8,8 @@ services:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1"]
interval: 1s
timeout: 5s
retries: 60
restart: always
s3:
container_name: s3

View File

@@ -67,16 +67,6 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/setup-node
with:
working-directory: web
dependencies: "monorepo"
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
run: |
make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
id: push
@@ -91,8 +81,7 @@ jobs:
${{ steps.ev.outputs.imageBuildArgs }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames
}}:buildcache-${{ 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
id: attest

View File

@@ -1,65 +0,0 @@
---
name: API - Publish Typescript client
on:
push:
branches: [main]
paths:
- "schema.yml"
workflow_dispatch:
permissions:
# Required for NPM OIDC trusted publisher
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: ./.github/actions/setup-node
with:
working-directory: web
- name: Generate API Client
run: make gen-client-ts
- name: Publish package
working-directory: gen-ts-api/
run: |
npm i
npm publish --tag generated
- name: Upgrade /web
working-directory: web
run: |
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-web-api-client
commit-message: "web: bump API Client version"
title: "web: bump API Client version"
body: "web: bump API Client version"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@@ -22,19 +22,25 @@ jobs:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
with:
working-directory: website
- name: Install Dependencies
working-directory: website/
run: npm ci
- name: Lint
run: corepack npm run ${{ matrix.command }} --prefix website
working-directory: website/
run: npm run ${{ matrix.command }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: |
@@ -48,7 +54,7 @@ jobs:
working-directory: website
env:
NODE_ENV: production
run: corepack npm run build -w api
run: npm run build -w api
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: api-docs
@@ -65,9 +71,11 @@ jobs:
with:
name: api-docs
path: website/api/build
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- name: Deploy Netlify (Production)
working-directory: website/api
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

View File

@@ -24,9 +24,14 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: lifecycle/aws
node-version-file: lifecycle/aws/package.json
cache: "npm"
cache-dependency-path: lifecycle/aws/package-lock.json
- working-directory: lifecycle/aws/
run: |
npm ci
- name: Check changes have been applied
run: |
uv run make aws-cfn

View File

@@ -24,34 +24,46 @@ jobs:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
with:
working-directory: website
- name: Install dependencies
working-directory: website/
run: npm ci
- name: Lint
run: corepack npm run ${{ matrix.command }} --prefix website
working-directory: website/
run: npm run ${{ matrix.command }}
build-docs:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
name: Setup Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- name: Build Documentation via Docusaurus
run: corepack npm run build --prefix website
working-directory: website/
run: npm run build
build-integrations:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- name: Build Integrations via Docusaurus
run: corepack npm run build -w integrations --prefix website
working-directory: website/
run: npm run build -w integrations
build-container:
runs-on: ubuntu-latest
permissions:
@@ -92,9 +104,7 @@ jobs:
platforms: linux/amd64,linux/arm64
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'
|| '' }}
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
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}

View File

@@ -73,8 +73,7 @@ jobs:
- name: generate API clients
run: make gen-clients
- name: ensure schema is up-to-date
run: git diff --exit-code -- schema.yml blueprints/schema.json
packages/client-go packages/client-rust packages/client-ts
run: git diff --exit-code -- schema.yml blueprints/schema.json packages/client-go packages/client-rust packages/client-ts
test-migrations:
runs-on: ubuntu-latest
steps:
@@ -92,8 +91,7 @@ jobs:
outputs:
seed: ${{ steps.seed.outputs.seed }}
test-migrations-from-stable:
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{
matrix.run_id }}/5
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test-make-seed
@@ -103,7 +101,7 @@ jobs:
psql:
- 14-alpine
- 18-alpine
run_id: [ 1, 2, 3, 4, 5 ]
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -111,13 +109,8 @@ jobs:
- name: checkout stable
run: |
set -e -o pipefail
cp -R .github ..
cp -R scripts ..
mkdir -p ../packages
cp -R packages/logger-js ../packages/logger-js
# Previous stable tag
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# Current version family based on
@@ -125,13 +118,10 @@ jobs:
if [[ -n $current_version_family ]]; then
prev_stable="version/${current_version_family}"
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
rm -rf .github/ scripts/ packages/logger-js/
rm -rf .github/ scripts/
mv ../.github ../scripts .
mv ../packages/logger-js ./packages/
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
@@ -179,7 +169,7 @@ jobs:
psql:
- 14-alpine
- 18-alpine
run_id: [ 1, 2, 3, 4, 5 ]
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
@@ -262,22 +252,19 @@ jobs:
COMPOSE_PROFILES: ${{ matrix.job.profiles }}
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- uses: ./.github/actions/setup-node
- id: cache-web
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
if: contains(matrix.job.profiles, 'selenium')
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json',
'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles,
'selenium')
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles, 'selenium')
working-directory: web
run: |
corepack npm ci
corepack npm run build
corepack npm run build:sfe
npm ci
npm run build
npm run build:sfe
- name: run e2e
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -315,14 +302,14 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**',
'web/packages/sfe/src/**') }}-b
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
corepack npm ci --prefix web
corepack npm run build --prefix web
corepack npm run build:sfe --prefix web
npm ci
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -388,9 +375,7 @@ jobs:
uses: ./.github/workflows/_reusable-docker-build.yml
secrets: inherit
with:
image_name: ${{ github.repository == 'goauthentik/authentik-internal' &&
'ghcr.io/goauthentik/internal-server' ||
'ghcr.io/goauthentik/dev-server' }}
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
release: false
pr-comment:
needs:

View File

@@ -114,11 +114,8 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type
}}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' &&
format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max',
matrix.type) || '' }}
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
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
@@ -139,8 +136,8 @@ jobs:
- ldap
- radius
- rac
goos: [ linux ]
goarch: [ amd64, arm64 ]
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -148,11 +145,16 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Build web
run: corepack npm run build-proxy --prefix web
working-directory: web/
run: |
npm ci
npm run build-proxy
- name: Build outpost
run: |
set -x

View File

@@ -12,280 +12,51 @@ on:
- main
- version-*
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
AUTHENTIK_BLUEPRINTS_DIR: "./blueprints"
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST: "true"
# Drives the system/bootstrap.yaml blueprint at startup: creates akadmin with
# these credentials and flips the Setup flag (Setup.set(True)) so the SPA's
# post-login redirect to "/" doesn't bounce through /setup, which would 500
# because the OOBE policy refuses to run once akadmin already has a usable
# password. See authentik/core/setup/signals.py and blueprints/default/flow-oobe.yaml.
AUTHENTIK_BOOTSTRAP_EMAIL: "test-admin@goauthentik.io"
AUTHENTIK_BOOTSTRAP_PASSWORD: "test-runner"
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint
- lint:lockfile
- tsc
- prettier-check
project:
- web
include:
- command: tsc
project: web
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: |
npm ci
- name: Lint
run: corepack npm run lint --prefix web
- name: Check types
run: corepack npm run tsc --prefix web
- name: Check formatting
run: corepack npm run prettier-check --prefix web
- name: Lit analyse
run: corepack npm run lit-analyse --prefix web
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: build
working-directory: web/
run: corepack npm run build
e2e:
name: e2e (playwright)
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
# Required so the "Comment Playwright result on PR" step can update its
# marker comment via the gh CLI / REST API.
pull-requests: write
# Required so the optional "Upload HTML report to S3" step can mint OIDC
# credentials with aws-actions/configure-aws-credentials. Harmless when
# the upload is gated off.
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: system,python,node,go,rust,runtime
- name: Build web UI
run: corepack npm run --prefix web build
- name: Build authentik server (Go)
run: | # shell
go build -o ./bin/authentik-server ./cmd/server
sudo install -m 0755 ./bin/authentik-server /usr/local/bin/authentik-server
- name: Build authentik worker (Rust)
run: | # shell
cargo build --release --bin authentik
sudo install -m 0755 ./target/release/authentik /usr/local/bin/authentik
- name: Apply migrations
run: uv run python -m lifecycle.migrate
- name: Resolve Playwright version
id: playwright-version
working-directory: web
run: | # shell
version=$(node -p "require('@playwright/test/package.json').version")
if [ -z "$version" ]; then
echo "Failed to resolve @playwright/test version" >&2
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
working-directory: web
run: | # shell
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then
corepack npm exec -- playwright install-deps chromium
else
corepack npm exec -- playwright install --with-deps chromium
fi
- name: Start authentik server and worker
run: | # shell
set -euo pipefail
mkdir -p /tmp/ak-logs
# The Go server (authentik-server) spawns gunicorn as a child via PATH lookup
# and inherits the env that `uv run` set up for it. Verify gunicorn resolves
# under the same launcher so we fail fast here instead of waiting for an
# empty 200 from the proxy fallback later.
uv run --frozen sh -c 'command -v gunicorn' \
|| { echo "gunicorn not resolvable from uv run"; exit 1; }
uv run ak server > /tmp/ak-logs/server.log 2>&1 &
echo $! > /tmp/ak-logs/server.pid
# The Rust worker also opens an HTTP/metrics server on listen.http /
# listen.metrics (default :9000 / :9300). On a single CI host that races the
# Go server's binds and silently steals :9000, leaving Playwright talking to
# a healthcheck-only axum router that returns 200/empty for /if/* paths.
# Pin the worker to disjoint ports so the Go server keeps the public 9000.
AUTHENTIK_LISTEN__HTTP="[::]:9001" \
AUTHENTIK_LISTEN__METRICS="[::]:9301" \
uv run ak worker > /tmp/ak-logs/worker.log 2>&1 &
echo $! > /tmp/ak-logs/worker.pid
- name: Wait for authentik to be ready
run: | # shell
set -euo pipefail
# Readiness probes must verify the Go server is actually serving the request,
# not just that *something* on :9000 returned 200. The Go proxy stamps
# `X-authentik-version` on its static responses and the rendered flow page
# contains the <ak-flow-executor> custom element — both are absent from the
# worker's axum healthcheck router, so checking either rules out the
# port-collision failure mode.
timeout 240 bash -c '
until curl -fsS -o /dev/null http://localhost:9000/-/health/ready/; do
sleep 2
done'
timeout 300 bash -c '
until curl -fsS http://localhost:9000/if/flow/default-authentication-flow/ \
| grep -q "ak-flow-executor"; do
sleep 3
done'
- name: Run Playwright tests
working-directory: web
env:
AK_TEST_RUNNER_PAGE_URL: http://localhost:9000
run: corepack npm run test:e2e
# Reporting / upload steps below intentionally use `!cancelled()` rather
# than `failure()`: a cancelled run (e.g. superseded by a newer push) is
# not a real result and shouldn't produce reviewer-facing artifacts or
# comments. `if-no-files-found: ignore` keeps the "passed" case quiet.
- name: Upload Playwright HTML report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
if-no-files-found: ignore
- name: Upload Playwright traces and videos
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-traces
path: web/test-results/
retention-days: 14
if-no-files-found: ignore
- name: Upload authentik server and worker logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: authentik-logs
path: /tmp/ak-logs/
retention-days: 14
if-no-files-found: ignore
- name: Parse Playwright results
id: playwright-results
if: ${{ !cancelled() }}
run: | # shell
set -euo pipefail
report=web/playwright-report/results.json
if [ ! -f "$report" ]; then
{
echo "available=false"
echo "passed=0"
echo "failed=0"
echo "flaky=0"
echo "skipped=0"
} >> "$GITHUB_OUTPUT"
exit 0
fi
{
echo "available=true"
echo "passed=$(jq -r '.stats.expected // 0' "$report")"
echo "failed=$(jq -r '.stats.unexpected // 0' "$report")"
echo "flaky=$(jq -r '.stats.flaky // 0' "$report")"
echo "skipped=$(jq -r '.stats.skipped // 0' "$report")"
} >> "$GITHUB_OUTPUT"
# The S3 publishing pair below is intentionally gated off until the
# `authentik-playwright-artifacts` bucket is provisioned by infra. Flip
# the repo variable `PLAYWRIGHT_S3_ENABLED=true` to turn it on; the URL
# baked into the PR comment below already points at the eventual key.
- name: Configure AWS credentials for HTML report upload
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && vars.PLAYWRIGHT_S3_ENABLED == 'true' }}
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: eu-central-1
- name: Upload HTML report to S3
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && vars.PLAYWRIGHT_S3_ENABLED == 'true' }}
env:
S3_BUCKET: authentik-playwright-artifacts
S3_KEY_PREFIX: pr-${{ github.event.pull_request.number }}/run-${{ github.run_id }}/attempt-${{ github.run_attempt }}
run: | # shell
set -euo pipefail
if [ ! -d web/playwright-report ]; then
echo "No playwright-report/ produced; skipping S3 upload"
exit 0
fi
aws s3 cp \
--recursive \
--acl=public-read \
--cache-control "public, max-age=600" \
web/playwright-report/ \
"s3://${S3_BUCKET}/${S3_KEY_PREFIX}/"
# Same-repo guard: fork PRs run with a read-only GITHUB_TOKEN (even after
# maintainer approval), so the comment + edit calls would 403. Skip cleanly
# rather than failing the job on every fork PR run.
- name: Comment Playwright result on PR
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
AVAILABLE: ${{ steps.playwright-results.outputs.available }}
PASSED: ${{ steps.playwright-results.outputs.passed }}
FAILED: ${{ steps.playwright-results.outputs.failed }}
FLAKY: ${{ steps.playwright-results.outputs.flaky }}
SKIPPED: ${{ steps.playwright-results.outputs.skipped }}
S3_ENABLED: ${{ vars.PLAYWRIGHT_S3_ENABLED }}
REPORT_URL: https://authentik-playwright-artifacts.s3.amazonaws.com/pr-${{ github.event.pull_request.number }}/run-${{ github.run_id }}/attempt-${{ github.run_attempt }}/index.html
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
run: | # shell
set -euo pipefail
marker='<!-- playwright-result -->'
if [ "$AVAILABLE" = "true" ]; then
if [ "$FAILED" -gt 0 ]; then
status='❌ Failed'
elif [ "$FLAKY" -gt 0 ]; then
status='⚠️ Passed with flakes'
else
status='✅ Passed'
fi
stats=$(printf '| Result | Count |\n|---|---|\n| ✅ Passed | %s |\n| ❌ Failed | %s |\n| ⚠️ Flaky | %s |\n| ⏭️ Skipped | %s |\n' "$PASSED" "$FAILED" "$FLAKY" "$SKIPPED")
else
status='⚠️ No results produced'
stats='The job did not produce `playwright-report/results.json`. The suite likely crashed before the JSON reporter wrote its output — see the workflow run for setup-step failures.'
fi
if [ "$S3_ENABLED" = "true" ]; then
report_line=$(printf '[HTML report](%s) · [Workflow run](%s)' "$REPORT_URL" "$RUN_URL")
else
report_line=$(printf '[Workflow run](%s) · _HTML report hosting is gated off until the `authentik-playwright-artifacts` S3 bucket is provisioned (`vars.PLAYWRIGHT_S3_ENABLED`). Until then, download the `playwright-report` artifact from the run page._' "$RUN_URL")
fi
body=$(printf '%s\n## Playwright e2e — %s\n\n%s\n\n%s\n' "$marker" "$status" "$stats" "$report_line")
existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--paginate \
--jq "[.[] | select(.body != null and (.body | startswith(\"$marker\")))] | .[0].id // empty")
if [ -n "$existing" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$body" > /dev/null
echo "Updated existing comment ${existing}"
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body" > /dev/null
echo "Created new playwright-result comment"
fi
run: npm run build
ci-web-mark:
if: always()
needs:
@@ -302,9 +73,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: test
working-directory: web/
run: corepack npm run test || exit 0
run: npm run test || exit 0

View File

@@ -3,7 +3,7 @@ name: Packages - Publish NPM packages
on:
push:
branches: [ main ]
branches: [main]
paths:
- packages/tsconfig/**
- packages/eslint-config/**
@@ -35,19 +35,22 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 2
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: ${{ matrix.package }}
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json
- name: Install Dependencies
run: npm ci
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }}
run: |
corepack npm ci
corepack npm run build
corepack npm publish
npm ci
npm run build
npm publish

View File

@@ -3,7 +3,7 @@ name: Release - On publish
on:
release:
types: [ published, created ]
types: [published, created]
jobs:
build-server:
@@ -87,9 +87,11 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
@@ -142,16 +144,22 @@ jobs:
- proxy
- ldap
- radius
goos: [ linux, darwin ]
goarch: [ amd64, arm64 ]
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web/
run: |
npm ci
- name: Build web
working-directory: web/
run: |
@@ -167,10 +175,8 @@ jobs:
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
matrix.goarch }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }}
upload-aws-cfn-template:
permissions:

2
.gitignore vendored
View File

@@ -14,8 +14,6 @@ media
# Node
node_modules
corepack.tgz
.corepack
.cspellcache
cspell-report.*

4
Cargo.lock generated
View File

@@ -3000,9 +3000,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"aws-lc-rs",
"log",

View File

@@ -66,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"query",
"rustls",
] }
rustls = { version = "= 0.23.40", features = ["fips"] }
rustls = { version = "= 0.23.39", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
"backtrace",
"contexts",

View File

@@ -106,9 +106,8 @@ migrate: ## Run the Authentik Django server's migrations
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn: node-install
corepack npm install --prefix lifecycle/aws
$(UV) run corepack npm run aws-cfn --prefix lifecycle/aws
aws-cfn:
cd lifecycle/aws && npm i && $(UV) run npm run aws-cfn
run-server: ## Run the main authentik server process
$(UV) run ak server
@@ -129,7 +128,7 @@ core-i18n-extract:
--ignore website \
-l en
install: node-install web-install core-install ## Install all requires dependencies for `node`, `web` and `core`
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
dev-drop-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
@@ -233,46 +232,38 @@ gen-dev-config: ## Generate a local development config file
#########################
node-install: ## Install the necessary libraries to build Node.js packages
node ./scripts/node/setup-corepack.mjs
node ./scripts/node/lint-runtime.mjs
node ./scripts/node/lint-runtime.mjs
npm ci
npm ci --prefix web
#########################
## Web
#########################
web-install: ## Install the necessary libraries to build the Authentik UI
node ./scripts/node/lint-runtime.mjs web
corepack npm ci
corepack npm ci --prefix web
web-build: ## Build the Authentik UI
corepack npm run --prefix web build
web-build: node-install ## Build the Authentik UI
npm run --prefix web build
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web-test: ## Run tests for the Authentik UI
corepack npm run --prefix web test
npm run --prefix web test
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
corepack npm run --prefix web watch
npm run --prefix web watch
web-storybook-watch: ## Build and run the storybook documentation server
corepack npm run --prefix web storybook
npm run --prefix web storybook
web-lint-fix:
corepack npm run --prefix web prettier
npm run --prefix web prettier
web-lint:
corepack npm run --prefix web lint
corepack npm run --prefix web lit-analyse
npm run --prefix web lint
npm run --prefix web lit-analyse
web-check-compile:
corepack npm run --prefix web tsc
npm run --prefix web tsc
web-i18n-extract:
corepack npm run --prefix web extract-locales
npm run --prefix web extract-locales
#########################
## Docs
@@ -280,40 +271,35 @@ web-i18n-extract:
docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Authentik docs source code, lint the code, and compile it
docs-install: node-install ## Install the necessary libraries to build the Authentik documentation
node ./scripts/node/lint-runtime.mjs
corepack npm ci
corepack npm ci --prefix website
docs-install:
npm ci --prefix website
docs-lint-fix: lint-spellcheck
corepack npm run --prefix website prettier
npm run --prefix website prettier
docs-build:
node ./scripts/node/lint-runtime.mjs website
corepack npm run --prefix website build
npm run --prefix website build
docs-watch: ## Build and watch the topics documentation
corepack npm run --prefix website start
npm run --prefix website start
integrations: docs-lint-fix integrations-build ## Fix formatting issues in the integrations source code, lint the code, and compile it
integrations-build:
corepack npm run --prefix website -w integrations build
npm run --prefix website -w integrations build
integrations-watch: ## Build and watch the Integrations documentation
corepack npm run --prefix website -w integrations start
npm run --prefix website -w integrations start
docs-api-build:
corepack npm run --prefix website -w api build
npm run --prefix website -w api build
docs-api-watch: ## Build and watch the API documentation
corepack npm run --prefix website -w api generate
corepack npm run --prefix website -w api start
npm run --prefix website -w api generate
npm run --prefix website -w api start
docs-api-clean: ## Clean generated API documentation
corepack npm run --prefix website -w api build:api:clean
npm run --prefix website -w api build:api:clean
#########################
## Docker

View File

@@ -1,73 +1,31 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from urllib.parse import urlencode
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.api.v3.urls import router
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.tenants.api.domains import DomainViewSet
from authentik.tenants.api.tenants import TenantViewSet
from authentik.tenants.utils import get_current_tenant
class TestModelViewSets(TestCase):
"""Test Viewset"""
def setUp(self):
self.user = create_test_admin_user()
self.factory = RequestFactory()
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
"""Test Viewset"""
def test_attrs(self: TestModelViewSets) -> None:
"""Test attributes we require on all viewsets"""
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
def test_ordering(self: TestModelViewSets) -> None:
"""Test that all ordering fields are correct"""
view = test_viewset.as_view({"get": "list"})
for ordering_field in test_viewset.ordering:
with self.subTest(ordering_field):
req = self.factory.get(
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
def test_search(self: TestModelViewSets) -> None:
"""Test that search fields are correct"""
view = test_viewset.as_view({"get": "list"})
req = self.factory.get(
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
cases = {
"attrs": test_attrs,
}
if full:
cases["ordering"] = test_ordering
cases["search"] = test_search
return cases
return tester
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
continue
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
for test, case in viewset_tester_factory(viewset, full=full).items():
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))

View File

@@ -126,7 +126,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
def check_blueprint_perms(blueprint: Blueprint, user: User, explicit_action: str | None = None):
"""Check for individual permissions for each model in a blueprint"""
for entry in blueprint.iter_entries():
for entry in blueprint.entries:
full_model = entry.get_model(blueprint)
app, __, model = full_model.partition(".")
perms = [

View File

@@ -1,11 +1,8 @@
"""Test blueprints v1"""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -45,45 +42,3 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
# Ensure objects do not exist
self.assertFalse(Flow.objects.filter(slug=flow_slug1))
self.assertFalse(Flow.objects.filter(slug=flow_slug2))
def test_enterprise_license_context_unlicensed(self):
"""Test enterprise license context defaults to a false boolean when unlicensed."""
license_key = LicenseKey("test", 0, "Test license", 0, 0)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], False)
def test_enterprise_license_context_licensed(self):
"""Test enterprise license context defaults to a true boolean when licensed."""
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], True)

View File

@@ -146,7 +146,9 @@ class Importer:
try:
from authentik.enterprise.license import LicenseKey
context["goauthentik.io/enterprise/licensed"] = LicenseKey.get_total().status().is_valid
context["goauthentik.io/enterprise/licensed"] = (
LicenseKey.get_total().status().is_valid,
)
except ModuleNotFoundError:
pass
return context

View File

@@ -64,7 +64,6 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"default_application",
"web_certificate",
"client_certificates",
@@ -118,7 +117,6 @@ class CurrentBrandSerializer(PassiveSerializer):
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
default_locale = CharField(read_only=True)
flags = SerializerMethodField()
@@ -156,7 +154,6 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"web_certificate",
"client_certificates",
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0011_alter_brand_branding_default_flow_background_and_more"),
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.AddField(
model_name="brand",
name="flow_lockdown",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_lockdown",
to="authentik_flows.flow",
),
),
]

View File

@@ -58,9 +58,6 @@ class Brand(SerializerModel):
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
flow_lockdown = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
)
default_application = models.ForeignKey(
"authentik_core.Application",

View File

@@ -20,16 +20,11 @@ class TestBrands(APITestCase):
def setUp(self):
super().setUp()
self.default_flags = {}
for flag in Flag.available(visibility="public"):
self.default_flags[flag().key] = flag.get()
Brand.objects.all().delete()
@property
def default_flags(self) -> dict[str, object]:
"""Get current public flags.
Some tests define temporary Flag subclasses, so this can't be cached in setUp.
"""
return {flag().key: flag.get() for flag in Flag.available(visibility="public")}
def test_current_brand(self):
"""Test Current brand API"""
brand = create_test_brand()

View File

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

View File

@@ -1,4 +1,4 @@
"""LogoutRequest parser"""
"""Shared SAML LogoutRequest parser"""
from base64 import b64decode
from dataclasses import dataclass
@@ -6,41 +6,29 @@ from dataclasses import dataclass
from defusedxml import ElementTree
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
@dataclass(slots=True)
class LogoutRequest:
"""Logout Request"""
"""Parsed SAML LogoutRequest"""
id: str | None = None
issuer: str | None = None
name_id: str | None = None
name_id_format: str | None = None
session_index: str | None = None
relay_state: str | None = None
class LogoutRequestParser:
"""LogoutRequest Parser"""
provider: SAMLProvider
def __init__(self, provider: SAMLProvider):
self.provider = provider
"""Parse incoming SAML LogoutRequest messages"""
def _parse_xml(self, decoded_xml: str | bytes, relay_state: str | None = None) -> LogoutRequest:
root = ElementTree.fromstring(decoded_xml)
request = LogoutRequest(
id=root.attrib["ID"],
id=root.attrib.get("ID"),
)
# Try both namespaces for Issuer
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
@@ -55,7 +43,6 @@ class LogoutRequestParser:
name_ids = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameID")
if len(name_ids) > 0:
request.name_id = name_ids[0].text
# Extract NameID Format if present
if "Format" in name_ids[0].attrib:
request.name_id_format = name_ids[0].attrib["Format"]
@@ -70,22 +57,17 @@ class LogoutRequestParser:
return request
def parse(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
"""Validate and parse raw request with enveloped signautre."""
"""Parse a POST-binding LogoutRequest (base64 encoded)."""
try:
decoded_xml = b64decode(saml_request.encode())
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request") from None
return self._parse_xml(decoded_xml, relay_state)
def parse_detached(
self,
saml_request: str,
relay_state: str | None = None,
) -> LogoutRequest:
"""Validate and parse raw request with detached signature"""
def parse_detached(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
"""Parse a Redirect-binding LogoutRequest (deflate + base64 encoded)."""
try:
decoded_xml = decode_base64_and_inflate(saml_request)
except UnicodeDecodeError:
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
raise CannotHandleAssertion("Cannot decode SAML request") from None
return self._parse_xml(decoded_xml, relay_state)

View File

@@ -0,0 +1,43 @@
"""Shared SAML LogoutResponse parser"""
from defusedxml.lxml import fromstring
from lxml.etree import _Element # nosec
from structlog.stdlib import get_logger
from authentik.common.saml.constants import NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
LOGGER = get_logger()
class LogoutResponseParser:
"""Parse and validate SAML LogoutResponse messages"""
_root: _Element
def __init__(self, raw_response: str):
self._raw_response = raw_response
def parse(self):
"""Decode and parse the LogoutResponse XML."""
# decode_base64_and_inflate handles both deflate-compressed (Redirect binding)
# and plain base64 (POST binding) responses
response_xml = decode_base64_and_inflate(self._raw_response)
self._root = fromstring(response_xml.encode())
def verify_status(self) -> bool:
"""Check LogoutResponse status. Returns True if status is Success."""
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
if status is None:
return True
status_code = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
if status_code is None:
return True
status_value = status_code.attrib.get("Value", "")
if status_value != SAML_STATUS_SUCCESS:
LOGGER.warning(
"LogoutResponse status is not Success",
status=status_value,
)
return False
return True

View File

@@ -47,8 +47,7 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
search_fields = [
"pbm_uuid",
"name",
"app__name",
"app__slug",
"app",
"attributes",
]
filterset_fields = [

View File

@@ -563,9 +563,6 @@ class UsersFilter(FilterSet):
class UserViewSet(
ConditionalInheritance(
"authentik.enterprise.stages.account_lockdown.api.UserAccountLockdownMixin"
),
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
UsedByMixin,
ModelViewSet,

View File

@@ -12,7 +12,7 @@
{% block head %}
<style data-id="static-styles">
:root {
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url|iriencode|safe }}");
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
}
</style>

View File

@@ -1,6 +1,7 @@
from datetime import datetime
from django.db.models import Exists, OuterRef, Q, Subquery
from django.db.models import BooleanField as ModelBooleanField
from django.db.models import Case, Q, Value, When
from django_filters.rest_framework import BooleanFilter, FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
@@ -13,7 +14,7 @@ from rest_framework.viewsets import GenericViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.lifecycle.api.reviews import ReviewSerializer
from authentik.enterprise.lifecycle.models import LifecycleIteration, LifecycleRule, ReviewState
from authentik.enterprise.lifecycle.models import LifecycleIteration, ReviewState
from authentik.enterprise.lifecycle.utils import (
ContentTypeField,
ReviewerGroupSerializer,
@@ -25,25 +26,20 @@ from authentik.enterprise.lifecycle.utils import (
from authentik.lib.utils.time import timedelta_from_string
class RelatedRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
reviewer_groups = ReviewerGroupSerializer(many=True, read_only=True)
min_reviewers = IntegerField(read_only=True)
reviewers = ReviewerUserSerializer(many=True, read_only=True)
class Meta:
model = LifecycleRule
fields = ["id", "name", "reviewer_groups", "min_reviewers", "reviewers"]
class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
content_type = ContentTypeField()
object_verbose = SerializerMethodField()
rule = RelatedRuleSerializer(read_only=True)
object_admin_url = SerializerMethodField(read_only=True)
grace_period_end = SerializerMethodField(read_only=True)
reviews = ReviewSerializer(many=True, read_only=True, source="review_set.all")
user_can_review = SerializerMethodField(read_only=True)
reviewer_groups = ReviewerGroupSerializer(
many=True, read_only=True, source="rule.reviewer_groups"
)
min_reviewers = IntegerField(read_only=True, source="rule.min_reviewers")
reviewers = ReviewerUserSerializer(many=True, read_only=True, source="rule.reviewers")
next_review_date = SerializerMethodField(read_only=True)
class Meta:
@@ -59,8 +55,10 @@ class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
"grace_period_end",
"next_review_date",
"reviews",
"rule",
"user_can_review",
"reviewer_groups",
"min_reviewers",
"reviewers",
]
read_only_fields = fields
@@ -90,55 +88,43 @@ class IterationViewSet(EnterpriseRequiredMixin, CreateModelMixin, GenericViewSet
queryset = LifecycleIteration.objects.all()
serializer_class = LifecycleIterationSerializer
ordering = ["-opened_on"]
ordering_fields = [
"state",
"content_type__model",
"rule__name",
"opened_on",
"grace_period_end",
]
ordering_fields = ["state", "content_type__model", "opened_on", "grace_period_end"]
filterset_class = LifecycleIterationFilterSet
def get_queryset(self):
user = self.request.user
return self.queryset.annotate(
user_is_reviewer=Exists(
LifecycleRule.objects.filter(
pk=OuterRef("rule_id"),
).filter(
Q(reviewers=user) | Q(reviewer_groups__in=user.groups.all().with_ancestors())
)
user_is_reviewer=Case(
When(
Q(rule__reviewers=user)
| Q(rule__reviewer_groups__in=user.groups.all().with_ancestors()),
then=Value(True),
),
default=Value(False),
output_field=ModelBooleanField(),
)
)
).distinct()
@extend_schema(
operation_id="lifecycle_iterations_list_latest",
responses={200: LifecycleIterationSerializer(many=True)},
)
@action(
detail=False,
pagination_class=None,
methods=["get"],
url_path=r"latest/(?P<content_type>[^/]+)/(?P<object_id>[^/]+)",
)
def latest_iterations(self, request: Request, content_type: str, object_id: str) -> Response:
def latest_iteration(self, request: Request, content_type: str, object_id: str) -> Response:
ct = parse_content_type(content_type)
latest_ids_subquery = (
LifecycleIteration.objects.filter(
rule=OuterRef("rule"),
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
try:
obj = (
self.get_queryset()
.filter(
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
)
.latest("opened_on")
)
.order_by("-opened_on")
.values("id")[:1]
)
latest_per_rule = LifecycleIteration.objects.filter(
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
).filter(id=Subquery(latest_ids_subquery))
serializer = self.get_serializer(latest_per_rule, many=True)
except LifecycleIteration.DoesNotExist:
return Response(status=404)
serializer = self.get_serializer(obj)
return Response(serializer.data)
@extend_schema(

View File

@@ -84,6 +84,23 @@ class LifecycleRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
raise ValidationError(
{"grace_period": _("Grace period must be shorter than the interval.")}
)
if "content_type" in attrs or "object_id" in attrs:
content_type = attrs.get("content_type", getattr(self.instance, "content_type", None))
object_id = attrs.get("object_id", getattr(self.instance, "object_id", None))
if content_type is not None and object_id is None:
existing = LifecycleRule.objects.filter(
content_type=content_type, object_id__isnull=True
)
if self.instance:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise ValidationError(
{
"content_type": _(
"Only one type-wide rule for each object type is allowed."
)
}
)
return attrs

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-05 11:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_lifecycle", "0002_alter_lifecycleiteration_opened_on"),
]
operations = [
migrations.RemoveConstraint(
model_name="lifecyclerule",
name="uniq_lifecycle_rule_ct_null_object",
),
migrations.AlterUniqueTogether(
name="lifecyclerule",
unique_together=set(),
),
]

View File

@@ -56,6 +56,14 @@ class LifecycleRule(SerializerModel):
class Meta:
indexes = [models.Index(fields=["content_type"])]
unique_together = [["content_type", "object_id"]]
constraints = [
models.UniqueConstraint(
fields=["content_type"],
condition=Q(object_id__isnull=True),
name="uniq_lifecycle_rule_ct_null_object",
)
]
@property
def serializer(self) -> type[BaseSerializer]:
@@ -74,6 +82,12 @@ class LifecycleRule(SerializerModel):
qs = self.content_type.get_all_objects_for_this_type()
if self.object_id:
qs = qs.filter(pk=self.object_id)
else:
qs = qs.exclude(
pk__in=LifecycleRule.objects.filter(
content_type=self.content_type, object_id__isnull=False
).values_list(Cast("object_id", output_field=self._get_pk_field()), flat=True)
)
return qs
def _get_stale_iterations(self) -> QuerySet[LifecycleIteration]:
@@ -93,7 +107,8 @@ class LifecycleRule(SerializerModel):
def _get_newly_due_objects(self) -> QuerySet:
recent_iteration_ids = LifecycleIteration.objects.filter(
rule=self,
content_type=self.content_type,
object_id__isnull=False,
opened_on__gte=start_of_day(
timezone.now() + timedelta(days=1) - timedelta_from_string(self.interval)
),
@@ -199,15 +214,9 @@ class LifecycleIteration(SerializerModel, ManagedModel):
}
def initialize(self):
if (self.content_type.app_label, self.content_type.model) == ("authentik_core", "group"):
object_label = self.object.name
elif (self.content_type.app_label, self.content_type.model) == ("authentik_rbac", "role"):
object_label = self.object.name
else:
object_label = str(self.object)
event = Event.new(
EventAction.REVIEW_INITIATED,
message=_(f"Access review is due for {self.content_type.name.lower()} {object_label}"),
message=_(f"Access review is due for {self.content_type.name} {str(self.object)}"),
**self._get_event_args(),
)
event.save()

View File

@@ -3,7 +3,6 @@ from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from authentik.enterprise.lifecycle.models import LifecycleRule, ReviewState
from authentik.tasks.schedules.models import Schedule
@receiver(post_save, sender=LifecycleRule)
@@ -12,9 +11,7 @@ def post_rule_save(sender, instance: LifecycleRule, created: bool, **_):
apply_lifecycle_rule.send_with_options(
args=(instance.id,),
rel_obj=Schedule.objects.get(
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
),
rel_obj=instance,
)

View File

@@ -4,17 +4,14 @@ from dramatiq import actor
from authentik.core.models import User
from authentik.enterprise.lifecycle.models import LifecycleRule
from authentik.events.models import Event, Notification, NotificationTransport
from authentik.tasks.schedules.models import Schedule
@actor(description=_("Dispatch tasks to apply lifecycle rules."))
@actor(description=_("Dispatch tasks to validate lifecycle rules."))
def apply_lifecycle_rules():
for rule in LifecycleRule.objects.all():
apply_lifecycle_rule.send_with_options(
args=(rule.id,),
rel_obj=Schedule.objects.get(
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
),
rel_obj=rule,
)

View File

@@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -20,11 +19,6 @@ class TestLifecycleRuleAPI(APITestCase):
self.content_type = ContentType.objects.get_for_model(Application)
self.reviewer_group = Group.objects.create(name=generate_id())
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_list_rules(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
@@ -196,11 +190,6 @@ class TestIterationAPI(APITestCase):
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_open_iterations(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
@@ -242,7 +231,7 @@ class TestIterationAPI(APITestCase):
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iterations",
"authentik_api:lifecycleiteration-latest-iteration",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
@@ -250,20 +239,19 @@ class TestIterationAPI(APITestCase):
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["object_id"], str(self.app.pk))
self.assertEqual(response.data["object_id"], str(self.app.pk))
def test_latest_iteration_not_found(self):
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iterations",
"authentik_api:lifecycleiteration-latest-iteration",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": "00000000-0000-0000-0000-000000000000",
},
)
)
self.assertEqual(response.data, [])
self.assertEqual(response.status_code, 404)
def test_iteration_includes_user_can_review(self):
rule = LifecycleRule.objects.create(
@@ -291,11 +279,6 @@ class TestReviewAPI(APITestCase):
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_create_review(self):
rule = LifecycleRule.objects.create(
name=generate_id(),

View File

@@ -2,7 +2,6 @@ import datetime as dt
from datetime import timedelta
from unittest.mock import patch
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.utils import timezone
@@ -30,11 +29,6 @@ class TestLifecycleModels(TestCase):
def setUp(self):
self.factory = RequestFactory()
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def _get_request(self):
return self.factory.get("/")
@@ -444,6 +438,31 @@ class TestLifecycleModels(TestCase):
self.assertIn(app_one, objects)
self.assertIn(app_two, objects)
def test_rule_type_excludes_objects_with_specific_rules(self):
app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
# Create a specific rule for app_with_rule
LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_with_rule.pk),
interval="days=30",
)
# Create a type-level rule
type_rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
interval="days=60",
)
objects = list(type_rule.get_objects())
self.assertNotIn(app_with_rule, objects)
self.assertIn(app_without_rule, objects)
def test_rule_type_apply_creates_iterations_for_all_objects(self):
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
@@ -650,73 +669,6 @@ class TestLifecycleModels(TestCase):
self.assertIn(explicit_reviewer, reviewers)
self.assertIn(group_member, reviewers)
def test_multiple_rules_same_object_create_separate_iterations(self):
"""Two rules targeting the same object each create their own iteration."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
rule_one = self._create_rule_for_object(obj, interval="days=30", grace_period="days=10")
rule_two = self._create_rule_for_object(obj, interval="days=60", grace_period="days=20")
iterations = LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(obj.pk)
)
self.assertEqual(iterations.count(), 2)
iter_one = iterations.get(rule=rule_one)
iter_two = iterations.get(rule=rule_two)
self.assertEqual(iter_one.state, ReviewState.PENDING)
self.assertEqual(iter_two.state, ReviewState.PENDING)
self.assertNotEqual(iter_one.pk, iter_two.pk)
def test_multiple_rules_same_object_reviewed_independently(self):
"""Reviewing one rule's iteration does not affect the other rule's iteration."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
reviewer = create_test_user()
rule_one = self._create_rule_for_object(obj, min_reviewers=1)
rule_two = self._create_rule_for_object(obj, min_reviewers=1)
group = Group.objects.create(name=generate_id())
group.users.add(reviewer)
rule_one.reviewer_groups.add(group)
rule_two.reviewer_groups.add(group)
iter_one = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule_one
)
iter_two = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule_two
)
request = self._get_request()
# Review only rule_one's iteration
Review.objects.create(iteration=iter_one, reviewer=reviewer)
iter_one.on_review(request)
iter_one.refresh_from_db()
iter_two.refresh_from_db()
self.assertEqual(iter_one.state, ReviewState.REVIEWED)
self.assertEqual(iter_two.state, ReviewState.PENDING)
def test_type_rule_and_object_rule_both_create_iterations(self):
"""A type-level rule and an object-level rule both create iterations for the same object."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
object_rule = self._create_rule_for_object(obj, interval="days=30")
type_rule = self._create_rule_for_type(Application, interval="days=60")
iterations = LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(obj.pk)
)
self.assertEqual(iterations.count(), 2)
self.assertTrue(iterations.filter(rule=object_rule).exists())
self.assertTrue(iterations.filter(rule=type_rule).exists())
class TestLifecycleDateBoundaries(TestCase):
"""Verify that start_of_day normalization ensures correct overdue/due
@@ -727,11 +679,6 @@ class TestLifecycleDateBoundaries(TestCase):
ensures that the boundary is always at midnight, so millisecond variations
in task execution time do not affect results."""
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
app = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)

View File

@@ -14,7 +14,6 @@ TENANT_APPS = [
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",

View File

@@ -1,141 +0,0 @@
"""Account Lockdown Stage API Views"""
from django.utils.translation import gettext as _
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import PrimaryKeyRelatedField
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer
from authentik.core.models import (
User,
)
from authentik.enterprise.api import EnterpriseRequiredMixin, enterprise_action
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
can_lock_user,
get_lockdown_target_users,
)
from authentik.flows.api.stages import StageSerializer
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
LOGGER = get_logger()
class AccountLockdownStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""AccountLockdownStage Serializer"""
class Meta:
model = AccountLockdownStage
fields = StageSerializer.Meta.fields + [
"deactivate_user",
"set_unusable_password",
"delete_sessions",
"revoke_tokens",
"self_service_completion_flow",
]
class AccountLockdownStageViewSet(UsedByMixin, ModelViewSet):
"""AccountLockdownStage Viewset"""
queryset = AccountLockdownStage.objects.all()
serializer_class = AccountLockdownStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
class UserAccountLockdownSerializer(PassiveSerializer):
"""Choose the target account before starting the lockdown flow."""
user = PrimaryKeyRelatedField(
queryset=get_lockdown_target_users(),
required=False,
allow_null=True,
help_text=_("User to lock. If omitted, locks the current user (self-service)."),
)
class UserAccountLockdownMixin:
"""Enterprise account-lockdown API actions for UserViewSet."""
def _create_lockdown_flow_url(self, request: Request, user: User) -> str:
"""Create a flow URL for account lockdown.
The request body selects the target before the flow starts. The API
pre-plans the lockdown flow with the target as the pending user, so the
account lockdown stage can use the normal flow context.
"""
flow = request._request.brand.flow_lockdown
if flow is None:
raise ValidationError({"non_field_errors": [_("No lockdown flow configured.")]})
planner = FlowPlanner(flow)
planner.use_cache = False
try:
plan = planner.plan(request._request, {PLAN_CONTEXT_PENDING_USER: user})
except EmptyFlowException, FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": [_("Lockdown flow is not applicable.")]}
) from None
return plan.to_redirect(request._request, flow).url
@extend_schema(
description=_("Choose the target account, then return a flow link."),
request=UserAccountLockdownSerializer,
responses={
"200": OpenApiResponse(
response=LinkSerializer,
examples=[
OpenApiExample(
"Lockdown flow URL",
value={
"link": "https://example.invalid/if/flow/default-account-lockdown/",
},
response_only=True,
status_codes=["200"],
)
],
),
"400": OpenApiResponse(
description=_("No lockdown flow configured or the flow is not applicable")
),
"403": OpenApiResponse(
description=_("Permission denied (when targeting another user)")
),
},
)
@action(
detail=False,
methods=["POST"],
permission_classes=[IsAuthenticated],
url_path="account_lockdown",
)
@validate(UserAccountLockdownSerializer)
@enterprise_action
def account_lockdown(self, request: Request, body: UserAccountLockdownSerializer) -> Response:
"""Trigger account lockdown for a user.
If no user is specified, locks the current user (self-service).
When targeting another user, admin permissions are required.
Returns a flow link for the frontend to follow. The flow is pre-planned
with the target user as pending user for the lockdown stage.
"""
user = body.validated_data.get("user") or request.user
if not can_lock_user(request.user, user):
LOGGER.debug("Permission denied for account lockdown", user=request.user)
self.permission_denied(request)
flow_url = self._create_lockdown_flow_url(request, user)
LOGGER.debug("Returning lockdown flow URL", flow_url=flow_url, user=user.username)
return Response({"link": flow_url})

View File

@@ -1,12 +0,0 @@
"""authentik account lockdown stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageAccountLockdownConfig(EnterpriseConfig):
"""authentik account lockdown stage config"""
name = "authentik.enterprise.stages.account_lockdown"
label = "authentik_stages_account_lockdown"
verbose_name = "authentik Enterprise.Stages.Account Lockdown"
default = True

View File

@@ -1,74 +0,0 @@
# Generated by Django 5.2.13 on 2026-04-19 21:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.CreateModel(
name="AccountLockdownStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"deactivate_user",
models.BooleanField(
default=True,
help_text="Deactivate the user account (set is_active to False)",
),
),
(
"set_unusable_password",
models.BooleanField(
default=True, help_text="Set an unusable password for the user"
),
),
(
"delete_sessions",
models.BooleanField(
default=True, help_text="Delete all active sessions for the user"
),
),
(
"revoke_tokens",
models.BooleanField(
default=True,
help_text="Revoke all tokens for the user (API, app password, recovery, verification, OAuth)",
),
),
(
"self_service_completion_flow",
models.ForeignKey(
blank=True,
help_text="Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="account_lockdown_stages",
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Account Lockdown Stage",
"verbose_name_plural": "Account Lockdown Stages",
},
bases=("authentik_flows.stage",),
),
]

View File

@@ -1,62 +0,0 @@
"""Account lockdown stage models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import Stage
class AccountLockdownStage(Stage):
"""Lock down a target user account."""
deactivate_user = models.BooleanField(
default=True,
help_text=_("Deactivate the user account (set is_active to False)"),
)
set_unusable_password = models.BooleanField(
default=True,
help_text=_("Set an unusable password for the user"),
)
delete_sessions = models.BooleanField(
default=True,
help_text=_("Delete all active sessions for the user"),
)
revoke_tokens = models.BooleanField(
default=True,
help_text=_(
"Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
),
)
self_service_completion_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="account_lockdown_stages",
help_text=_(
"Flow to redirect users to after self-service lockdown. "
"This flow should not require authentication since the user's session is deleted."
),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageSerializer
return AccountLockdownStageSerializer
@property
def view(self) -> type[View]:
from authentik.enterprise.stages.account_lockdown.stage import AccountLockdownStageView
return AccountLockdownStageView
@property
def component(self) -> str:
return "ak-stage-account-lockdown-form"
class Meta:
verbose_name = _("Account Lockdown Stage")
verbose_name_plural = _("Account Lockdown Stages")

View File

@@ -1,345 +0,0 @@
"""Account lockdown stage logic"""
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
from django.db.models import Model, QuerySet
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dramatiq.actor import Actor
from dramatiq.composition import group
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
Session,
Token,
User,
UserTypes,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.events.models import Event, EventAction
from authentik.flows.stage import StageView
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
from authentik.lib.utils.reflection import class_to_path
from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
PLAN_CONTEXT_LOCKDOWN_REASON = "lockdown_reason"
LOCKDOWN_EVENT_ACTION_ID = "account_lockdown"
TARGET_REQUIRED_MESSAGE = _("No target user specified for account lockdown")
PERMISSION_DENIED_MESSAGE = _("You do not have permission to lock down this account.")
ACCOUNT_LOCKDOWN_FAILED_MESSAGE = _("Account lockdown failed for this account.")
SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE = _(
"Self-service account lockdown requires a completion flow."
)
def get_lockdown_target_users() -> QuerySet[User]:
"""Return users that can be targeted by account lockdown."""
return User.objects.exclude_anonymous().exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
def _get_model_field(model: type[Model], field_name: str):
"""Get a model field by name, if present."""
try:
return model._meta.get_field(field_name)
except FieldDoesNotExist:
return None
def _has_user_field(model: type[Model]) -> bool:
"""Check if a model has a direct user foreign key."""
field = _get_model_field(model, "user")
return bool(field and getattr(field, "remote_field", None) and field.remote_field.model is User)
def _has_authenticated_session_field(model: type[Model]) -> bool:
"""Check if a model is linked to an authenticated session."""
field = _get_model_field(model, "session")
return bool(
field
and getattr(field, "remote_field", None)
and field.remote_field.model is AuthenticatedSession
)
def _has_provider_field(model: type[Model]) -> bool:
"""Check if a model is linked to a provider."""
return _get_model_field(model, "provider") is not None
def get_lockdown_token_models() -> tuple[type[Model], ...]:
"""Return token, grant, and provider session models removed by account lockdown."""
token_models: list[type[Model]] = []
for model in apps.get_models():
if model._meta.abstract or not issubclass(model, ExpiringModel):
continue
if model is Token:
token_models.append(model)
elif _has_user_field(model) and (
_has_provider_field(model) or _has_authenticated_session_field(model)
):
token_models.append(model)
elif _has_authenticated_session_field(model):
token_models.append(model)
return tuple(token_models)
def get_lockdown_token_queryset(model: type[Model], user: User) -> QuerySet:
"""Return account lockdown artifacts for a model and user."""
manager = model.objects.including_expired()
if _has_user_field(model):
return manager.filter(user=user)
return manager.filter(session__user=user)
def can_lock_user(actor, user: User) -> bool:
"""Check whether the actor may lock the target user."""
if not actor.is_authenticated:
return False
if user.pk == actor.pk:
return True
return actor.has_perm("authentik_core.change_user", user)
def get_outgoing_sync_tasks() -> tuple[tuple[type[OutgoingSyncProvider], Actor], ...]:
"""Return outgoing sync provider types and their direct sync tasks."""
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync_direct
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync_direct
from authentik.providers.scim.models import SCIMProvider
from authentik.providers.scim.tasks import scim_sync_direct
return (
(SCIMProvider, scim_sync_direct),
(GoogleWorkspaceProvider, google_workspace_sync_direct),
(MicrosoftEntraProvider, microsoft_entra_sync_direct),
)
class AccountLockdownStageView(StageView):
"""Execute account lockdown actions on the target user."""
def is_self_service(self, request: HttpRequest, user: User) -> bool:
"""Check whether the currently authenticated user is locking their own account."""
return request.user.is_authenticated and user.pk == request.user.pk
def get_reason(self) -> str:
"""Get the lockdown reason from the plan context.
Priority:
1. prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
2. PLAN_CONTEXT_LOCKDOWN_REASON (explicitly set)
3. Empty string as fallback
"""
prompt_data = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
if PLAN_CONTEXT_LOCKDOWN_REASON in prompt_data:
return prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
return self.executor.plan.context.get(PLAN_CONTEXT_LOCKDOWN_REASON, "")
def _apply_lockdown_actions(self, stage: AccountLockdownStage, user: User) -> None:
"""Apply the configured account changes to the target user."""
if stage.deactivate_user:
user.is_active = False
if stage.set_unusable_password:
user.set_unusable_password()
if stage.deactivate_user:
with sync_outgoing_inhibit_dispatch():
user.save()
return
user.save()
def _sync_deactivated_user_to_outgoing_providers(self, user: User) -> None:
"""Synchronize a deactivated user to outgoing sync providers."""
messages = []
wait_timeout = 0
model = class_to_path(User)
provider_filter = Q(backchannel_application__isnull=False) | Q(application__isnull=False)
for provider_model, task_sync_direct in get_outgoing_sync_tasks():
for provider in provider_model.objects.filter(provider_filter):
time_limit = int(
timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
)
messages.append(
task_sync_direct.message_with_options(
args=(model, user.pk, provider.pk),
rel_obj=provider,
time_limit=time_limit,
uid=f"{provider.name}:user:{user.pk}:direct",
)
)
wait_timeout += time_limit
if not messages:
return
try:
group(messages).run().wait(timeout=wait_timeout)
except ResultTimeout:
self.logger.warning(
"Timed out waiting for outgoing sync tasks; tasks remain queued",
user=user.username,
timeout=wait_timeout,
)
def _get_lockdown_artifact_querysets(
self, stage: AccountLockdownStage, user: User
) -> tuple[QuerySet, ...]:
"""Return the configured sessions and tokens targeted by lockdown."""
querysets: list[QuerySet] = []
if stage.delete_sessions:
querysets.append(Session.objects.filter(authenticatedsession__user=user))
if stage.revoke_tokens:
querysets.extend(
get_lockdown_token_queryset(model, user) for model in get_lockdown_token_models()
)
return tuple(querysets)
def _delete_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> None:
"""Delete sessions and tokens selected by the lockdown configuration."""
for queryset in self._get_lockdown_artifact_querysets(stage, user):
queryset.delete()
def _has_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> bool:
"""Check whether there are still sessions or tokens to remove."""
return any(
queryset.exists() for queryset in self._get_lockdown_artifact_querysets(stage, user)
)
def _emit_lockdown_event(self, request: HttpRequest, user: User, reason: str) -> None:
"""Emit the audit event for a completed lockdown."""
# Emit the audit event after the transaction commits. If event creation
# fails here, dispatch() would otherwise treat the whole lockdown as
# failed even though the account changes have already been committed.
try:
Event.new(
EventAction.USER_WRITE,
action_id=LOCKDOWN_EVENT_ACTION_ID,
reason=reason,
affected_user=user.username,
).from_http(request)
except Exception as exc: # noqa: BLE001
# Event emission should not make the lockdown itself fail.
self.logger.warning(
"Failed to emit account lockdown event",
user=user.username,
exc=exc,
)
def _lockdown_user(
self,
request: HttpRequest,
stage: AccountLockdownStage,
user: User,
reason: str,
) -> None:
"""Execute lockdown actions on a single user."""
with atomic():
user = User.objects.get(pk=user.pk)
self._apply_lockdown_actions(stage, user)
self._delete_lockdown_artifacts(stage, user)
# These additional checks/deletes are done to prevent a timing attack that creates tokens
# with a compromised token that is simultaneously being deleted.
while self._has_lockdown_artifacts(stage, user):
with atomic():
self._delete_lockdown_artifacts(stage, user)
if stage.deactivate_user:
try:
self._sync_deactivated_user_to_outgoing_providers(user)
except Exception as exc: # noqa: BLE001
# Local lockdown has already committed. Provider sync failures
# must not reopen access or mark the lockdown itself as failed.
self.logger.warning(
"Failed to sync account lockdown deactivation to outgoing providers",
user=user.username,
exc=exc,
)
self._emit_lockdown_event(request, user, reason)
def dispatch(self, request: HttpRequest) -> HttpResponse:
"""Execute account lockdown actions."""
self.request = request
stage: AccountLockdownStage = self.executor.current_stage
pending_user = self.get_pending_user()
if not pending_user.is_authenticated:
self.logger.warning("No target user found for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
user = get_lockdown_target_users().filter(pk=pending_user.pk).first()
if user is None:
self.logger.warning("Target user is not eligible for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
if not can_lock_user(request.user, user):
self.logger.warning(
"Permission denied for account lockdown",
actor=getattr(request.user, "username", None),
target=user.username,
)
return self.executor.stage_invalid(PERMISSION_DENIED_MESSAGE)
reason = self.get_reason()
self_service = self.is_self_service(request, user)
if self_service and stage.delete_sessions and not stage.self_service_completion_flow:
self.logger.warning("No completion flow configured for self-service account lockdown")
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
self.logger.info(
"Executing account lockdown",
user=user.username,
reason=reason,
self_service=self_service,
deactivate_user=stage.deactivate_user,
set_unusable_password=stage.set_unusable_password,
delete_sessions=stage.delete_sessions,
revoke_tokens=stage.revoke_tokens,
)
try:
self._lockdown_user(request, stage, user, reason)
self.logger.info("Account lockdown completed", user=user.username)
except Exception as exc: # noqa: BLE001
# Convert unexpected lockdown errors to a flow-stage failure instead
# of leaking an exception through the flow executor.
self.logger.warning("Account lockdown failed", user=user.username, exc=exc)
return self.executor.stage_invalid(ACCOUNT_LOCKDOWN_FAILED_MESSAGE)
if self_service:
if stage.delete_sessions:
return self._self_service_completion_response(request)
return self.executor.stage_ok()
return self.executor.stage_ok()
def _self_service_completion_response(self, request: HttpRequest) -> HttpResponse:
"""Redirect to completion flow after self-service lockdown.
Since all sessions are deleted, the user cannot continue in the flow.
Redirect them to an unauthenticated completion flow that shows the
lockdown message.
We use a direct HTTP redirect instead of a challenge because the
flow executor's challenge handling may try to access the session
which we just deleted.
"""
stage: AccountLockdownStage = self.executor.current_stage
completion_flow = stage.self_service_completion_flow
if completion_flow:
# Flush the current request's session to prevent Django's session
# middleware from trying to save a deleted session
if hasattr(request, "session"):
request.session.flush()
redirect_to = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": completion_flow.slug},
)
return HttpResponseRedirect(redirect_to)
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)

View File

@@ -1,148 +0,0 @@
"""Test Users Account Lockdown API"""
from json import loads
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import (
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
# Patch for enterprise license check
patch_license = patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
MagicMock(return_value=True),
)
@patch_license
class AccountLockdownAPITestCase(APITestCase):
"""Shared helpers for account lockdown API tests."""
def setUp(self) -> None:
self.lockdown_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.lockdown_stage = AccountLockdownStage.objects.create(name=generate_id())
FlowStageBinding.objects.create(
target=self.lockdown_flow,
stage=self.lockdown_stage,
order=0,
)
self.brand = create_test_brand()
self.brand.flow_lockdown = self.lockdown_flow
self.brand.save()
def create_user_with_email(self):
"""Create a regular user with a unique email address."""
user = create_test_user()
user.email = f"{generate_id()}@test.com"
user.save()
return user
def assert_redirect_targets(self, response, user):
"""Assert that a response contains a pre-planned lockdown flow link for a user."""
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertIn(self.lockdown_flow.slug, body["link"])
self.assertEqual(urlparse(body["link"]).query, "")
plan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER].pk, user.pk)
def assert_no_flow_configured(self, response):
"""Assert that the API reports a missing lockdown flow."""
self.assertEqual(response.status_code, 400)
body = loads(response.content)
self.assertIn("No lockdown flow configured", body["non_field_errors"][0])
@patch_license
class TestUsersAccountLockdownAPI(AccountLockdownAPITestCase):
"""Test Users Account Lockdown API"""
def setUp(self) -> None:
super().setUp()
self.actor = create_test_user()
self.user = self.create_user_with_email()
def test_account_lockdown_with_change_user_returns_redirect(self):
"""Test that account lockdown allows users with change_user permission."""
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_no_flow_configured(self):
"""Test account lockdown when no flow is configured"""
self.brand.flow_lockdown = None
self.brand.save()
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_no_flow_configured(response)
def test_account_lockdown_unauthenticated(self):
"""Test account lockdown requires authentication"""
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_without_change_user_denied(self):
"""Test account lockdown denies users without change_user permission."""
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_self_returns_redirect(self):
"""Test successful self-service account lockdown returns a direct redirect."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_self_target_without_change_user_returns_redirect(self):
"""Test self-service does not require change_user permission."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)

View File

@@ -1,46 +0,0 @@
"""Tests for the packaged account-lockdown blueprint."""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.tasks import blueprints_find, check_blueprint_v1_file
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
BLUEPRINT_PATH = "example/flow-default-account-lockdown.yaml"
class TestAccountLockdownBlueprint(TransactionTestCase):
"""Test the packaged account-lockdown blueprint behavior."""
def test_blueprint_is_not_auto_instantiated(self):
"""Test the packaged blueprint is opt-in and skipped by discovery."""
BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).delete()
blueprint = next(item for item in blueprints_find() if item.path == BLUEPRINT_PATH)
check_blueprint_v1_file(blueprint)
self.assertFalse(BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).exists())
def test_blueprint_requires_licensed_context(self):
"""Test manual import only creates flows when enterprise is licensed."""
content = BlueprintInstance(path=BLUEPRINT_PATH).retrieve()
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": False})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": True})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown-complete").exists())

View File

@@ -1,627 +0,0 @@
"""Account lockdown stage tests"""
import json
from dataclasses import asdict
from threading import Event as ThreadEvent
from threading import Thread
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.db import connection
from django.http import HttpResponse
from django.test import TransactionTestCase
from django.urls import reverse
from django.utils import timezone
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import AuthenticatedSession, Session, Token, TokenIntents
from authentik.core.tests.utils import (
RequestFactory,
create_test_admin_user,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
LOCKDOWN_EVENT_ACTION_ID,
PLAN_CONTEXT_LOCKDOWN_REASON,
AccountLockdownStageView,
can_lock_user,
)
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.utils.reflection import class_to_path
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.saml.models import SAMLProvider, SAMLSession
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
patch_enterprise_enabled = patch(
"authentik.enterprise.apps.AuthentikEnterpriseConfig.check_enabled",
return_value=True,
)
class AccountLockdownStageTestMixin:
"""Shared setup helpers for account lockdown stage tests."""
@classmethod
def setUpClass(cls):
cls.patch_enterprise_enabled = patch_enterprise_enabled.start()
cls.patch_event_dispatch = patch("authentik.events.tasks.event_trigger_dispatch.send")
cls.patch_event_dispatch.start()
super().setUpClass()
@classmethod
def tearDownClass(cls):
cls.patch_event_dispatch.stop()
patch_enterprise_enabled.stop()
super().tearDownClass()
def setUp(self):
super().setUp()
self.user = create_test_admin_user()
self.target_user = create_test_admin_user()
self.flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage = AccountLockdownStage.objects.create(
name="lockdown",
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.request_factory = RequestFactory()
def make_stage_view(self, plan: FlowPlan):
def _stage_ok():
return HttpResponse(status=204)
def _stage_invalid(_error_message=None):
return HttpResponse(status=400)
return AccountLockdownStageView(
SimpleNamespace(
plan=plan,
current_stage=self.stage,
current_binding=self.binding,
flow=self.flow,
stage_ok=_stage_ok,
stage_invalid=_stage_invalid,
)
)
def make_request(self, *, user=None, query=None):
return self.request_factory.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
query_params=query or {},
user=user,
)
def get_lockdown_event(self):
"""Return the account-lockdown user-write event."""
return Event.objects.filter(
action=EventAction.USER_WRITE,
context__action_id=LOCKDOWN_EVENT_ACTION_ID,
).first()
class TestAccountLockdownStage(AccountLockdownStageTestMixin, FlowTestCase):
"""Account lockdown stage tests"""
def test_lockdown_no_target(self):
"""Test lockdown stage with no pending user fails"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
response = view.dispatch(self.make_request())
self.assertEqual(response.status_code, 400)
def test_lockdown_with_pending_user(self):
"""Test lockdown stage with a pending target user."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Security incident"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertFalse(self.target_user.has_usable_password())
self.assertEqual(response.status_code, 204)
# Check event was created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["action_id"], LOCKDOWN_EVENT_ACTION_ID)
self.assertEqual(event.context["reason"], "Security incident")
self.assertEqual(event.context["affected_user"], self.target_user.username)
def test_lockdown_with_pending_user_reason(self):
"""Test lockdown stage with a pending target and explicit reason."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Compromised account"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_reason_from_prompt(self):
"""Test lockdown stage reads the reason from prompt data."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {
PLAN_CONTEXT_LOCKDOWN_REASON: "User requested lockdown",
}
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["reason"], "User requested lockdown")
def test_lockdown_event_failure_does_not_fail_self_service(self):
"""Test lockdown still succeeds when event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
def test_dispatch_records_success_when_event_emission_fails(self):
"""Test dispatch still completes if event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(
user=self.target_user,
)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_self_service_redirects_to_completion_flow(self):
"""Test self-service lockdown redirects to completion flow when sessions are deleted."""
completion_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage.self_service_completion_flow = completion_flow
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
response = view._self_service_completion_response(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": completion_flow.slug}),
)
def test_lockdown_self_service_requires_completion_flow(self):
"""Test self-service lockdown fails before deleting sessions without a completion flow."""
self.stage.self_service_completion_flow = None
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
self.target_user.refresh_from_db()
self.assertTrue(self.target_user.is_active)
def test_lockdown_denies_other_user_without_permission(self):
"""Test lockdown stage rejects non-self requests without change_user permission."""
actor = create_test_user()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=actor)
self.assertFalse(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
def test_lockdown_revokes_tokens(self):
"""Test lockdown stage revokes tokens"""
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_revokes_provider_tokens(self):
"""Test lockdown stage revokes provider tokens and sessions."""
oauth_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback")
],
signing_key=create_test_cert(),
)
saml_provider = SAMLProvider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
acs_url="https://sp.example.com/acs",
issuer_override="https://idp.example.com",
)
session = Session.objects.create(
session_key=generate_id(),
expires=timezone.now() + timezone.timedelta(hours=1),
last_ip="127.0.0.1",
)
auth_session = AuthenticatedSession.objects.create(
session=session,
user=self.target_user,
)
grant_kwargs = {
"provider": oauth_provider,
"user": self.target_user,
"auth_time": timezone.now(),
"_scope": "openid profile",
"expiring": False,
}
token_kwargs = grant_kwargs | {"_id_token": json.dumps(asdict(IDToken("foo", "bar")))}
AuthorizationCode.objects.create(
code=generate_id(),
session=auth_session,
**grant_kwargs,
)
AccessToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
RefreshToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
DeviceToken.objects.create(
provider=oauth_provider,
user=self.target_user,
session=auth_session,
_scope="openid profile",
expiring=False,
)
SAMLSession.objects.create(
provider=saml_provider,
user=self.target_user,
session=auth_session,
session_index=generate_id(),
name_id=self.target_user.email,
expires=timezone.now() + timezone.timedelta(hours=1),
expiring=True,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(AuthorizationCode.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(AccessToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(RefreshToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(DeviceToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(SAMLSession.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_selective_actions(self):
"""Test lockdown stage with selective actions"""
self.stage.deactivate_user = True
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should be deactivated
self.assertFalse(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Token should still exist
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
def test_lockdown_no_actions(self):
"""Test lockdown stage with all actions disabled"""
self.stage.deactivate_user = False
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should still be active
self.assertTrue(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Event should still be created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
def test_lockdown_deactivation_inhibits_signal_dispatch_until_after_commit(self):
"""Test lockdown queues explicit outgoing syncs after the deactivation transaction."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.sync_outgoing_inhibit_dispatch"
) as inhibit,
patch.object(view, "_sync_deactivated_user_to_outgoing_providers") as sync_outgoing,
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
inhibit.assert_called_once()
sync_outgoing.assert_called_once()
synced_user = sync_outgoing.call_args.args[0]
self.assertEqual(synced_user.pk, self.target_user.pk)
self.assertFalse(synced_user.is_active)
def test_lockdown_waits_for_direct_outgoing_provider_syncs(self):
"""Test direct outgoing sync tasks are enqueued and waited on."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
) as task_group_cls,
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_sync_direct.message_with_options.assert_called_once_with(
args=(class_to_path(type(self.target_user)), self.target_user.pk, provider.pk),
rel_obj=provider,
time_limit=5000,
uid=f"{provider.name}:user:{self.target_user.pk}:direct",
)
task_group_cls.assert_called_once_with(["direct-message"])
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_timeout_leaves_tasks_running(self):
"""Test timeout while waiting for direct outgoing syncs does not fail lockdown."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
task_group.run.return_value.wait.side_effect = ResultTimeout("timed out")
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
),
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_group.run.assert_called_once_with()
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_failure_does_not_fail_lockdown(self):
"""Test completed local lockdown still emits an event if outgoing sync fails."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with patch.object(
view,
"_sync_deactivated_user_to_outgoing_providers",
side_effect=ValueError("sync failed"),
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
event = self.get_lockdown_event()
self.assertIsNotNone(event)
class TestAccountLockdownStageConcurrency(AccountLockdownStageTestMixin, TransactionTestCase):
"""Account lockdown concurrency tests."""
def test_lockdown_retries_when_another_transaction_recreates_a_token(self):
"""Lockdown should remove a token recreated before the retry check runs."""
Token.objects.create(
user=self.target_user,
identifier=f"initial-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
original_has_artifacts = view._has_lockdown_artifacts
target_user = self.target_user
thread_ready = ThreadEvent()
start_create = ThreadEvent()
thread_done = ThreadEvent()
thread_errors = []
class TokenCreatorThread(Thread):
__test__ = False
def run(self):
try:
thread_ready.set()
if not start_create.wait(timeout=5):
thread_errors.append("timed out waiting to recreate token")
return
Token.objects.create(
user=target_user,
identifier=f"concurrent-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
except Exception as exc: # noqa: BLE001
thread_errors.append(exc)
finally:
thread_done.set()
connection.close()
def has_artifacts_after_concurrent_create(stage, user):
if not start_create.is_set():
start_create.set()
self.assertTrue(
thread_done.wait(timeout=30),
(
"Concurrent token creation did not complete "
f"before retry check: {thread_errors}"
),
)
return original_has_artifacts(stage, user)
creator = TokenCreatorThread()
with patch.object(
view, "_has_lockdown_artifacts", side_effect=has_artifacts_after_concurrent_create
):
creator.start()
self.assertTrue(
thread_ready.wait(timeout=5),
"Concurrent token creation thread did not start",
)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
creator.join()
self.assertEqual(thread_errors, [])
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)

View File

@@ -1,5 +0,0 @@
"""API URLs"""
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageViewSet
api_urlpatterns = [("stages/account_lockdown", AccountLockdownStageViewSet)]

View File

@@ -23,7 +23,7 @@
height: 100%;
}
body {
background-image: url("{{ flow_background_url|iriencode|safe }}");
background-image: url("{{ flow_background_url }}");
background-repeat: no-repeat;
background-size: cover;
}

View File

@@ -39,7 +39,7 @@
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style data-id="flow-css">
:root {
--ak-global--background-image: url("{{ flow_background_url|iriencode|safe }}");
--ak-global--background-image: url("{{ flow_background_url }}");
}
</style>
{% endblock %}

View File

@@ -1,14 +1,12 @@
"""stage view tests"""
from collections.abc import Callable
from unittest.mock import patch
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.tests.utils import RequestFactory as AuthentikRequestFactory
from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import Flow, FlowStageBinding
from authentik.flows.models import FlowStageBinding
from authentik.flows.stage import StageView
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.utils.reflection import all_subclasses
@@ -44,46 +42,6 @@ class TestViews(TestCase):
"/static/dist/assets/images/flow_background.jpg",
)
def test_flow_interface_css_background_preserves_presigned_url_query(self):
"""Test flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertContains(
response,
f'--ak-global--background-image: url("{background_url}");',
html=False,
)
def test_flow_sfe_css_background_preserves_presigned_url_query(self):
"""Test SFE flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) + "?sfe"
)
self.assertContains(
response,
f'background-image: url("{background_url}");',
html=False,
)
def view_tester_factory(view_class: type[StageView]) -> Callable:
"""Test a form"""

View File

@@ -19,8 +19,8 @@ from authentik.common.saml.constants import (
RSA_SHA512,
SAML_NAME_ID_FORMAT_UNSPECIFIED,
)
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.lib.xml import lxml_from_string
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.sources.saml.models import SAMLNameIDPolicy

View File

@@ -15,8 +15,8 @@ from authentik.common.saml.constants import (
NS_SAML_PROTOCOL,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
from authentik.common.saml.parsers.logout_request import LogoutRequest
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

View File

@@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model
from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.common.saml.parsers.logout_request import LogoutRequest
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()

View File

@@ -8,10 +8,10 @@ from authentik.common.saml.constants import (
RSA_SHA256,
SAML_NAME_ID_FORMAT_EMAIL,
)
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.core.tests.utils import create_test_cert, create_test_flow
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 LogoutRequestParser
class TestLogoutIntegration(TestCase):
@@ -46,7 +46,7 @@ class TestLogoutIntegration(TestCase):
)
# Create parser for validation
self.parser = LogoutRequestParser(self.provider)
self.parser = LogoutRequestParser()
def test_post_binding_roundtrip(self):
"""Test that a POST-encoded request can be parsed correctly"""
@@ -100,7 +100,7 @@ class TestLogoutIntegration(TestCase):
encoded = processor.encode_post()
# Create parser with verification enabled
parser = LogoutRequestParser(self.provider)
parser = LogoutRequestParser()
# Parse it - this would validate signature if verification is enabled
parsed = parser.parse(encoded)

View File

@@ -4,9 +4,9 @@ from django.test import TestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_TRANSIENT
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
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 LogoutRequestParser
from authentik.sources.saml.models import SAMLSource
GET_LOGOUT_REQUEST = (
@@ -51,7 +51,7 @@ class TestLogoutRequest(TestCase):
def test_static_get(self):
"""Test static LogoutRequest"""
request = LogoutRequestParser(self.provider).parse_detached(GET_LOGOUT_REQUEST)
request = LogoutRequestParser().parse_detached(GET_LOGOUT_REQUEST)
self.assertEqual(request.id, "id-2ea1b01f69363ac95e3da4a15409b9d8ec525944")
self.assertEqual(request.issuer, "saml-test-sp")
# The GET request has an empty NameID element with transient format
@@ -60,7 +60,7 @@ class TestLogoutRequest(TestCase):
def test_static_post(self):
"""Test static LogoutRequest"""
request = LogoutRequestParser(self.provider).parse(POST_LOGOUT_REQUEST)
request = LogoutRequestParser().parse(POST_LOGOUT_REQUEST)
self.assertEqual(request.id, "id-b8f4fd51ed4106f1e782b95d51d9ad3f385e5816")
self.assertEqual(request.issuer, "saml-test-sp")
# The POST request has an empty NameID element with transient format

View File

@@ -9,11 +9,11 @@ from authentik.common.saml.constants import (
NS_SAML_PROTOCOL,
NS_SIGNATURE,
)
from authentik.common.saml.parsers.logout_request import LogoutRequest
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
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
from authentik.providers.saml.processors.metadata import MetadataProcessor

View File

@@ -7,11 +7,11 @@ from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
from authentik.providers.saml.views.flows import (

View File

@@ -7,6 +7,8 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.core.models import Application, AuthenticatedSession
from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage
@@ -16,7 +18,6 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import (
SAMLBindings,
SAMLLogoutMethods,
@@ -24,7 +25,6 @@ from authentik.providers.saml.models import (
SAMLSession,
)
from authentik.providers.saml.native_logout import NativeLogoutStageView
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
@@ -251,7 +251,7 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
return bad_request_message(self.request, "The SAML request payload is missing.")
try:
logout_request = LogoutRequestParser(self.provider).parse_detached(
logout_request = LogoutRequestParser().parse_detached(
self.request.GET[REQUEST_KEY_SAML_REQUEST],
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
)
@@ -295,7 +295,7 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
return bad_request_message(self.request, "The SAML request payload is missing.")
try:
logout_request = LogoutRequestParser(self.provider).parse(
logout_request = LogoutRequestParser().parse(
payload[REQUEST_KEY_SAML_REQUEST],
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
)

View File

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

View File

@@ -172,7 +172,6 @@ SPECTACULAR_SETTINGS = {
},
"ENUM_NAME_OVERRIDES": {
"AppEnum": "authentik.lib.api.Apps",
"AuthenticationEnum": "authentik.flows.models.FlowAuthenticationRequirement",
"ConsentModeEnum": "authentik.stages.consent.models.ConsentMode",
"CountryCodeEnum": "django_countries.countries",
"DeviceClassesEnum": "authentik.stages.authenticator_validate.models.DeviceClasses",

View File

@@ -43,6 +43,7 @@ class SAMLSourceSerializer(SourceSerializer):
"force_authn",
"name_id_policy",
"binding_type",
"slo_binding",
"verification_kp",
"signing_kp",
"digest_algorithm",
@@ -51,6 +52,8 @@ class SAMLSourceSerializer(SourceSerializer):
"encryption_kp",
"signed_assertion",
"signed_response",
"sign_authn_request",
"sign_logout_request",
]
@@ -78,6 +81,7 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
"force_authn",
"name_id_policy",
"binding_type",
"slo_binding",
"verification_kp",
"signing_kp",
"digest_algorithm",
@@ -85,6 +89,8 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
"temporary_user_delete_after",
"signed_assertion",
"signed_response",
"sign_authn_request",
"sign_logout_request",
]
search_fields = ["name", "slug"]
ordering = ["name"]

View File

@@ -0,0 +1,101 @@
# Generated by Django 5.2.11 on 2026-02-09 22:34
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
("authentik_sources_saml", "0021_samlsource_signed_assertion_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="samlsource",
name="sign_authn_request",
field=models.BooleanField(
default=True,
help_text="Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
verbose_name="Sign AuthnRequest",
),
),
migrations.AddField(
model_name="samlsource",
name="sign_logout_request",
field=models.BooleanField(
default=True,
help_text="Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
verbose_name="Sign LogoutRequest",
),
),
migrations.AddField(
model_name="samlsource",
name="slo_binding",
field=models.CharField(
choices=[("REDIRECT", "Redirect Binding"), ("POST", "POST Binding")],
default="REDIRECT",
help_text="Binding type for Single Logout requests to the IdP.",
max_length=100,
verbose_name="SLO Binding",
),
),
migrations.CreateModel(
name="SAMLSourceSession",
fields=[
(
"saml_session_id",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
(
"session_index",
models.TextField(
blank=True,
default="",
help_text="SAML SessionIndex from the IdP's AuthnStatement",
),
),
("name_id", models.TextField(help_text="SAML NameID value for this session")),
(
"name_id_format",
models.TextField(blank=True, default="", help_text="SAML NameID format"),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"session",
models.ForeignKey(
help_text="Link to the user's authenticated session",
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_sources_saml.samlsource",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "SAML Source Session",
"verbose_name_plural": "SAML Source Sessions",
"indexes": [
models.Index(fields=["source", "user"], name="authentik_s_source__abd088_idx"),
models.Index(fields=["session"], name="authentik_s_session_054d2d_idx"),
],
},
),
]

View File

@@ -1,6 +1,7 @@
"""saml sp models"""
from typing import Any
from uuid import uuid4
from django.db import models
from django.http import HttpRequest
@@ -36,9 +37,11 @@ from authentik.common.saml.constants import (
SHA512,
)
from authentik.core.models import (
AuthenticatedSession,
GroupSourceConnection,
PropertyMapping,
Source,
User,
UserSourceConnection,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
@@ -78,6 +81,13 @@ class SAMLNameIDPolicy(models.TextChoices):
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
class SAMLSLOBindingTypes(models.TextChoices):
"""SAML SLO Binding types"""
REDIRECT = "REDIRECT", _("Redirect Binding")
POST = "POST", _("POST Binding")
class SAMLSource(Source):
"""Authenticate using an external SAML Identity Provider."""
@@ -134,6 +144,28 @@ class SAMLSource(Source):
choices=SAMLBindingTypes.choices,
default=SAMLBindingTypes.REDIRECT,
)
slo_binding = models.CharField(
max_length=100,
choices=SAMLSLOBindingTypes.choices,
default=SAMLSLOBindingTypes.REDIRECT,
verbose_name=_("SLO Binding"),
help_text=_("Binding type for Single Logout requests to the IdP."),
)
sign_authn_request = models.BooleanField(
default=True,
verbose_name=_("Sign AuthnRequest"),
help_text=_(
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
),
)
sign_logout_request = models.BooleanField(
default=True,
verbose_name=_("Sign LogoutRequest"),
help_text=_(
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
),
)
temporary_user_delete_after = models.TextField(
default="days=1",
@@ -355,3 +387,39 @@ class GroupSAMLSourceConnection(GroupSourceConnection):
class Meta:
verbose_name = _("Group SAML Source Connection")
verbose_name_plural = _("Group SAML Source Connections")
class SAMLSourceSession(models.Model):
"""Track active SAML source sessions for Single Logout support"""
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
source = models.ForeignKey(SAMLSource, on_delete=models.CASCADE)
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
session = models.ForeignKey(
AuthenticatedSession,
on_delete=models.CASCADE,
help_text=_("Link to the user's authenticated session"),
)
session_index = models.TextField(
default="",
blank=True,
help_text=_("SAML SessionIndex from the IdP's AuthnStatement"),
)
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
name_id_format = models.TextField(
default="",
blank=True,
help_text=_("SAML NameID format"),
)
created = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("SAML Source Session")
verbose_name_plural = _("SAML Source Sessions")
indexes = [
models.Index(fields=["source", "user"]),
models.Index(fields=["session"]),
]
def __str__(self):
return f"SAML Source Session for source {self.source_id} and user {self.user_id}"

View File

@@ -0,0 +1,210 @@
"""SAML Source LogoutRequest Processor"""
import base64
from urllib.parse import quote, urlencode
import xmlsec
from django.http import HttpRequest
from lxml import etree # nosec
from lxml.etree import Element, _Element
from authentik.common.saml.constants import (
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_NAME_ID_FORMAT_EMAIL,
SIGN_ALGORITHM_TRANSFORM_MAP,
)
from authentik.lib.xml import remove_xml_newlines
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
from authentik.sources.saml.models import SAMLSource
class LogoutRequestProcessor:
"""Generate SAML LogoutRequest messages for SP-initiated logout"""
source: SAMLSource
http_request: HttpRequest
destination: str
name_id: str
name_id_format: str
session_index: str
relay_state: str | None
_issue_instant: str
_request_id: str
def __init__(
self,
source: SAMLSource,
http_request: HttpRequest,
destination: str,
name_id: str,
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
session_index: str = "",
relay_state: str | None = None,
):
self.source = source
self.http_request = http_request
self.destination = destination
self.name_id = name_id
self.name_id_format = name_id_format
self.session_index = session_index
self.relay_state = relay_state
self._issue_instant = get_time_string()
self._request_id = get_random_id()
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.source.get_issuer(self.http_request)
return issuer
def get_name_id(self) -> Element:
"""Get NameID element"""
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
name_id.attrib["Format"] = self.name_id_format
name_id.text = self.name_id
return name_id
def build(self) -> Element:
"""Build a SAML LogoutRequest as etree Element"""
logout_request = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutRequest", nsmap=NS_MAP)
logout_request.attrib["ID"] = self._request_id
logout_request.attrib["Version"] = "2.0"
logout_request.attrib["IssueInstant"] = self._issue_instant
logout_request.attrib["Destination"] = self.destination
logout_request.append(self.get_issuer())
logout_request.append(self.get_name_id())
if self.session_index:
session_index_element = Element(f"{{{NS_SAML_PROTOCOL}}}SessionIndex")
session_index_element.text = self.session_index
logout_request.append(session_index_element)
return logout_request
def encode_post(self) -> str:
"""Encode LogoutRequest for POST binding"""
logout_request = self.build()
if self.source.signing_kp and self.source.sign_logout_request:
self._sign_logout_request(logout_request)
return base64.b64encode(etree.tostring(logout_request)).decode()
def encode_redirect(self) -> str:
"""Encode LogoutRequest for Redirect binding"""
logout_request = self.build()
xml_str = etree.tostring(logout_request, encoding="UTF-8", xml_declaration=True)
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
def get_redirect_url(self) -> str:
"""Build complete logout URL for redirect binding with signature if needed"""
encoded_request = self.encode_redirect()
params = {
"SAMLRequest": encoded_request,
}
if self.relay_state:
params["RelayState"] = self.relay_state
if self.source.signing_kp and self.source.sign_logout_request:
sig_alg = self.source.signature_algorithm
params["SigAlg"] = sig_alg
query_string = self._build_signable_query_string(params)
signature = self._sign_query_string(query_string)
params["Signature"] = base64.b64encode(signature).decode()
separator = "&" if "?" in self.destination else "?"
return f"{self.destination}{separator}{urlencode(params)}"
def get_post_form_data(self) -> dict:
"""Get form data for POST binding"""
return {
"SAMLRequest": self.encode_post(),
"RelayState": self.relay_state or "",
}
def _sign_logout_request(self, logout_request: _Element):
"""Sign the LogoutRequest element"""
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)
signature = xmlsec.template.create(
logout_request,
xmlsec.constants.TransformExclC14N,
signature_algorithm_transform,
ns=xmlsec.constants.DSigNs,
)
issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
if issuer is not None:
issuer.addnext(signature)
else:
logout_request.insert(0, signature)
self._sign(logout_request)
def _sign(self, element: _Element):
"""Sign an XML element based on the source's configured signing settings"""
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
self.source.digest_algorithm, xmlsec.constants.TransformSha1
)
xmlsec.tree.add_ids(element, ["ID"])
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
ref = xmlsec.template.add_reference(
signature_node,
digest_algorithm_transform,
uri="#" + element.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()
key = xmlsec.Key.from_memory(
self.source.signing_kp.key_data,
xmlsec.constants.KeyDataFormatPem,
None,
)
key.load_cert_from_memory(
self.source.signing_kp.certificate_data,
xmlsec.constants.KeyDataFormatCertPem,
)
ctx.key = key
ctx.sign(remove_xml_newlines(element, signature_node))
def _build_signable_query_string(self, params: dict) -> str:
"""Build query string for signing (order matters per SAML spec)"""
ordered = []
if "SAMLRequest" in params:
ordered.append(f"SAMLRequest={quote(params['SAMLRequest'], 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.source.signature_algorithm, xmlsec.constants.TransformRsaSha256
)
key = xmlsec.Key.from_memory(
self.source.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)

View File

@@ -0,0 +1,96 @@
"""SAML Source LogoutResponse Builder"""
import base64
from urllib.parse import urlencode
from django.http import HttpRequest
from lxml import etree # nosec
from lxml.etree import Element
from authentik.common.saml.constants import (
NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_STATUS_SUCCESS,
)
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
from authentik.sources.saml.models import SAMLSource
class LogoutResponseBuilder:
"""Build SAML LogoutResponse messages for IdP-initiated logout"""
source: SAMLSource
http_request: HttpRequest
destination: str
in_response_to: str
_issue_instant: str
_response_id: str
def __init__(
self,
source: SAMLSource,
http_request: HttpRequest,
destination: str,
in_response_to: str,
):
self.source = source
self.http_request = http_request
self.destination = destination
self.in_response_to = in_response_to
self._issue_instant = get_time_string()
self._response_id = get_random_id()
def build(self) -> Element:
"""Build a SAML LogoutResponse as etree Element"""
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
response.attrib["ID"] = self._response_id
response.attrib["Version"] = "2.0"
response.attrib["IssueInstant"] = self._issue_instant
response.attrib["Destination"] = self.destination
response.attrib["InResponseTo"] = self.in_response_to
# Issuer
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.source.get_issuer(self.http_request)
response.append(issuer)
# Status
status = Element(f"{{{NS_SAML_PROTOCOL}}}Status")
status_code = Element(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
status_code.attrib["Value"] = SAML_STATUS_SUCCESS
status.append(status_code)
response.append(status)
return response
def encode_post(self) -> str:
"""Encode LogoutResponse for POST binding"""
response = self.build()
return base64.b64encode(etree.tostring(response)).decode()
def encode_redirect(self) -> str:
"""Encode LogoutResponse for Redirect binding"""
response = self.build()
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, relay_state: str | None = None) -> str:
"""Build complete URL for redirect binding"""
encoded = self.encode_redirect()
params = {"SAMLResponse": encoded}
if relay_state:
params["RelayState"] = relay_state
separator = "&" if "?" in self.destination else "?"
return f"{self.destination}{separator}{urlencode(params)}"
def get_post_form_data(self, relay_state: str | None = None) -> dict:
"""Get form data for POST binding"""
data = {"SAMLResponse": self.encode_post()}
if relay_state:
data["RelayState"] = relay_state
return data

View File

@@ -8,6 +8,7 @@ from authentik.common.saml.constants import (
NS_SAML_METADATA,
NS_SIGNATURE,
SAML_BINDING_POST,
SAML_BINDING_REDIRECT,
)
from authentik.providers.saml.utils.encoding import strip_pem_header
from authentik.sources.saml.models import SAMLSource
@@ -75,6 +76,19 @@ class MetadataProcessor:
if encryption_descriptor is not None:
sp_sso_descriptor.append(encryption_descriptor)
if self.source.slo_url:
slo_location = self.source.build_full_url(self.http_request, view="slo")
slo_redirect = SubElement(
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService"
)
slo_redirect.attrib["Binding"] = SAML_BINDING_REDIRECT
slo_redirect.attrib["Location"] = slo_location
slo_post = SubElement(sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService")
slo_post.attrib["Binding"] = SAML_BINDING_POST
slo_post.attrib["Location"] = slo_location
sp_sso_descriptor.append(self.get_name_id_format())
assertion_consumer_service = SubElement(

View File

@@ -72,7 +72,11 @@ class RequestProcessor:
# Create issuer object
auth_n_request.append(self.get_issuer())
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
if (
self.source.signing_kp
and self.source.sign_authn_request
and self.source.binding_type != SAMLBindingTypes.REDIRECT
):
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)
@@ -93,7 +97,11 @@ class RequestProcessor:
(used for POST Bindings)"""
auth_n_request = self.get_auth_n()
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
if (
self.source.signing_kp
and self.source.sign_authn_request
and self.source.binding_type != SAMLBindingTypes.REDIRECT
):
xmlsec.tree.add_ids(auth_n_request, ["ID"])
ctx = xmlsec.SignatureContext()
@@ -141,7 +149,7 @@ class RequestProcessor:
if self.relay_state != "":
response_dict["RelayState"] = self.relay_state
if self.source.signing_kp:
if self.source.signing_kp and self.source.sign_authn_request:
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
)

View File

@@ -42,12 +42,9 @@ from authentik.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from authentik.sources.saml.models import (
GroupSAMLSourceConnection,
SAMLSource,
UserSAMLSourceConnection,
)
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
from authentik.sources.saml.stages import PLAN_CONTEXT_SAML_SESSION_DATA, SAMLSourceFlowManager
LOGGER = get_logger()
if TYPE_CHECKING:
@@ -240,6 +237,7 @@ class ResponseProcessor:
UserSAMLSourceConnection.objects.create(
source=self._source, user=user, identifier=name_id.text
)
session_index = self._get_session_index()
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
@@ -249,9 +247,25 @@ class ResponseProcessor:
"assertion": self.get_assertion(),
"name_id": name_id,
},
policy_context={},
policy_context={
PLAN_CONTEXT_SAML_SESSION_DATA: {
"session_index": session_index or "",
"name_id": name_id.text,
"name_id_format": name_id.attrib.get("Format", ""),
},
},
)
def _get_session_index(self) -> str | None:
"""Get SessionIndex from AuthnStatement element"""
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
if assertion is None:
return None
authn_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
if authn_statement is None:
return None
return authn_statement.attrib.get("SessionIndex")
def get_assertion(self) -> Element | None:
"""Get assertion element, if we have a signed assertion"""
if self._assertion is not None:
@@ -307,6 +321,7 @@ class ResponseProcessor:
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
return self._handle_name_id_transient()
session_index = self._get_session_index()
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
@@ -318,12 +333,10 @@ class ResponseProcessor:
},
policy_context={
"saml_response": etree.tostring(self._root),
PLAN_CONTEXT_SAML_SESSION_DATA: {
"session_index": session_index or "",
"name_id": name_id.text,
"name_id_format": name_id.attrib.get("Format", ""),
},
},
)
class SAMLSourceFlowManager(SourceFlowManager):
"""Source flow manager for SAML Sources"""
user_connection_type = UserSAMLSourceConnection
group_connection_type = GroupSAMLSourceConnection

View File

@@ -3,12 +3,40 @@
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.http import HttpRequest
from django.urls import reverse
from structlog.stdlib import get_logger
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, AuthenticatedSession, User
from authentik.flows.challenge import PLAN_CONTEXT_ATTRS, PLAN_CONTEXT_TITLE, PLAN_CONTEXT_URL
from authentik.flows.models import in_memory_stage
from authentik.flows.stage import RedirectStage, SessionEndStage
from authentik.flows.views.executor import FlowExecutorView
from authentik.providers.saml.native_logout import NativeLogoutStageView
from authentik.sources.saml.models import SAMLSLOBindingTypes, SAMLSourceSession
from authentik.sources.saml.processors.logout_request import LogoutRequestProcessor
from authentik.sources.saml.views import PLAN_CONTEXT_SAML_RELAY_STATE, AutosubmitStageView
from authentik.stages.user_logout.models import UserLogoutStage
from authentik.stages.user_logout.stage import flow_pre_user_logout
LOGGER = get_logger()
# Stages that redirect the user away from authentik. Source SLO stages must be
# inserted before these so they have a chance to execute.
TERMINAL_STAGE_VIEWS = {SessionEndStage, NativeLogoutStageView}
def _insert_before_terminal_stage(plan, stage):
"""Insert a stage before any terminal stage (SessionEndStage, NativeLogoutStageView)
in the plan. Falls back to append if no terminal stage is found."""
for i, binding in enumerate(plan.bindings):
try:
if binding.stage.view in TERMINAL_STAGE_VIEWS:
plan.insert_stage(stage, index=i)
return
except NotImplementedError:
continue
plan.append_stage(stage)
@receiver(user_logged_out)
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
@@ -18,3 +46,89 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
LOGGER.debug("Deleted temporary user", user=user)
user.delete()
@receiver(flow_pre_user_logout)
def handle_saml_source_pre_user_logout(
sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
):
"""Handle SAML source SP-initiated SLO when user logs out via flow.
Injects a stage into the logout flow to redirect the user to the IdP's SLO URL."""
if not isinstance(executor.current_stage, UserLogoutStage):
return
if not user.is_authenticated:
return
auth_session = AuthenticatedSession.from_request(request, user)
if not auth_session:
return
# Find SAMLSourceSessions for this user's current session
saml_source_sessions = SAMLSourceSession.objects.filter(
session=auth_session,
user=user,
).select_related("source")
for saml_session in saml_source_sessions:
source = saml_session.source
if not source.slo_url or not source.enabled:
continue
try:
# Use the flow executor URL as relay_state so that after the IdP
# processes the LogoutRequest and sends a LogoutResponse, the user
# is redirected back to the flow to continue remaining stages.
relay_state = request.build_absolute_uri(
reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": executor.flow.slug},
)
)
# Stash the outbound relay_state so the SLOView can redirect to a
# server-known value rather than trusting the echoed request param.
executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
processor = LogoutRequestProcessor(
source=source,
http_request=request,
destination=source.slo_url,
name_id=saml_session.name_id,
name_id_format=saml_session.name_id_format,
session_index=saml_session.session_index,
relay_state=relay_state,
)
# Insert before terminal stages (SessionEndStage, NativeLogoutStageView)
# so the SLO redirect runs before the flow ends or the user is
# redirected away. Provider logout stages (at index 1/2) still run
# first since they're inserted earlier.
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
redirect_url = processor.get_redirect_url()
stage = in_memory_stage(RedirectStage, destination=redirect_url)
else:
# POST binding
form_data = processor.get_post_form_data()
executor.plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
executor.plan.context[PLAN_CONTEXT_URL] = source.slo_url
executor.plan.context[PLAN_CONTEXT_ATTRS] = form_data
stage = in_memory_stage(AutosubmitStageView)
_insert_before_terminal_stage(executor.plan, stage)
LOGGER.debug(
"Injected SAML source SLO into logout flow",
source=source.name,
binding=source.slo_binding,
)
except (KeyError, AttributeError) as exc:
LOGGER.warning(
"Failed to generate SAML source logout request",
source=source.name,
exc=exc,
)
# Clean up SAMLSourceSessions for this auth session
saml_source_sessions.delete()

View File

@@ -0,0 +1,71 @@
"""SAML Source stages and flow manager"""
from django.http import HttpRequest, HttpResponse
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User
from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.core.sources.stage import PostSourceStage
from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
from authentik.sources.saml.models import (
GroupSAMLSourceConnection,
SAMLSource,
SAMLSourceSession,
UserSAMLSourceConnection,
)
LOGGER = get_logger()
PLAN_CONTEXT_SAML_SESSION_DATA = "saml_session_data"
class SAMLPostSourceStage(PostSourceStage):
"""Extends PostSourceStage to also create SAMLSourceSession for SLO support."""
def dispatch(self, request: HttpRequest) -> HttpResponse:
response = super().dispatch(request)
session_data = self.executor.plan.context.get(PLAN_CONTEXT_SAML_SESSION_DATA)
if not session_data:
return response
source = self.executor.plan.context.get(PLAN_CONTEXT_SOURCE)
if not isinstance(source, SAMLSource):
return response
user: User = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user or not user.pk:
return response
auth_session = AuthenticatedSession.from_request(request, user)
if not auth_session:
return response
SAMLSourceSession.objects.create(
source=source,
user=user,
session=auth_session,
session_index=session_data.get("session_index", ""),
name_id=session_data.get("name_id", ""),
name_id_format=session_data.get("name_id_format", ""),
)
LOGGER.debug(
"Created SAMLSourceSession",
source=source.name,
user=user,
session_index=session_data.get("session_index", ""),
)
return response
class SAMLSourceFlowManager(SourceFlowManager):
"""Source flow manager for SAML Sources"""
user_connection_type = UserSAMLSourceConnection
group_connection_type = GroupSAMLSourceConnection
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
return [
in_memory_stage(SAMLPostSourceStage),
]

View File

@@ -3,7 +3,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
@@ -13,9 +12,13 @@ from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from lxml import etree # nosec
from structlog.stdlib import get_logger
from xmlsec import InternalError, VerificationError
from authentik.common.saml.exceptions import CannotHandleAssertion
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
from authentik.flows.challenge import (
PLAN_CONTEXT_ATTRS,
PLAN_CONTEXT_TITLE,
@@ -33,7 +36,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
from authentik.flows.stage import ChallengeStageView
from authentik.flows.stage import ChallengeStageView, RedirectStage, SessionEndStage
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import nice64
@@ -44,7 +47,13 @@ from authentik.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
from authentik.sources.saml.models import (
SAMLBindingTypes,
SAMLSLOBindingTypes,
SAMLSource,
SAMLSourceSession,
)
from authentik.sources.saml.processors.logout_response import LogoutResponseBuilder
from authentik.sources.saml.processors.metadata import MetadataProcessor
from authentik.sources.saml.processors.request import RequestProcessor
from authentik.sources.saml.processors.response import ResponseProcessor
@@ -52,6 +61,8 @@ from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentS
LOGGER = get_logger()
PLAN_CONTEXT_SAML_RELAY_STATE = "goauthentik.io/sources/saml/relay_state"
class AutosubmitStageView(ChallengeStageView):
"""Wrapper stage to create an autosubmit challenge from plan context variables"""
@@ -181,16 +192,195 @@ class ACSView(View):
return bad_request_message(request, str(exc))
class SLOView(LoginRequiredMixin, View):
"""Single-Logout-View"""
@method_decorator(csrf_exempt, name="dispatch")
class SLOView(View):
"""Single-Logout-View: handles SP-initiated SLO, IdP-initiated LogoutRequest,
and LogoutResponse from IdP"""
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Log user out and redirect them to the IdP's SLO URL."""
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Handle GET requests: LogoutResponse, LogoutRequest, or initiate SLO."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
logout(request)
return redirect(source.slo_url)
if "SAMLResponse" in request.GET:
return self._handle_logout_response(
request,
request.GET["SAMLResponse"],
relay_state=request.GET.get("RelayState"),
)
if "SAMLRequest" in request.GET:
return self._handle_logout_request(
request, source, request.GET["SAMLRequest"], is_post=False
)
# No SAML message, initiate SP-initiated SLO
return self._initiate_logout(request)
def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Handle POST requests: LogoutResponse or LogoutRequest from the IdP."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
if "SAMLResponse" in request.POST:
return self._handle_logout_response(
request,
request.POST["SAMLResponse"],
relay_state=request.POST.get("RelayState"),
)
if "SAMLRequest" in request.POST:
return self._handle_logout_request(
request, source, request.POST["SAMLRequest"], is_post=True
)
return bad_request_message(request, "Missing SAMLRequest or SAMLResponse")
def _initiate_logout(self, request: HttpRequest) -> HttpResponse:
"""Initiate logout using the brand's invalidation flow.
The invalidation flow contains a UserLogoutStage which fires the
flow_pre_user_logout signal. Our signal handler in signals.py picks that up,
finds the SAMLSourceSession, and injects the SLO redirect/POST stage."""
# Sources do not have an invalidation flow, use the brand's
flow = request.brand.flow_invalidation
if not flow:
logout(request)
return redirect("authentik_core:root-redirect")
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
plan = planner.plan(request)
except FlowNonApplicableException:
logout(request)
return redirect("authentik_core:root-redirect")
plan.append_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(request, flow)
def _handle_logout_request(
self,
request: HttpRequest,
source: SAMLSource,
raw_request: str,
is_post: bool = False,
) -> HttpResponse:
"""Handle an incoming LogoutRequest from the IdP (IdP-initiated SLO).
Parses the request, deletes the SAMLSourceSession (to prevent circular
redirect back to the IdP), runs the invalidation flow, and appends a
final stage to send the LogoutResponse back to the IdP."""
parser = LogoutRequestParser()
try:
if is_post:
logout_request = parser.parse(raw_request)
else:
logout_request = parser.parse_detached(raw_request)
except (CannotHandleAssertion, ValueError) as exc:
LOGGER.warning("Failed to parse LogoutRequest from IdP", exc=exc)
return bad_request_message(request, str(exc))
relay_state = (
request.GET.get("RelayState") if not is_post else request.POST.get("RelayState")
)
# Delete SAMLSourceSession so the source signal handler doesn't try to
# redirect back to the IdP (which would be circular)
SAMLSourceSession.objects.filter(
source=source,
user=request.user,
).delete()
# Build the LogoutResponse to send back to the IdP after logout
response_builder = LogoutResponseBuilder(
source=source,
http_request=request,
destination=source.slo_url,
in_response_to=logout_request.id,
)
# Sources do not have an invalidation flow, use the brand's
flow = request.brand.flow_invalidation
if not flow:
logout(request)
return self._send_logout_response(response_builder, relay_state)
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
plan = planner.plan(request)
except FlowNonApplicableException:
logout(request)
return self._send_logout_response(response_builder, relay_state)
# Append logout response stage, then session end
self._append_response_stage(plan, source, response_builder, relay_state)
plan.append_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(request, flow)
def _send_logout_response(
self,
response_builder: LogoutResponseBuilder,
relay_state: str | None = None,
) -> HttpResponse:
"""Send LogoutResponse back to the IdP directly (no flow).
Without a flow we can't render an autosubmit form, so always redirect."""
return redirect(response_builder.get_redirect_url(relay_state))
def _append_response_stage(
self,
plan: FlowPlan,
source: SAMLSource,
response_builder: LogoutResponseBuilder,
relay_state: str | None = None,
):
"""Append a stage to send the LogoutResponse back to the IdP."""
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
redirect_url = response_builder.get_redirect_url(relay_state)
plan.append_stage(in_memory_stage(RedirectStage, destination=redirect_url))
else:
# POST binding — use autosubmit form
form_data = response_builder.get_post_form_data(relay_state)
plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
plan.context[PLAN_CONTEXT_URL] = source.slo_url
plan.context[PLAN_CONTEXT_ATTRS] = form_data
plan.append_stage(in_memory_stage(AutosubmitStageView))
def _handle_logout_response(
self, request: HttpRequest, raw_response: str, relay_state: str | None = None
) -> HttpResponse:
"""Parse and handle a LogoutResponse from the IdP."""
processor = LogoutResponseParser(raw_response)
try:
processor.parse()
except (ValueError, etree.XMLSyntaxError) as exc:
LOGGER.warning("Failed to parse LogoutResponse", exc=exc)
return redirect("authentik_core:root-redirect")
processor.verify_status()
# If a RelayState was provided (e.g. the flow executor URL), advance
# past the current stage (RedirectStage) in the plan so the flow
# continues to the next stage instead of looping. Only redirect to the
# value stashed in the plan context on outbound — never to the value
# echoed back in the request, which is attacker-controllable.
if relay_state and 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 != 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,
)
if plan.bindings:
plan.pop()
request.session[SESSION_KEY_PLAN] = plan
if stored_relay_state:
return redirect(stored_relay_state)
return redirect("authentik_core:root-redirect")
class MetadataView(View):

View File

@@ -1,64 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_prompt",
"0011_prompt_initial_value_prompt_initial_value_expression_and_more",
),
]
operations = [
migrations.AlterField(
model_name="prompt",
name="type",
field=models.CharField(
choices=[
("text", "Text: Simple Text input"),
("text_area", "Text area: Multiline Text Input."),
(
"text_read_only",
"Text (read-only): Simple Text input, but cannot be edited.",
),
(
"text_area_read_only",
"Text area (read-only): Multiline Text input, but cannot be edited.",
),
(
"username",
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
),
("email", "Email: Text field with Email type."),
(
"password",
"Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
),
("number", "Number"),
("checkbox", "Checkbox"),
(
"radio-button-group",
"Fixed choice field rendered as a group of radio buttons.",
),
("dropdown", "Fixed choice field rendered as a dropdown."),
("date", "Date"),
("date-time", "Date Time"),
(
"file",
"File: File upload for arbitrary files. File content will be available in flow context as data-URI",
),
("separator", "Separator: Static Separator Line"),
("hidden", "Hidden: Hidden field, can be used to insert data into form."),
("static", "Static: Static value, displayed as-is."),
("alert_info", "Alert (Info): Static alert box with info styling"),
("alert_warning", "Alert (Warning): Static alert box with warning styling"),
("alert_danger", "Alert (Danger): Static alert box with danger styling"),
("ak-locale", "authentik: Selection of locales authentik supports"),
],
max_length=100,
),
),
]

View File

@@ -87,11 +87,6 @@ class FieldTypes(models.TextChoices):
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
STATIC = "static", _("Static: Static value, displayed as-is.")
# Alert box types for displaying styled messages
ALERT_INFO = "alert_info", _("Alert (Info): Static alert box with info styling")
ALERT_WARNING = "alert_warning", _("Alert (Warning): Static alert box with warning styling")
ALERT_DANGER = "alert_danger", _("Alert (Danger): Static alert box with danger styling")
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
@@ -304,12 +299,7 @@ class Prompt(SerializerModel):
field_class = HiddenField
kwargs["required"] = False
kwargs["default"] = self.placeholder
case (
FieldTypes.STATIC
| FieldTypes.ALERT_INFO
| FieldTypes.ALERT_WARNING
| FieldTypes.ALERT_DANGER
):
case FieldTypes.STATIC:
kwargs["default"] = self.placeholder
kwargs["required"] = False
kwargs["label"] = ""

View File

@@ -124,9 +124,6 @@ class PromptChallengeResponse(ChallengeResponse):
type__in=[
FieldTypes.HIDDEN,
FieldTypes.STATIC,
FieldTypes.ALERT_INFO,
FieldTypes.ALERT_WARNING,
FieldTypes.ALERT_DANGER,
FieldTypes.TEXT_READ_ONLY,
FieldTypes.TEXT_AREA_READ_ONLY,
]

View File

@@ -330,20 +330,10 @@ class TestPromptStage(FlowTestCase):
def test_static_hidden_overwrite(self):
"""Test that static and hidden fields ignore any value sent to them"""
alert_prompt = Prompt.objects.create(
name=generate_id(),
field_key="alert_prompt",
type=FieldTypes.ALERT_INFO,
required=True,
placeholder="alert fallback",
initial_value="alert content",
)
self.stage.fields.add(alert_prompt)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {"hidden_prompt": "hidden"}
self.prompt_data["hidden_prompt"] = "foo"
self.prompt_data["static_prompt"] = "foo"
self.prompt_data["alert_prompt"] = "foo"
challenge_response = PromptChallengeResponse(
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
)
@@ -351,7 +341,6 @@ class TestPromptStage(FlowTestCase):
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["alert_prompt"], "alert content")
def test_prompt_placeholder(self):
"""Test placeholder and expression"""

View File

@@ -1,306 +0,0 @@
version: 1
metadata:
name: Example - Account lockdown flow
labels:
blueprints.goauthentik.io/instantiate: "false"
entries:
flows:
# Main lockdown flow - requires authentication
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown
title: Lock Account
authentication: require_authenticated
identifiers:
slug: default-account-lockdown
model: authentik_flows.flow
id: flow
# Self-service completion flow - no authentication required
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown Complete
title: Account Locked
authentication: none
identifiers:
slug: default-account-lockdown-complete
model: authentik_flows.flow
id: completion-flow
prompt_fields:
# Warning field - danger alert box (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 50
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
pending_user = None
if target_uuid and not is_self_service:
from authentik.core.models import User
pending_user = User.objects.filter(pk=target_uuid).first()
if is_self_service:
return (
"<p><strong>You are about to lock down your own account.</strong></p>"
"<p>This is an emergency action for cutting off access to your account right away.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li><strong>Invalidate your password</strong> - Your password will be set to a random value "
"and cannot be recovered</li>"
"<li><strong>Deactivate your account</strong> - Your account will be disabled</li>"
"<li><strong>Terminate all your sessions</strong> - You will be logged out everywhere</li>"
"<li><strong>Revoke all your tokens</strong> - All your API, app password, recovery, "
"verification, and OAuth2 tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
from django.utils.html import escape
if pending_user:
email = escape(pending_user.email or pending_user.name or "No email")
user_html = f"<p><code>{escape(pending_user.username)}</code> ({email})</p>"
else:
user_html = "<p>the account selected when this one-time lockdown link was created</p>"
return (
"<p><strong>You are about to lock down the following account:</strong></p>"
f"{user_html}"
"<p>This is an emergency action for cutting off access to the account right away. "
"It does not lock the administrator who opened this page.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li>Invalidate the user's password</li>"
"<li>Deactivate the user</li>"
"<li>Terminate all sessions - All active sessions will be ended</li>"
"<li>Revoke all tokens - All API, app password, recovery, verification, and OAuth2 "
"tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
initial_value_expression: true
required: false
type: alert_danger
field_key: lockdown_warning
label: Warning
sub_text: ""
identifiers:
name: default-account-lockdown-field-warning
id: prompt-field-warning
model: authentik_stages_prompt.prompt
# Info field - when to use lockdown (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
info = (
"Use this if you no longer trust your current password or sessions. "
"After lockdown, you will need help from your administrator or security team to regain access."
)
else:
info = (
"Use this for incident response on the listed account, for example after a compromise report "
"or suspicious activity. The reason you enter below will be recorded in the audit log."
)
return (
f"<p>{info}</p>"
'<p><a href="https://docs.goauthentik.io/docs/security/'
'account-lockdown?utm_source=authentik" '
'target="_blank" rel="noopener noreferrer">Learn more about account lockdown</a></p>'
)
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_info
label: Information
sub_text: ""
identifiers:
name: default-account-lockdown-field-info
id: prompt-field-info
model: authentik_stages_prompt.prompt
# Reason field - text area for lockdown reason
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 200
placeholder: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
return "Describe why you are locking your account..."
return "Describe why this account is being locked down..."
placeholder_expression: true
required: true
type: text_area
field_key: lockdown_reason
label: Reason
sub_text: This explanation will be recorded in the audit log.
identifiers:
name: default-account-lockdown-field-reason
id: prompt-field-reason
model: authentik_stages_prompt.prompt
prompt_stages:
# Prompt stage for warnings and reason input
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-warning
- !KeyOf prompt-field-info
- !KeyOf prompt-field-reason
identifiers:
name: default-account-lockdown-prompt
id: default-account-lockdown-prompt
model: authentik_stages_prompt.promptstage
lockdown_stage:
# Account lockdown stage - performs the actual lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
name: default-account-lockdown-stage
id: default-account-lockdown-stage
model: authentik_stages_account_lockdown.accountlockdownstage
attrs:
deactivate_user: true
set_unusable_password: true
delete_sessions: true
revoke_tokens: true
self_service_completion_flow: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
completion_prompt:
# Completion message field - confirmation shown after an admin-triggered lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 300
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
from django.utils.html import escape
from authentik.core.models import User
if target_uuid:
target = User.objects.filter(pk=target_uuid).first()
if target:
return f"<p><code>{escape(target.username)}</code> has been locked down.</p>"
return "<p>The selected account has been locked down.</p>"
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_complete
label: Result
sub_text: ""
identifiers:
name: default-account-lockdown-field-complete
id: prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for admin completion message
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-complete
identifiers:
name: default-account-lockdown-complete-prompt
id: default-account-lockdown-complete-prompt
model: authentik_stages_prompt.promptstage
policies:
# Expression policy to check if this is NOT a self-service lockdown (admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
name: default-account-lockdown-admin-policy
expression: |
target_uuid = (request.http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(request.user, "pk", "") or getattr(request.http_request.user, "pk", ""))
return bool(target_uuid) and target_uuid != current_user_uuid
identifiers:
name: default-account-lockdown-admin-policy
id: admin-policy
model: authentik_policies_expression.expressionpolicy
bindings:
# Stage bindings
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-prompt
target: !KeyOf flow
model: authentik_flows.flowstagebinding
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 10
stage: !KeyOf default-account-lockdown-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# Admin completion stage binding - shown for admin lockdown only
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 20
stage: !KeyOf default-account-lockdown-complete-prompt
target: !KeyOf flow
id: admin-completion-binding
model: authentik_flows.flowstagebinding
# Bind the admin policy to the admin completion stage
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
enabled: true
negate: false
order: 0
identifiers:
policy: !KeyOf admin-policy
target: !KeyOf admin-completion-binding
model: authentik_policies.policybinding
self_service_completion:
# Self-service completion message field (for the unauthenticated completion flow)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
return (
"<h1>Your account has been locked</h1>"
"<p>You have been logged out of all sessions and your password has been invalidated.</p>"
"<p>To regain access to your account, please contact your IT administrator or security team.</p>"
)
initial_value_expression: true
required: false
type: alert_warning
field_key: self_lockdown_complete
label: Account locked
sub_text: ""
identifiers:
name: default-account-lockdown-self-field-complete
id: self-prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for self-service completion (unauthenticated)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf self-prompt-field-complete
identifiers:
name: default-account-lockdown-self-complete-prompt
id: default-account-lockdown-self-complete-prompt
model: authentik_stages_prompt.promptstage
# Bind self-service completion stage to the completion flow
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-self-complete-prompt
target: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
model: authentik_flows.flowstagebinding

View File

@@ -1216,46 +1216,6 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_account_lockdown.accountlockdownstage"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
}
}
},
{
"type": "object",
"required": [
@@ -5140,11 +5100,6 @@
"format": "uuid",
"title": "Flow device code"
},
"flow_lockdown": {
"type": "string",
"format": "uuid",
"title": "Flow lockdown"
},
"default_application": {
"type": "string",
"format": "uuid",
@@ -6094,18 +6049,22 @@
"authentik_sources_saml.add_groupsamlsourceconnection",
"authentik_sources_saml.add_samlsource",
"authentik_sources_saml.add_samlsourcepropertymapping",
"authentik_sources_saml.add_samlsourcesession",
"authentik_sources_saml.add_usersamlsourceconnection",
"authentik_sources_saml.change_groupsamlsourceconnection",
"authentik_sources_saml.change_samlsource",
"authentik_sources_saml.change_samlsourcepropertymapping",
"authentik_sources_saml.change_samlsourcesession",
"authentik_sources_saml.change_usersamlsourceconnection",
"authentik_sources_saml.delete_groupsamlsourceconnection",
"authentik_sources_saml.delete_samlsource",
"authentik_sources_saml.delete_samlsourcepropertymapping",
"authentik_sources_saml.delete_samlsourcesession",
"authentik_sources_saml.delete_usersamlsourceconnection",
"authentik_sources_saml.view_groupsamlsourceconnection",
"authentik_sources_saml.view_samlsource",
"authentik_sources_saml.view_samlsourcepropertymapping",
"authentik_sources_saml.view_samlsourcesession",
"authentik_sources_saml.view_usersamlsourceconnection",
"authentik_sources_scim.add_scimsource",
"authentik_sources_scim.add_scimsourcegroup",
@@ -6139,10 +6098,6 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -7806,69 +7761,6 @@
}
}
},
"model_authentik_stages_account_lockdown.accountlockdownstage": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"deactivate_user": {
"type": "boolean",
"title": "Deactivate user",
"description": "Deactivate the user account (set is_active to False)"
},
"set_unusable_password": {
"type": "boolean",
"title": "Set unusable password",
"description": "Set an unusable password for the user"
},
"delete_sessions": {
"type": "boolean",
"title": "Delete sessions",
"description": "Delete all active sessions for the user"
},
"revoke_tokens": {
"type": "boolean",
"title": "Revoke tokens",
"description": "Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
},
"self_service_completion_flow": {
"type": "string",
"format": "uuid",
"title": "Self service completion flow",
"description": "Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted."
}
},
"required": []
},
"model_authentik_stages_account_lockdown.accountlockdownstage_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_accountlockdownstage",
"change_accountlockdownstage",
"delete_accountlockdownstage",
"view_accountlockdownstage"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": {
"type": "object",
"properties": {
@@ -9064,7 +8956,6 @@
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source"
@@ -9197,7 +9088,6 @@
"authentik_providers_ssf.ssfprovider",
"authentik_providers_ws_federation.wsfederationprovider",
"authentik_reports.dataexport",
"authentik_stages_account_lockdown.accountlockdownstage",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
"authentik_stages_source.sourcestage"
@@ -11860,18 +11750,22 @@
"authentik_sources_saml.add_groupsamlsourceconnection",
"authentik_sources_saml.add_samlsource",
"authentik_sources_saml.add_samlsourcepropertymapping",
"authentik_sources_saml.add_samlsourcesession",
"authentik_sources_saml.add_usersamlsourceconnection",
"authentik_sources_saml.change_groupsamlsourceconnection",
"authentik_sources_saml.change_samlsource",
"authentik_sources_saml.change_samlsourcepropertymapping",
"authentik_sources_saml.change_samlsourcesession",
"authentik_sources_saml.change_usersamlsourceconnection",
"authentik_sources_saml.delete_groupsamlsourceconnection",
"authentik_sources_saml.delete_samlsource",
"authentik_sources_saml.delete_samlsourcepropertymapping",
"authentik_sources_saml.delete_samlsourcesession",
"authentik_sources_saml.delete_usersamlsourceconnection",
"authentik_sources_saml.view_groupsamlsourceconnection",
"authentik_sources_saml.view_samlsource",
"authentik_sources_saml.view_samlsourcepropertymapping",
"authentik_sources_saml.view_samlsourcesession",
"authentik_sources_saml.view_usersamlsourceconnection",
"authentik_sources_scim.add_scimsource",
"authentik_sources_scim.add_scimsourcegroup",
@@ -11905,10 +11799,6 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -13696,6 +13586,15 @@
],
"title": "Binding type"
},
"slo_binding": {
"type": "string",
"enum": [
"REDIRECT",
"POST"
],
"title": "SLO Binding",
"description": "Binding type for Single Logout requests to the IdP."
},
"verification_kp": {
"type": "string",
"format": "uuid",
@@ -13752,6 +13651,16 @@
"signed_response": {
"type": "boolean",
"title": "Signed response"
},
"sign_authn_request": {
"type": "boolean",
"title": "Sign AuthnRequest",
"description": "Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
},
"sign_logout_request": {
"type": "boolean",
"title": "Sign LogoutRequest",
"description": "Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
}
},
"required": []
@@ -15775,9 +15684,6 @@
"separator",
"hidden",
"static",
"alert_info",
"alert_warning",
"alert_danger",
"ak-locale"
],
"title": "Type"

2
go.mod
View File

@@ -7,7 +7,7 @@ require (
beryju.io/radius-eap v0.1.0
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/getsentry/sentry-go v0.46.0
github.com/getsentry/sentry-go v0.45.1
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-openapi/runtime v0.29.4

4
go.sum
View File

@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw=
github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -13,8 +13,8 @@
"cross-env": "^10.1.0"
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
"node": ">=20",
"npm": ">=11.6.2"
}
},
"node_modules/@epic-web/invariant": {

View File

@@ -11,20 +11,7 @@
"cross-env": "^10.1.0"
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
},
"devEngines": {
"runtime": {
"name": "node",
"onFail": "warn",
"version": ">=24"
},
"packageManager": {
"name": "npm",
"version": ">=11.10.1",
"onFail": "warn"
}
},
"packageManager": "npm@11.11.0+sha512.f36811c4aae1fde639527368ae44c571d050006a608d67a191f195a801a52637a312d259186254aa3a3799b05335b7390539cf28656d18f0591a1125ba35f973"
"node": ">=20",
"npm": ">=11.6.2"
}
}

View File

@@ -7,17 +7,6 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
ENV NODE_ENV=production
WORKDIR /work
RUN --mount=type=bind,target=/work/package.json,src=./package.json \
--mount=type=bind,target=/work/package-lock.json,src=./package-lock.json \
--mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/work/packages/logger-js/,src=./packages/logger-js/ \
node ./scripts/node/setup-corepack.mjs --force && \
node ./scripts/node/lint-runtime.mjs ./web
WORKDIR /work/web
# These files need to be copied and cannot be mounted as `npm ci` will build the client's typescript
@@ -29,7 +18,7 @@ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \
corepack npm ci
npm ci
COPY ./package.json /work
COPY ./web /work/web/

View File

@@ -10,22 +10,12 @@ WORKDIR /static
COPY ./packages /packages
COPY ./web/packages /static/packages
RUN --mount=type=bind,target=/static/package.json,src=./package.json \
--mount=type=bind,target=/static/package-lock.json,src=./package-lock.json \
--mount=type=bind,target=/static/web/package.json,src=./web/package.json \
--mount=type=bind,target=/static/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/static/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/static/packages/logger-js/,src=./packages/logger-js/ \
node ./scripts/node/setup-corepack.mjs --force && \
node ./scripts/node/lint-runtime.mjs ./web
COPY package.json /
RUN --mount=type=bind,target=/static/package.json,src=./web/package.json \
--mount=type=bind,target=/static/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/static/scripts,src=./web/scripts \
--mount=type=cache,target=/root/.npm \
corepack npm ci
npm ci
COPY web .
RUN npm run build-proxy

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-30 00:27+0000\n"
"POT-Creation-Date: 2026-04-29 00:28+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -224,14 +224,6 @@ msgid ""
"providers are returned. When set to false, backchannel providers are excluded"
msgstr ""
#: authentik/core/api/users.py
msgid "Invalid password hash format. Must be a valid Django password hash."
msgstr ""
#: authentik/core/api/users.py
msgid "Cannot set both password and password_hash. Use only one."
msgstr ""
#: authentik/core/api/users.py
msgid "No leading or trailing slashes allowed."
msgstr ""

View File

@@ -19,7 +19,6 @@ Forti
Fortigate
Gatus
Gestionnaire
ghec
Gitea
Gravitee
Homarr

View File

@@ -100,7 +100,6 @@ mod json {
);
let mut json_layer = json_subscriber::fmt::layer()
.with_level(false)
.with_timer(LocalTime::new(time_format))
.with_file(true)
.with_line_number(true)
@@ -110,11 +109,6 @@ mod json {
let inner_layer = json_layer.inner_layer_mut();
inner_layer.with_thread_ids("thread_id");
inner_layer.with_thread_names("thread_name");
inner_layer.add_dynamic_field("level", |event, _| {
Some(serde_json::Value::String(
event.metadata().level().as_str().to_lowercase(),
))
});
inner_layer.add_dynamic_field("pid", |_, _| {
Some(serde_json::Value::Number(serde_json::Number::from(
std::process::id(),

View File

@@ -39,7 +39,6 @@ type ApiCoreBrandsListRequest struct {
flowAuthentication *string
flowDeviceCode *string
flowInvalidation *string
flowLockdown *string
flowRecovery *string
flowUnenrollment *string
flowUserSettings *string
@@ -105,11 +104,6 @@ func (r ApiCoreBrandsListRequest) FlowInvalidation(flowInvalidation string) ApiC
return r
}
func (r ApiCoreBrandsListRequest) FlowLockdown(flowLockdown string) ApiCoreBrandsListRequest {
r.flowLockdown = &flowLockdown
return r
}
func (r ApiCoreBrandsListRequest) FlowRecovery(flowRecovery string) ApiCoreBrandsListRequest {
r.flowRecovery = &flowRecovery
return r
@@ -236,9 +230,6 @@ func (a *CoreAPIService) CoreBrandsListExecute(r ApiCoreBrandsListRequest) (*Pag
if r.flowInvalidation != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_invalidation", r.flowInvalidation, "form", "")
}
if r.flowLockdown != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_lockdown", r.flowLockdown, "form", "")
}
if r.flowRecovery != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_recovery", r.flowRecovery, "form", "")
}

View File

@@ -36,7 +36,6 @@ type Brand struct {
FlowUnenrollment NullableString `json:"flow_unenrollment,omitempty"`
FlowUserSettings NullableString `json:"flow_user_settings,omitempty"`
FlowDeviceCode NullableString `json:"flow_device_code,omitempty"`
FlowLockdown NullableString `json:"flow_lockdown,omitempty"`
// When set, external users will be redirected to this application after authenticating.
DefaultApplication NullableString `json:"default_application,omitempty"`
// Web Certificate used by the authentik Core webserver.
@@ -566,49 +565,6 @@ func (o *Brand) UnsetFlowDeviceCode() {
o.FlowDeviceCode.Unset()
}
// GetFlowLockdown returns the FlowLockdown field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *Brand) GetFlowLockdown() string {
if o == nil || IsNil(o.FlowLockdown.Get()) {
var ret string
return ret
}
return *o.FlowLockdown.Get()
}
// GetFlowLockdownOk returns a tuple with the FlowLockdown field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *Brand) GetFlowLockdownOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.FlowLockdown.Get(), o.FlowLockdown.IsSet()
}
// HasFlowLockdown returns a boolean if a field has been set.
func (o *Brand) HasFlowLockdown() bool {
if o != nil && o.FlowLockdown.IsSet() {
return true
}
return false
}
// SetFlowLockdown gets a reference to the given NullableString and assigns it to the FlowLockdown field.
func (o *Brand) SetFlowLockdown(v string) {
o.FlowLockdown.Set(&v)
}
// SetFlowLockdownNil sets the value for FlowLockdown to be an explicit nil
func (o *Brand) SetFlowLockdownNil() {
o.FlowLockdown.Set(nil)
}
// UnsetFlowLockdown ensures that no value is present for FlowLockdown, not even an explicit nil
func (o *Brand) UnsetFlowLockdown() {
o.FlowLockdown.Unset()
}
// GetDefaultApplication returns the DefaultApplication field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *Brand) GetDefaultApplication() string {
if o == nil || IsNil(o.DefaultApplication.Get()) {
@@ -807,9 +763,6 @@ func (o Brand) ToMap() (map[string]interface{}, error) {
if o.FlowDeviceCode.IsSet() {
toSerialize["flow_device_code"] = o.FlowDeviceCode.Get()
}
if o.FlowLockdown.IsSet() {
toSerialize["flow_lockdown"] = o.FlowLockdown.Get()
}
if o.DefaultApplication.IsSet() {
toSerialize["default_application"] = o.DefaultApplication.Get()
}
@@ -880,7 +833,6 @@ func (o *Brand) UnmarshalJSON(data []byte) (err error) {
delete(additionalProperties, "flow_unenrollment")
delete(additionalProperties, "flow_user_settings")
delete(additionalProperties, "flow_device_code")
delete(additionalProperties, "flow_lockdown")
delete(additionalProperties, "default_application")
delete(additionalProperties, "web_certificate")
delete(additionalProperties, "client_certificates")

View File

@@ -38,9 +38,6 @@ const (
PROMPTTYPEENUM_SEPARATOR PromptTypeEnum = "separator"
PROMPTTYPEENUM_HIDDEN PromptTypeEnum = "hidden"
PROMPTTYPEENUM_STATIC PromptTypeEnum = "static"
PROMPTTYPEENUM_ALERT_INFO PromptTypeEnum = "alert_info"
PROMPTTYPEENUM_ALERT_WARNING PromptTypeEnum = "alert_warning"
PROMPTTYPEENUM_ALERT_DANGER PromptTypeEnum = "alert_danger"
PROMPTTYPEENUM_AK_LOCALE PromptTypeEnum = "ak-locale"
)
@@ -63,9 +60,6 @@ var AllowedPromptTypeEnumEnumValues = []PromptTypeEnum{
"separator",
"hidden",
"static",
"alert_info",
"alert_warning",
"alert_danger",
"ak-locale",
}

View File

@@ -71,7 +71,6 @@ pub async fn core_brands_list(
flow_authentication: Option<&str>,
flow_device_code: Option<&str>,
flow_invalidation: Option<&str>,
flow_lockdown: Option<&str>,
flow_recovery: Option<&str>,
flow_unenrollment: Option<&str>,
flow_user_settings: Option<&str>,
@@ -93,7 +92,6 @@ pub async fn core_brands_list(
let p_query_flow_authentication = flow_authentication;
let p_query_flow_device_code = flow_device_code;
let p_query_flow_invalidation = flow_invalidation;
let p_query_flow_lockdown = flow_lockdown;
let p_query_flow_recovery = flow_recovery;
let p_query_flow_unenrollment = flow_unenrollment;
let p_query_flow_user_settings = flow_user_settings;
@@ -156,9 +154,6 @@ pub async fn core_brands_list(
if let Some(ref param_value) = p_query_flow_invalidation {
req_builder = req_builder.query(&[("flow_invalidation", &param_value.to_string())]);
}
if let Some(ref param_value) = p_query_flow_lockdown {
req_builder = req_builder.query(&[("flow_lockdown", &param_value.to_string())]);
}
if let Some(ref param_value) = p_query_flow_recovery {
req_builder = req_builder.query(&[("flow_recovery", &param_value.to_string())]);
}

View File

@@ -78,13 +78,6 @@ pub struct Brand {
skip_serializing_if = "Option::is_none"
)]
pub flow_device_code: Option<Option<uuid::Uuid>>,
#[serde(
rename = "flow_lockdown",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub flow_lockdown: Option<Option<uuid::Uuid>>,
/// When set, external users will be redirected to this application after authenticating.
#[serde(
rename = "default_application",
@@ -129,7 +122,6 @@ impl Brand {
flow_unenrollment: None,
flow_user_settings: None,
flow_device_code: None,
flow_lockdown: None,
default_application: None,
web_certificate: None,
client_certificates: None,

View File

@@ -47,12 +47,6 @@ pub enum PromptTypeEnum {
Hidden,
#[serde(rename = "static")]
Static,
#[serde(rename = "alert_info")]
AlertInfo,
#[serde(rename = "alert_warning")]
AlertWarning,
#[serde(rename = "alert_danger")]
AlertDanger,
#[serde(rename = "ak-locale")]
AkLocale,
}
@@ -77,9 +71,6 @@ impl std::fmt::Display for PromptTypeEnum {
Self::Separator => write!(f, "separator"),
Self::Hidden => write!(f, "hidden"),
Self::Static => write!(f, "static"),
Self::AlertInfo => write!(f, "alert_info"),
Self::AlertWarning => write!(f, "alert_warning"),
Self::AlertDanger => write!(f, "alert_danger"),
Self::AkLocale => write!(f, "ak-locale"),
}
}

View File

@@ -52,7 +52,6 @@ import type {
TransactionApplicationResponse,
UsedBy,
User,
UserAccountLockdownRequest,
UserAccountRequest,
UserConsent,
UserPasswordHashSetRequest,
@@ -103,7 +102,6 @@ import {
TransactionApplicationRequestToJSON,
TransactionApplicationResponseFromJSON,
UsedByFromJSON,
UserAccountLockdownRequestToJSON,
UserAccountRequestToJSON,
UserConsentFromJSON,
UserFromJSON,
@@ -247,7 +245,6 @@ export interface CoreBrandsListRequest {
flowAuthentication?: string;
flowDeviceCode?: string;
flowInvalidation?: string;
flowLockdown?: string;
flowRecovery?: string;
flowUnenrollment?: string;
flowUserSettings?: string;
@@ -406,10 +403,6 @@ export interface CoreUserConsentUsedByListRequest {
id: number;
}
export interface CoreUsersAccountLockdownCreateRequest {
userAccountLockdownRequest?: UserAccountLockdownRequest;
}
export interface CoreUsersCreateRequest {
userRequest: UserRequest;
}
@@ -2221,10 +2214,6 @@ export class CoreApi extends runtime.BaseAPI {
queryParameters["flow_invalidation"] = requestParameters["flowInvalidation"];
}
if (requestParameters["flowLockdown"] != null) {
queryParameters["flow_lockdown"] = requestParameters["flowLockdown"];
}
if (requestParameters["flowRecovery"] != null) {
queryParameters["flow_recovery"] = requestParameters["flowRecovery"];
}
@@ -4200,66 +4189,6 @@ export class CoreApi extends runtime.BaseAPI {
return await response.value();
}
/**
* Creates request options for coreUsersAccountLockdownCreate without sending the request
*/
async coreUsersAccountLockdownCreateRequestOpts(
requestParameters: CoreUsersAccountLockdownCreateRequest,
): Promise<runtime.RequestOpts> {
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/core/users/account_lockdown/`;
return {
path: urlPath,
method: "POST",
headers: headerParameters,
query: queryParameters,
body: UserAccountLockdownRequestToJSON(requestParameters["userAccountLockdownRequest"]),
};
}
/**
* Choose the target account, then return a flow link.
*/
async coreUsersAccountLockdownCreateRaw(
requestParameters: CoreUsersAccountLockdownCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<Link>> {
const requestOptions =
await this.coreUsersAccountLockdownCreateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => LinkFromJSON(jsonValue));
}
/**
* Choose the target account, then return a flow link.
*/
async coreUsersAccountLockdownCreate(
requestParameters: CoreUsersAccountLockdownCreateRequest = {},
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<Link> {
const response = await this.coreUsersAccountLockdownCreateRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for coreUsersCreate without sending the request
*/

View File

@@ -40,12 +40,9 @@ export interface LifecycleIterationsCreateRequest {
lifecycleIterationRequest: LifecycleIterationRequest;
}
export interface LifecycleIterationsListLatestRequest {
export interface LifecycleIterationsLatestRetrieveRequest {
contentType: string;
objectId: string;
ordering?: string;
search?: string;
userIsReviewer?: boolean;
}
export interface LifecycleIterationsListOpenRequest {
@@ -160,39 +157,27 @@ export class LifecycleApi extends runtime.BaseAPI {
}
/**
* Creates request options for lifecycleIterationsListLatest without sending the request
* Creates request options for lifecycleIterationsLatestRetrieve without sending the request
*/
async lifecycleIterationsListLatestRequestOpts(
requestParameters: LifecycleIterationsListLatestRequest,
async lifecycleIterationsLatestRetrieveRequestOpts(
requestParameters: LifecycleIterationsLatestRetrieveRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["contentType"] == null) {
throw new runtime.RequiredError(
"contentType",
'Required parameter "contentType" was null or undefined when calling lifecycleIterationsListLatest().',
'Required parameter "contentType" was null or undefined when calling lifecycleIterationsLatestRetrieve().',
);
}
if (requestParameters["objectId"] == null) {
throw new runtime.RequiredError(
"objectId",
'Required parameter "objectId" was null or undefined when calling lifecycleIterationsListLatest().',
'Required parameter "objectId" was null or undefined when calling lifecycleIterationsLatestRetrieve().',
);
}
const queryParameters: any = {};
if (requestParameters["ordering"] != null) {
queryParameters["ordering"] = requestParameters["ordering"];
}
if (requestParameters["search"] != null) {
queryParameters["search"] = requestParameters["search"];
}
if (requestParameters["userIsReviewer"] != null) {
queryParameters["user_is_reviewer"] = requestParameters["userIsReviewer"];
}
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
@@ -225,27 +210,27 @@ export class LifecycleApi extends runtime.BaseAPI {
/**
* Mixin to validate that a valid enterprise license exists before allowing to save the object
*/
async lifecycleIterationsListLatestRaw(
requestParameters: LifecycleIterationsListLatestRequest,
async lifecycleIterationsLatestRetrieveRaw(
requestParameters: LifecycleIterationsLatestRetrieveRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<Array<LifecycleIteration>>> {
): Promise<runtime.ApiResponse<LifecycleIteration>> {
const requestOptions =
await this.lifecycleIterationsListLatestRequestOpts(requestParameters);
await this.lifecycleIterationsLatestRetrieveRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
jsonValue.map(LifecycleIterationFromJSON),
LifecycleIterationFromJSON(jsonValue),
);
}
/**
* Mixin to validate that a valid enterprise license exists before allowing to save the object
*/
async lifecycleIterationsListLatest(
requestParameters: LifecycleIterationsListLatestRequest,
async lifecycleIterationsLatestRetrieve(
requestParameters: LifecycleIterationsLatestRetrieveRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<Array<LifecycleIteration>> {
const response = await this.lifecycleIterationsListLatestRaw(
): Promise<LifecycleIteration> {
const response = await this.lifecycleIterationsLatestRetrieveRaw(
requestParameters,
initOverrides,
);

View File

@@ -101,6 +101,7 @@ import type {
SCIMSourceUser,
SCIMSourceUserRequest,
SignatureAlgorithmEnum,
SloBindingEnum,
Source,
SourceType,
SyncStatus,
@@ -749,10 +750,13 @@ export interface SourcesSamlListRequest {
policyEngineMode?: PolicyEngineMode;
preAuthenticationFlow?: string;
search?: string;
signAuthnRequest?: boolean;
signLogoutRequest?: boolean;
signatureAlgorithm?: SignatureAlgorithmEnum;
signedAssertion?: boolean;
signedResponse?: boolean;
signingKp?: string;
sloBinding?: SloBindingEnum;
sloUrl?: string;
slug?: string;
ssoUrl?: string;
@@ -7734,6 +7738,14 @@ export class SourcesApi extends runtime.BaseAPI {
queryParameters["search"] = requestParameters["search"];
}
if (requestParameters["signAuthnRequest"] != null) {
queryParameters["sign_authn_request"] = requestParameters["signAuthnRequest"];
}
if (requestParameters["signLogoutRequest"] != null) {
queryParameters["sign_logout_request"] = requestParameters["signLogoutRequest"];
}
if (requestParameters["signatureAlgorithm"] != null) {
queryParameters["signature_algorithm"] = requestParameters["signatureAlgorithm"];
}
@@ -7750,6 +7762,10 @@ export class SourcesApi extends runtime.BaseAPI {
queryParameters["signing_kp"] = requestParameters["signingKp"];
}
if (requestParameters["sloBinding"] != null) {
queryParameters["slo_binding"] = requestParameters["sloBinding"];
}
if (requestParameters["sloUrl"] != null) {
queryParameters["slo_url"] = requestParameters["sloUrl"];
}

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