Compare commits

..

3 Commits

Author SHA1 Message Date
Teffen Ellis
9e77dd4070 Update Makefile
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-05-04 19:22:51 +02:00
Teffen Ellis
634c58f753 lifecycle: default MODE_FILE to /tmp when TMPDIR is unset
Line 6 already uses \${TMPDIR:-/tmp} for PROMETHEUS_MULTIPROC_DIR;
apply the same fallback to MODE_FILE so \`ak server\` works outside
a container where TMPDIR is not exported.
2026-04-21 02:39:21 +00:00
Teffen Ellis
1036844a1a core,website/docs: add rust-install make target, document Cargo prerequisites
Cargo tools required by `make lint` (cargo-deny, cargo-machete,
cargo-llvm-cov, cargo-nextest) were silently absent from local dev
environments since PR #20983 introduced the Rust workspace in March 2026.
CI installs them via taiki-e/install-action but no local equivalent existed,
causing `make` to fail on `ci-lint-cargo-machete` with "no such command".

Adds a `rust-install` target that runs `cargo install` for all four tools
and wires it into the existing `install` target alongside node-install,
docs-install, and core-install. Updates the Rust prerequisite bullet in
full-dev-environment.mdx to name the tools and point to `make install`.

Trade-off vs CI: `cargo install` compiles from source, so this is slower
than the pre-built binaries taiki-e/install-action fetches. It is a
one-time setup cost and requires no additional tooling beyond a standard
Rust installation.

core: add --locked to rust-install cargo invocation

cargo-nextest requires --locked when installing from source; apply it to
the whole invocation for reproducible builds across all four tools.
2026-04-21 01:50:36 +00:00
364 changed files with 4929 additions and 30649 deletions

View File

@@ -1,5 +1,5 @@
[alias]
t = ["nextest", "run", "--workspace"]
t = ["nextest", "run"]
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -1,6 +1,5 @@
[licenses]
allow = [
"Apache-2.0 WITH LLVM-exception",
"Apache-2.0",
"BSD-3-Clause",
"CC0-1.0",

View File

@@ -64,12 +64,12 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
uses: taiki-e/install-action@58e862542551f667fa44c8a2a4a1d64ad477c96a # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
with:
node-version-file: "${{ inputs.working-directory }}web/package.json"
cache: "npm"
@@ -77,7 +77,7 @@ runs:
registry-url: "https://registry.npmjs.org"
- name: Setup node (root)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
with:
node-version-file: "${{ inputs.working-directory }}package.json"
cache: "npm"

View File

@@ -2,7 +2,7 @@ services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}
volumes:
- db-data:/var/lib/postgresql
- db-data:/var/lib/postgresql/data
command: "-c log_statement=all"
environment:
POSTGRES_USER: authentik

View File

@@ -66,14 +66,6 @@ updates:
default-days: 7
semver-major-days: 14
semver-patch-days: 3
exclude:
- aws-lc-fips-sys
- aws-lc-rs
- aws-lc-sys
- rustls
- rustls-pki-types
- rustls-platform-verifier
- rustls-webpki
- package-ecosystem: rust-toolchain
directory: "/"

View File

@@ -90,7 +90,7 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # v2
- uses: int128/docker-manifest-create-action@44422a4b046d55dc036df622039ed3aec43c613c # v2
id: build
with:
tags: ${{ matrix.tag }}

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -71,7 +71,7 @@ jobs:
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -53,7 +53,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

@@ -127,10 +127,7 @@ jobs:
with:
postgresql_version: ${{ matrix.psql }}
- name: run migrations to stable
run: |
docker ps
docker logs setup-postgresql-1
uv run python -m lifecycle.migrate
run: uv run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x

View File

@@ -145,7 +145,7 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

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

View File

@@ -38,7 +38,7 @@ jobs:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
id: compress
uses: calibreapp/image-actions@e2cc8db5d49c849e00844dfebf01438318e96fa2 # main
uses: calibreapp/image-actions@4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 # main
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}

View File

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

View File

@@ -87,7 +87,7 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -151,7 +151,7 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -14,7 +14,6 @@ pyproject.toml @goauthentik/backend
uv.lock @goauthentik/backend
Cargo.toml @goauthentik/backend
Cargo.lock @goauthentik/backend
build.rs @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
.cargo/ @goauthentik/backend

360
Cargo.lock generated
View File

@@ -17,18 +17,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -118,37 +106,6 @@ dependencies = [
"rustversion",
]
[[package]]
name = "argh"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
dependencies = [
"argh_derive",
"argh_shared",
]
[[package]]
name = "argh_derive"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
dependencies = [
"argh_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "argh_shared"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
dependencies = [
"serde",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -181,30 +138,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "authentik"
version = "2026.5.0-rc1"
dependencies = [
"arc-swap",
"argh",
"authentik-axum",
"authentik-common",
"axum",
"color-eyre",
"eyre",
"hyper-unix-socket",
"hyper-util",
"metrics",
"metrics-exporter-prometheus",
"nix 0.31.2",
"pyo3",
"pyo3-build-config",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "authentik-axum"
version = "2026.5.0-rc1"
@@ -217,7 +150,6 @@ dependencies = [
"eyre",
"forwarded-header-value",
"futures",
"pin-project-lite",
"tokio",
"tokio-rustls",
"tower",
@@ -299,9 +231,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-fips-sys",
"aws-lc-sys",
@@ -311,9 +243,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
@@ -323,9 +255,9 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.9"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"axum-macros",
@@ -379,9 +311,9 @@ dependencies = [
[[package]]
name = "axum-macros"
version = "0.5.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
@@ -578,9 +510,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.1"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
@@ -600,9 +532,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.1"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
@@ -635,33 +567,6 @@ dependencies = [
"cc",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -823,15 +728,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -1081,12 +977,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1319,7 +1209,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
"foldhash",
]
[[package]]
@@ -1327,9 +1217,6 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
@@ -1456,9 +1343,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
@@ -1471,6 +1358,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1505,20 +1393,6 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-unix-socket"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88978f1d73da0eb87d86555fcc40cbdd87bc86eb6525710b89db8c9278ec6a59"
dependencies = [
"bytes",
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1976,46 +1850,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
dependencies = [
"ahash",
"portable-atomic",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
dependencies = [
"base64 0.22.1",
"indexmap",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
]
[[package]]
name = "metrics-util"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.2",
"rand_xoshiro",
"sketches-ddsketch",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2147,7 +1981,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.6",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@@ -2399,12 +2233,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "parking"
version = "2.2.1"
@@ -2481,6 +2309,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs1"
version = "0.7.5"
@@ -2514,12 +2348,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2595,79 +2423,6 @@ dependencies = [
"prost",
]
[[package]]
name = "pyo3"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2747,9 +2502,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -2804,24 +2559,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3000,9 +2737,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.39"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
@@ -3065,9 +2802,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3414,12 +3151,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3584,7 +3315,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.6",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -3625,7 +3356,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand 0.8.6",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@@ -3745,12 +3476,6 @@ dependencies = [
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3873,9 +3598,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [
"bytes",
"libc",
@@ -3933,9 +3658,9 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
@@ -4144,9 +3869,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
@@ -4156,6 +3881,7 @@ dependencies = [
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
@@ -4265,6 +3991,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -4285,9 +4017,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",

View File

@@ -20,13 +20,11 @@ publish = false
[workspace.dependencies]
arc-swap = "= 1.9.1"
argh = "= 0.1.19"
axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] }
aws-lc-rs = { version = "= 1.16.3", features = ["fips"] }
axum = { version = "= 0.8.9", features = ["http2", "macros", "ws"] }
clap = { version = "= 4.6.1", features = ["derive", "env"] }
aws-lc-rs = { version = "= 1.16.2", features = ["fips"] }
axum = { version = "= 0.8.8", features = ["http2", "macros", "ws"] }
clap = { version = "= 4.6.0", features = ["derive", "env"] }
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
color-eyre = "= 0.6.5"
colored = "= 3.1.1"
config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [
"json",
@@ -39,17 +37,11 @@ eyre = "= 0.6.12"
forwarded-header-value = "= 0.1.1"
futures = "= 0.3.32"
glob = "= 0.3.3"
hyper-unix-socket = "= 0.6.1"
hyper-util = "= 0.1.20"
ipnet = { version = "= 2.12.0", features = ["serde"] }
json-subscriber = "= 0.2.8"
metrics = "= 0.24.3"
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
nix = { version = "= 0.31.2", features = ["signal"] }
notify = "= 8.2.0"
pin-project-lite = "= 0.2.17"
pyo3 = "= 0.28.3"
pyo3-build-config = "= 0.28.3"
regex = "= 1.12.3"
reqwest = { version = "= 0.13.2", features = [
"form",
@@ -66,7 +58,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"query",
"rustls",
] }
rustls = { version = "= 0.23.39", features = ["fips"] }
rustls = { version = "= 0.23.37", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
"backtrace",
"contexts",
@@ -97,7 +89,7 @@ sqlx = { version = "= 0.8.6", default-features = false, features = [
tempfile = "= 3.27.0"
thiserror = "= 2.0.18"
time = { version = "= 0.3.47", features = ["macros"] }
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
tokio = { version = "= 1.51.1", features = ["full", "tracing"] }
tokio-retry2 = "= 0.9.1"
tokio-rustls = "= 0.26.4"
tokio-util = { version = "= 0.7.18", features = ["full"] }
@@ -112,12 +104,18 @@ tracing-subscriber = { version = "= 0.3.23", features = [
"tracing-log",
] }
url = "= 2.5.8"
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
uuid = { version = "= 1.23.0", features = ["serde", "v4"] }
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false }
[profile.dev.package.backtrace]
opt-level = 3
[profile.release]
lto = true
debug = 2
[workspace.lints.rust]
ambiguous_negative_literals = "warn"
closure_returning_async_block = "warn"
@@ -231,57 +229,3 @@ unused_trait_names = "warn"
unwrap_in_result = "warn"
unwrap_used = "warn"
verbose_file_reads = "warn"
[profile.dev.package.backtrace]
opt-level = 3
[profile.dev]
panic = "abort"
[profile.release]
debug = 2
lto = "fat"
# Because of the async runtime, we want to die straightaway if we panic.
panic = "abort"
strip = true
[package]
name = "authentik"
version.workspace = true
authors.workspace = true
edition.workspace = true
readme.workspace = true
homepage.workspace = true
repository.workspace = true
license-file.workspace = true
publish.workspace = true
[features]
default = ["core", "proxy"]
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
proxy = ["ak-common/proxy"]
[build-dependencies]
pyo3-build-config.workspace = true
[dependencies]
ak-axum.workspace = true
ak-common.workspace = true
arc-swap.workspace = true
argh.workspace = true
axum.workspace = true
color-eyre.workspace = true
eyre.workspace = true
hyper-unix-socket.workspace = true
hyper-util.workspace = true
metrics.workspace = true
metrics-exporter-prometheus.workspace = true
nix.workspace = true
pyo3 = { workspace = true, optional = true }
sqlx = { workspace = true, optional = true }
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[lints]
workspace = true

View File

@@ -115,9 +115,6 @@ run-server: ## Run the main authentik server process
run-worker: ## Run the main authentik worker process
$(UV) run ak worker
run-worker-watch: ## Run the authentik worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
core-i18n-extract:
$(UV) run ak makemessages \
--add-location file \
@@ -128,7 +125,10 @@ core-i18n-extract:
--ignore website \
-l en
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
rust-install: ## Install required Cargo tools
$(CARGO) install --locked cargo-deny cargo-machete cargo-llvm-cov cargo-nextest
install: node-install docs-install core-install rust-install ## Install all requires dependencies for `node`, `docs`, `core`, and `rust`
dev-drop-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))

View File

@@ -1,7 +1,7 @@
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from tempfile import SpooledTemporaryFile
from urllib.parse import urlsplit, urlunsplit
from urllib.parse import urlsplit
import boto3
from botocore.config import Config
@@ -164,19 +164,16 @@ class S3Backend(ManageableBackend):
)
def _file_url(name: str, request: HttpRequest | None) -> str:
client = self.client
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
operation_name = "GetObject"
operation_model = client.meta.service_model.operation_model(operation_name)
request_dict = client._convert_to_request_dict(
params,
operation_model,
endpoint_url=client.meta.endpoint_url,
context={"is_presign_request": True},
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in,
HttpMethod="GET",
)
# Support custom domain for S3-compatible storage (so not AWS)
@@ -186,8 +183,9 @@ class S3Backend(ManageableBackend):
CONFIG.get(f"storage.{self.name}.custom_domain", None),
)
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
path = request_dict["url_path"]
path = parsed.path
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom_domain must
@@ -202,22 +200,9 @@ class S3Backend(ManageableBackend):
if not path.startswith("/"):
path = f"/{path}"
custom_base = urlsplit(f"{scheme}://{custom_domain}")
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
# Sign the final public URL instead of signing the internal S3 endpoint and
# rewriting it afterwards. Presigned SigV4 URLs include the host header in the
# canonical request, so post-sign host changes break strict backends like RustFS.
public_path = f"{custom_base.path.rstrip('/')}{path}" if custom_base.path else path
request_dict["url_path"] = public_path
request_dict["url"] = urlunsplit(
(custom_base.scheme, custom_base.netloc, public_path, "", "")
)
return client._request_signer.generate_presigned_url(
request_dict,
operation_name,
expires_in=expires_in,
)
return url
if use_cache:
return self._cache_get_or_set(name, request, _file_url, expires_in)

View File

@@ -1,5 +1,4 @@
from unittest import skipUnless
from urllib.parse import parse_qs, urlsplit
from botocore.exceptions import UnsupportedSignatureVersionError
from django.test import TestCase
@@ -169,44 +168,6 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
f"URL: {url}",
)
@CONFIG.patch("storage.s3.secure_urls", False)
@CONFIG.patch("storage.s3.addressing_style", "path")
def test_file_url_custom_domain_resigns_for_custom_host(self):
"""Test presigned URLs are signed for the custom domain host.
Host-changing custom domains must produce a signature query string for
the public host, not reuse the internal endpoint signature.
"""
bucket_name = self.media_s3_bucket_name
key_name = "application-icons/test.svg"
custom_domain = f"files.example.test:8020/{bucket_name}"
endpoint_signed_url = self.media_s3_backend.client.generate_presigned_url(
"get_object",
Params={
"Bucket": bucket_name,
"Key": f"{self.media_s3_backend.base_path}/{key_name}",
},
ExpiresIn=900,
HttpMethod="GET",
)
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
custom_url = self.media_s3_backend.file_url(key_name, use_cache=False)
endpoint_parts = urlsplit(endpoint_signed_url)
custom_parts = urlsplit(custom_url)
self.assertEqual(custom_parts.scheme, "http")
self.assertEqual(custom_parts.netloc, "files.example.test:8020")
self.assertEqual(parse_qs(custom_parts.query)["X-Amz-SignedHeaders"], ["host"])
self.assertNotEqual(
custom_parts.query,
endpoint_parts.query,
"Custom-domain URLs must be signed for the public host, not reuse the endpoint "
"signature query string.",
)
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
result = self.media_s3_backend.themed_urls("logo.png")

View File

@@ -1,6 +1,5 @@
"""Apply blueprint from commandline"""
from argparse import ArgumentParser
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
@@ -32,5 +31,5 @@ class Command(BaseCommand):
sys_exit(1)
importer.apply()
def add_arguments(self, parser: ArgumentParser):
def add_arguments(self, parser):
parser.add_argument("blueprints", nargs="+", type=str)

View File

@@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
GRANT_TYPE_IMPLICIT = "implicit"
GRANT_TYPE_HYBRID = "hybrid"
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
GRANT_TYPE_PASSWORD = "password" # nosec

View File

@@ -30,8 +30,6 @@ SAML_BINDING_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
DEFAULT_ISSUER = "authentik"
DSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"
RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
# https://datatracker.ietf.org/doc/html/rfc4051#section-2.3.2

View File

@@ -1,43 +0,0 @@
"""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

@@ -36,13 +36,9 @@ from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
def user_app_cache_key(
user_pk: str, page_number: int | None = None, only_with_launch_url: bool = False
) -> str:
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}"
if only_with_launch_url:
key += "/launch"
if page_number:
key += f"/{page_number}"
return key
@@ -120,7 +116,6 @@ class ApplicationSerializer(ModelSerializer):
"meta_publisher",
"policy_engine_mode",
"group",
"meta_hide",
]
extra_kwargs = {
"backchannel_providers": {"required": False},
@@ -279,17 +274,11 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if superuser_full_list and request.user.is_superuser:
return super().list(request)
only_with_launch_url = (
str(request.query_params.get("only_with_launch_url", "false")).lower()
) == "true"
only_with_launch_url = str(
request.query_params.get("only_with_launch_url", "false")
).lower()
queryset = self._filter_queryset_for_list(self.get_queryset())
queryset = queryset.exclude(meta_hide=True)
if only_with_launch_url:
# Pre-filter at DB level to skip expensive per-app policy evaluation
# for apps that can never appear in the launcher (no meta_launch_url
# and no provider, so no possible launch URL).
queryset = queryset.exclude(meta_launch_url="", provider__isnull=True)
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)
@@ -306,6 +295,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
except ValueError as exc:
raise ValidationError from exc
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
allowed_applications = self._expand_applications(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)
@@ -315,26 +305,19 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
allowed_applications = self._get_allowed_applications(paginated_apps)
if should_cache:
allowed_applications = cache.get(
user_app_cache_key(
self.request.user.pk, paginator.page.number, only_with_launch_url
)
user_app_cache_key(self.request.user.pk, paginator.page.number)
)
if allowed_applications:
# Re-fetch cached applications since pickled instances lose prefetched
# relationships, causing N+1 queries during serialization
allowed_applications = self._expand_applications(allowed_applications)
else:
if not allowed_applications:
LOGGER.debug("Caching allowed application list", page=paginator.page.number)
allowed_applications = self._get_allowed_applications(paginated_apps)
cache.set(
user_app_cache_key(
self.request.user.pk, paginator.page.number, only_with_launch_url
),
user_app_cache_key(self.request.user.pk, paginator.page.number),
allowed_applications,
timeout=86400,
)
allowed_applications = self._expand_applications(allowed_applications)
if only_with_launch_url:
if only_with_launch_url == "true":
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)

View File

@@ -19,7 +19,7 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError
@@ -37,77 +37,6 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
class BulkManyRelatedField(ManyRelatedField):
"""ManyRelatedField that validates all PKs in a single query instead of one per PK."""
def to_internal_value(self, data):
if isinstance(data, str) or not hasattr(data, "__iter__"):
self.fail("not_a_list", input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail("empty")
child = self.child_relation
pk_field = child.pk_field
# Coerce PKs through pk_field if defined
pk_map = {}
for item in data:
if isinstance(item, bool):
self.fail("incorrect_type", data_type=type(item).__name__)
pk = pk_field.to_internal_value(item) if pk_field else item
pk_map[pk] = item # map coerced PK -> original value for error reporting
queryset = child.get_queryset()
# Use count to validate all PKs exist in a single query
found_count = queryset.filter(pk__in=pk_map.keys()).count()
if found_count < len(pk_map):
# Some PKs not found — fall back to per-PK checks for error reporting.
# This only runs when there's an actual validation error (rare path).
for pk, original in pk_map.items():
if not queryset.filter(pk=pk).exists():
child.fail("does_not_exist", pk_value=original)
# Return raw PKs — Django's M2M set() accepts both objects and PKs,
# using get_prep_value() for type coercion. This avoids loading all
# objects into memory and avoids triggering post_init signals.
return list(pk_map.keys())
def to_representation(self, iterable):
# For non-prefetched querysets, get PKs directly without loading model instances.
# When prefetched, _result_cache is a list (possibly empty); when not, it's None.
if hasattr(iterable, "values_list") and getattr(iterable, "_result_cache", None) is None:
return list(iterable.values_list("pk", flat=True))
return super().to_representation(iterable)
class BulkPrimaryKeyRelatedField(PrimaryKeyRelatedField):
"""PrimaryKeyRelatedField that uses bulk validation when many=True."""
@classmethod
def many_init(cls, *args, **kwargs):
allow_empty = kwargs.pop("allow_empty", None)
max_length = kwargs.pop("max_length", None)
min_length = kwargs.pop("min_length", None)
child_relation = cls(*args, **kwargs)
list_kwargs = {
"child_relation": child_relation,
}
if allow_empty is not None:
list_kwargs["allow_empty"] = allow_empty
if max_length is not None:
list_kwargs["max_length"] = max_length
if min_length is not None:
list_kwargs["min_length"] = min_length
list_kwargs.update(
{
key: value
for key, value in kwargs.items()
if key in ("required", "default", "source")
}
)
return BulkManyRelatedField(**list_kwargs)
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
"pk",
"username",
@@ -150,7 +79,6 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users = BulkPrimaryKeyRelatedField(queryset=User.objects.all(), many=True, default=list)
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
parents_obj = SerializerMethodField(allow_null=True)
children_obj = SerializerMethodField(allow_null=True)
@@ -265,6 +193,9 @@ class GroupSerializer(ModelSerializer):
"children_obj",
]
extra_kwargs = {
"users": {
"default": list,
},
"children": {
"required": False,
"default": list,
@@ -294,7 +225,6 @@ class GroupFilter(FilterSet):
members_by_pk = ModelMultipleChoiceFilter(
field_name="users",
queryset=User.objects.all(),
distinct=False,
)
def filter_attributes(self, queryset, name, value):
@@ -346,8 +276,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
]
def get_queryset(self):
# Always prefetch parents and children since their PKs are always serialized
base_qs = Group.objects.all().prefetch_related("roles", "parents", "children")
base_qs = Group.objects.all().prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
@@ -358,9 +287,16 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
)
)
# When include_users=false, skip users prefetch entirely.
# BulkManyRelatedField.to_representation will use values_list to get PKs
# directly without loading User instances into memory.
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))
)
if self.serializer_class(context={"request": self.request})._should_include_children:
base_qs = base_qs.prefetch_related("children")
if self.serializer_class(context={"request": self.request})._should_include_parents:
base_qs = base_qs.prefetch_related("parents")
return base_qs

View File

@@ -6,7 +6,6 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import AnonymousUser, Permission
from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django.urls import reverse_lazy
@@ -14,7 +13,6 @@ from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django_filters.filters import (
BooleanFilter,
CharFilter,
@@ -107,10 +105,6 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy(
"Invalid password hash format. Must be a valid Django password hash."
)
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
@@ -137,7 +131,7 @@ class PartialGroupSerializer(ModelSerializer):
class UserSerializer(ModelSerializer):
"""User Serializer"""
is_superuser = SerializerMethodField()
is_superuser = BooleanField(read_only=True)
avatar = SerializerMethodField()
attributes = JSONDictField(required=False)
groups = PrimaryKeyRelatedField(
@@ -174,14 +168,6 @@ class UserSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_roles", "true")).lower() == "true"
@extend_schema_field(BooleanField)
def get_is_superuser(self, instance: User) -> bool:
"""Use annotation if available to avoid N+1 query"""
ann = getattr(instance, "_annotated_is_superuser", None)
if ann is not None:
return ann
return instance.is_superuser
@extend_schema_field(PartialGroupSerializer(many=True))
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
if not self._should_include_groups:
@@ -195,79 +181,47 @@ class UserSerializer(ModelSerializer):
return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs):
"""Setting password and permissions directly is allowed only in blueprints."""
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["password_hash"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False,
child=ChoiceField(choices=get_permission_choices()),
)
def create(self, validated_data: dict) -> User:
"""Create a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
"""If this serializer is used in the blueprint context, we allow for
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def update(self, instance: User, validated_data: dict) -> User:
"""Update a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance = super().update(instance, validated_data)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def _validate_password_inputs(self, password: str | None, password_hash: str | None):
"""Validate mutually-exclusive password inputs before any model mutation."""
if password is not None and password_hash is not None:
raise ValidationError(_("Cannot set both password and password_hash. Use only one."))
if password_hash is None:
return
try:
User.validate_password_hash(password_hash)
except ValueError as exc:
LOGGER.warning("Failed to identify password hash format", exc_info=exc)
raise ValidationError(INVALID_PASSWORD_HASH_MESSAGE) from exc
def _set_password(self, instance: User, password: str | None, password_hash: str | None = None):
"""Set password from plain text or hash."""
if password_hash is not None:
instance.set_password_from_hash(password_hash)
instance.save()
elif password:
def _set_password(self, instance: User, password: str | None):
"""Set password of user if we're in a blueprint context, and if it's an empty
string then use an unusable password"""
if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password:
instance.set_password(password)
instance.save()
def _ensure_password_not_empty(self, instance: User):
"""Store an explicit unusable password instead of an empty password field."""
if len(instance.password) == 0:
instance.set_unusable_password()
instance.save()
@@ -436,12 +390,6 @@ class UserPasswordSetSerializer(PassiveSerializer):
password = CharField(required=True)
class UserPasswordHashSetSerializer(PassiveSerializer):
"""Payload to set a users' password hash directly"""
password = CharField(required=True)
class UserServiceAccountSerializer(PassiveSerializer):
"""Payload to create a service account"""
@@ -593,30 +541,10 @@ class UserViewSet(
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
# Always prefetch groups since group PKs are always serialized.
# Use full prefetch when include_groups=true (for groups_obj), ID-only otherwise.
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("groups")
else:
base_qs = base_qs.prefetch_related(
Prefetch("groups", queryset=Group.objects.all().only("group_uuid"))
)
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
else:
base_qs = base_qs.prefetch_related(
Prefetch("roles", queryset=Role.objects.all().only("uuid"))
)
# Annotate is_superuser to avoid N+1 query per user
base_qs = base_qs.annotate(
_annotated_is_superuser=Exists(
Group.objects.filter(
is_superuser=True,
).filter(
Q(users=OuterRef("pk")) | Q(descendant_nodes__descendant__users=OuterRef("pk"))
)
)
)
return base_qs
@extend_schema(
@@ -785,11 +713,6 @@ class UserViewSet(
self.request.session.modified = True
return Response(serializer.initial_data)
def _update_session_hash_after_password_change(self, request: Request, user: User):
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordSetSerializer,
@@ -813,45 +736,9 @@ class UserViewSet(
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)
return Response(status=400)
self._update_session_hash_after_password_change(request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordHashSetSerializer,
responses={
204: OpenApiResponse(description="Successfully changed password"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
methods=["POST"],
permission_classes=[IsAuthenticated],
)
@validate(UserPasswordHashSetSerializer)
def set_password_hash(
self, request: Request, pk: int, body: UserPasswordHashSetSerializer
) -> Response:
"""Set a user's password from a pre-hashed Django password value.
Submit the Django password hash in the shared ``password`` request field.
This updates authentik's local password verifier only. It does not attempt
to propagate the password change to LDAP or Kerberos because no raw password
is available from the request payload.
"""
user: User = self.get_object()
try:
user.set_password_from_hash(body.validated_data["password"], request=request)
user.save()
except ValueError as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(data={"password": [INVALID_PASSWORD_HASH_MESSAGE]}, status=400)
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(status=400)
self._update_session_hash_after_password_change(request, user)
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")

View File

@@ -7,12 +7,6 @@ from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tenants.flags import Flag
class Setup(Flag[bool], key="setup"):
default = False
visibility = "system"
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
default = True
@@ -32,10 +26,6 @@ class AuthentikCoreConfig(ManagedAppConfig):
mountpoint = ""
default = True
def import_related(self):
super().import_related()
self.import_module("authentik.core.setup.signals")
@ManagedAppConfig.reconcile_tenant
def source_inbuilt(self):
"""Reconcile inbuilt source"""

View File

@@ -1,28 +0,0 @@
"""Hash password using Django's password hashers"""
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
"""Hash a password using Django's password hashers"""
help = "Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"
def add_arguments(self, parser):
parser.add_argument(
"password",
type=str,
help="Password to hash",
)
def handle(self, *args, **options):
password = options["password"]
if not password:
raise CommandError("Password cannot be empty")
try:
hashed = make_password(password)
self.stdout.write(hashed)
except ValueError as exc:
raise CommandError(f"Error hashing password: {exc}") from exc

View File

@@ -1,61 +0,0 @@
# Generated by Django 5.2.13 on 2026-04-21 18:49
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db import migrations
def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from django.conf import settings
from authentik.flows.models import FlowAuthenticationRequirement
VersionHistory = apps.get_model("authentik_admin", "VersionHistory")
Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
# Upgrading from a previous version
if not settings.TEST and VersionHistory.objects.using(db_alias).count() > 1:
return True
# OOBE flow sets itself to this authentication requirement once finished
if (
Flow.objects.using(db_alias)
.filter(
slug="initial-setup", authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
)
.exists()
):
return True
# non-akadmin and non-guardian anonymous user exist
if (
User.objects.using(db_alias)
.exclude(username="akadmin")
.exclude(username="AnonymousUser")
.exists()
):
return True
return False
def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.core.apps import Setup
from authentik.tenants.utils import get_current_tenant
is_already_setup = check_is_already_setup(apps, schema_editor)
if is_already_setup:
tenant = get_current_tenant()
tenant.flags[Setup().key] = True
tenant.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
# 0024_flow_authentication adds the `authentication` field.
("authentik_flows", "0024_flow_authentication"),
]
operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-09 18:04
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_blank_launch_url(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Application = apps.get_model("authentik_core", "Application")
Application.objects.using(db_alias).filter(meta_launch_url="blank://blank").update(
meta_hide=True, meta_launch_url=""
)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0058_setup"),
]
operations = [
migrations.AddField(
model_name="application",
name="meta_hide",
field=models.BooleanField(
default=False,
help_text="Hide this application from the user's My applications page.",
),
),
migrations.RunPython(migrate_blank_launch_url, migrations.RunPython.noop),
]

View File

@@ -10,7 +10,7 @@ from uuid import uuid4
import pgtrigger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password, identify_hasher
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
@@ -560,33 +560,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
self.password_change_date = now()
return super().set_password(raw_password)
@staticmethod
def validate_password_hash(password_hash: str):
"""Validate that the value is a recognized Django password hash."""
identify_hasher(password_hash) # Raises ValueError if invalid
def set_password_from_hash(self, password_hash: str, signal=True, sender=None, request=None):
"""Set password directly from a pre-hashed value.
Unlike set_password(), this does not hash the input again. The provided value
must already be a valid Django password hash, and it is stored directly on the
user after validation.
Because no raw password is available, downstream password sync integrations
such as LDAP and Kerberos cannot be updated from this code path.
Raises ValueError if the hash format is not recognized.
"""
self.validate_password_hash(password_hash)
if self.pk and signal:
from authentik.core.signals import password_hash_changed
if not sender:
sender = self
password_hash_changed.send(sender=sender, user=self, request=request)
self.password = password_hash
self.password_change_date = now()
def check_password(self, raw_password: str) -> bool:
"""
Return a boolean of whether the raw_password was correct. Handles
@@ -762,9 +735,6 @@ class Application(SerializerModel, PolicyBindingModel):
meta_icon = FileField(default="", blank=True)
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
meta_hide = models.BooleanField(
default=False, help_text=_("Hide this application from the user's My applications page.")
)
objects = ApplicationQuerySet.as_manager()
@@ -820,13 +790,9 @@ class Application(SerializerModel, PolicyBindingModel):
def get_provider(self) -> Provider | None:
"""Get casted provider instance. Needs Application queryset with_provider"""
if hasattr(self, "_cached_provider"):
return self._cached_provider
if not self.provider:
self._cached_provider = None
return None
self._cached_provider = get_deepest_child(self.provider)
return self._cached_provider
return get_deepest_child(self.provider)
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
"""Get Backchannel provider for a specific type"""

View File

@@ -1,42 +0,0 @@
from os import getenv
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.core.apps import Setup
from authentik.root.signals import post_startup
from authentik.tenants.models import Tenant
BOOTSTRAP_BLUEPRINT = "system/bootstrap.yaml"
LOGGER = get_logger()
@receiver(post_startup)
def post_startup_setup_bootstrap(sender, **_):
if (
not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD")
and not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH")
and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN")
):
return
LOGGER.info("Configuring authentik through bootstrap environment variables")
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
# sync, so that we can sure the first start actually has working bootstrap
# credentials
for tenant in Tenant.objects.filter(ready=True):
if Setup.get(tenant=tenant):
LOGGER.info("Tenant is already setup, skipping", tenant=tenant.schema_name)
continue
with tenant:
importer = Importer.from_string(content)
valid, logs = importer.validate()
if not valid:
LOGGER.warning("Blueprint invalid", tenant=tenant.schema_name)
for log in logs:
log.log()
importer.apply()
Setup.set(True, tenant=tenant)

View File

@@ -1,80 +0,0 @@
from functools import lru_cache
from http import HTTPMethod, HTTPStatus
from django.contrib.staticfiles import finders
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.core.apps import Setup
from authentik.flows.models import Flow, FlowAuthenticationRequirement, in_memory_stage
from authentik.flows.planner import FlowPlanner
from authentik.flows.stage import StageView
LOGGER = get_logger()
FLOW_CONTEXT_START_BY = "goauthentik.io/core/setup/started-by"
@lru_cache
def read_static(path: str) -> str | None:
result = finders.find(path)
if not result:
return None
with open(result, encoding="utf8") as _file:
return _file.read()
class SetupView(View):
setup_flow_slug = "initial-setup"
def dispatch(self, request: HttpRequest, *args, **kwargs):
if request.method != HTTPMethod.HEAD and Setup.get():
return redirect(reverse("authentik_core:root-redirect"))
return super().dispatch(request, *args, **kwargs)
def head(self, request: HttpRequest, *args, **kwargs):
if Setup.get():
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
if not Flow.objects.filter(slug=self.setup_flow_slug).exists():
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
return HttpResponse(status=HTTPStatus.OK)
def get(self, request: HttpRequest):
flow = Flow.objects.filter(slug=self.setup_flow_slug).first()
if not flow:
LOGGER.info("Setup flow does not exist yet, waiting for worker to finish")
return HttpResponse(
read_static("dist/standalone/loading/startup.html"),
status=HTTPStatus.SERVICE_UNAVAILABLE,
)
planner = FlowPlanner(flow)
plan = planner.plan(request, {FLOW_CONTEXT_START_BY: "setup"})
plan.append_stage(in_memory_stage(PostSetupStageView))
return plan.to_redirect(request, flow)
class PostSetupStageView(StageView):
"""Run post-setup tasks"""
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Wrapper when this stage gets hit with a post request"""
return self.get(request, *args, **kwargs)
def get(self, requeset: HttpRequest, *args, **kwargs):
with transaction.atomic():
# Remember we're setup
Setup.set(True)
# Disable OOBE Blueprints
BlueprintInstance.objects.filter(
**{"metadata__labels__blueprints.goauthentik.io/system-oobe": "true"}
).update(enabled=False)
# Make flow inaccessible
Flow.objects.filter(slug="initial-setup").update(
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
)
return self.executor.stage_ok()

View File

@@ -24,8 +24,6 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
# Arguments: user: User, request: HttpRequest | None
password_hash_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any]
login_failed = Signal()

View File

@@ -129,7 +129,6 @@ class TestApplicationsAPI(APITestCase):
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_hide": False,
"meta_publisher": "",
"policy_engine_mode": "any",
},
@@ -188,14 +187,12 @@ class TestApplicationsAPI(APITestCase):
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_hide": False,
"meta_publisher": "",
"policy_engine_mode": "any",
},
{
"launch_url": None,
"meta_description": "",
"meta_hide": False,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,

View File

@@ -1,28 +0,0 @@
"""Tests for hash_password management command."""
from io import StringIO
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
class TestHashPasswordCommand(TestCase):
"""Test hash_password management command."""
def test_hash_password(self):
"""Test hashing a password."""
out = StringIO()
call_command("hash_password", "test123", stdout=out)
hashed = out.getvalue().strip()
self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password("test123", hashed))
def test_hash_password_empty_fails(self):
"""Test that empty password raises error."""
with self.assertRaises(CommandError) as ctx:
call_command("hash_password", "")
self.assertIn("Password cannot be empty", str(ctx.exception))

View File

@@ -4,7 +4,6 @@ from django.test import TestCase
from django.urls import reverse
from authentik.brands.models import Brand
from authentik.core.apps import Setup
from authentik.core.models import Application, UserTypes
from authentik.core.tests.utils import create_test_brand, create_test_user
@@ -13,7 +12,6 @@ class TestInterfaceRedirects(TestCase):
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
def setUp(self):
Setup.set(True)
self.app = Application.objects.create(name="test-app", slug="test-app")
self.brand: Brand = create_test_brand(default_application=self.app)

View File

@@ -1,174 +0,0 @@
from http import HTTPStatus
from os import environ
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.apps import Setup
from authentik.core.models import Token, TokenIntents, User
from authentik.flows.models import Flow
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.root.signals import post_startup, pre_startup
from authentik.tenants.flags import patch_flag
class TestSetup(FlowTestCase):
def tearDown(self):
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH", None)
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
@patch_flag(Setup, True)
def test_setup(self):
"""Test existing instance"""
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_flows:default-authentication") + "?next=/",
fetch_redirect_response=False,
)
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:root-redirect"),
fetch_redirect_response=False,
)
@patch_flag(Setup, False)
def test_not_setup_no_flow(self):
"""Test case on initial startup; setup flag is not set and oobe flow does
not exist yet"""
Flow.objects.filter(slug="initial-setup").delete()
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
# Flow does not exist, hence 503
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
@patch_flag(Setup, False)
@apply_blueprint("default/flow-oobe.yaml")
def test_not_setup(self):
"""Test case for when worker comes up, and has created flow"""
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
# Flow does not exist, hence 503
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.OK)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
fetch_redirect_response=False,
)
@apply_blueprint("default/flow-oobe.yaml")
@apply_blueprint("system/bootstrap.yaml")
def test_setup_flow_full(self):
"""Test full setup flow"""
Setup.set(False)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
fetch_redirect_response=False,
)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertStageResponse(res, component="ak-stage-prompt")
pw = generate_id()
res = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
{
"email": f"{generate_id()}@t.goauthentik.io",
"password": pw,
"password_repeat": pw,
"component": "ak-stage-prompt",
},
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertTrue(user.check_password(pw))
@patch_flag(Setup, False)
@apply_blueprint("default/flow-oobe.yaml")
@apply_blueprint("system/bootstrap.yaml")
def test_setup_flow_direct(self):
"""Test setup flow, directly accessing the flow"""
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"})
)
self.assertStageResponse(
res,
component="ak-stage-access-denied",
error_message="Access the authentik setup by navigating to http://testserver/",
)
def test_setup_bootstrap_env(self):
"""Test setup with env vars"""
User.objects.filter(username="akadmin").delete()
Setup.set(False)
environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] = generate_id()
environ["AUTHENTIK_BOOTSTRAP_TOKEN"] = generate_id()
pre_startup.send(sender=self)
post_startup.send(sender=self)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertTrue(user.check_password(environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]))
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])
def test_setup_bootstrap_env_password_hash(self):
"""Test setup with password hash env var"""
User.objects.filter(username="akadmin").delete()
Setup.set(False)
password = generate_id()
password_hash = make_password(password)
environ["AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"] = password_hash
pre_startup.send(sender=self)
post_startup.send(sender=self)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))

View File

@@ -1,15 +1,8 @@
"""user tests"""
from unittest.mock import patch
from django.contrib.auth.hashers import make_password
from django.test.testcases import TestCase
from rest_framework.exceptions import ValidationError
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.core.signals import password_changed, password_hash_changed
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -40,99 +33,3 @@ class TestUsers(TestCase):
self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
"""Test hash password updates do not expose a raw password to sync receivers."""
user = User.objects.create(
username=generate_id(),
attributes={"distinguishedName": "cn=test,ou=users,dc=example,dc=com"},
)
password_changed_captured = []
password_hash_changed_captured = []
dispatch_uid = generate_id()
hash_dispatch_uid = generate_id()
def password_changed_receiver(sender, **kwargs):
password_changed_captured.append(kwargs)
def password_hash_changed_receiver(sender, **kwargs):
password_hash_changed_captured.append(kwargs)
password_changed.connect(password_changed_receiver, dispatch_uid=dispatch_uid)
password_hash_changed.connect(
password_hash_changed_receiver, dispatch_uid=hash_dispatch_uid
)
try:
with (
patch(
"authentik.sources.ldap.signals.LDAPSource.objects.filter"
) as ldap_sources_filter,
patch(
"authentik.sources.kerberos.signals."
"UserKerberosSourceConnection.objects.select_related"
) as kerberos_connections_select,
):
user.set_password_from_hash(make_password("new-password")) # nosec
user.save()
finally:
password_changed.disconnect(dispatch_uid=dispatch_uid)
password_hash_changed.disconnect(dispatch_uid=hash_dispatch_uid)
self.assertEqual(password_changed_captured, [])
self.assertEqual(len(password_hash_changed_captured), 1)
ldap_sources_filter.assert_not_called()
kerberos_connections_select.assert_not_called()
class TestUserSerializerPasswordHash(TestCase):
"""Test UserSerializer password_hash support in blueprint context."""
def test_password_hash_sets_password_directly(self):
"""Test a valid password hash is stored without re-hashing."""
password = "test-password-123" # nosec
password_hash = make_password(password)
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": password_hash,
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
user = serializer.save()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
self.assertIsNotNone(user.password_change_date)
def test_password_hash_rejects_invalid_format(self):
"""Test invalid password hash values are rejected."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": "not-a-valid-hash",
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
with self.assertRaises(ValidationError) as ctx:
serializer.save()
self.assertIn("Invalid password hash format", str(ctx.exception))
def test_password_hash_ignored_outside_blueprint_context(self):
"""Test password_hash is not accepted by the regular serializer."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": make_password("test"), # nosec
}
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertNotIn("password_hash", serializer.validated_data)

View File

@@ -3,7 +3,6 @@
from datetime import datetime, timedelta
from json import loads
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
@@ -27,9 +26,6 @@ from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignatio
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
INVALID_PASSWORD_HASH = "not-a-valid-hash"
INVALID_PASSWORD_HASH_ERROR = "Invalid password hash format. Must be a valid Django password hash."
class TestUsersAPI(APITestCase):
"""Test Users API"""
@@ -38,20 +34,6 @@ class TestUsersAPI(APITestCase):
self.admin = create_test_admin_user()
self.user = create_test_user()
def _set_password_hash(self, user: User, password_hash: str, client=None):
return (client or self.client).post(
reverse("authentik_api:user-set-password-hash", kwargs={"pk": user.pk}),
data={"password": password_hash},
)
def _assert_password_hash_set(
self, user: User, password: str, password_hash: str, response
) -> None:
self.assertEqual(response.status_code, 204, response.data)
user.refresh_from_db()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
def test_filter_type(self):
"""Test API filtering by type"""
self.client.force_login(self.admin)
@@ -131,26 +113,6 @@ class TestUsersAPI(APITestCase):
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
def test_set_password_hash(self):
"""Test setting a user's password from a hash."""
self.client.force_login(self.admin)
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(self.user, password_hash)
self._assert_password_hash_set(self.user, password, password_hash, response)
def test_set_password_hash_invalid(self):
"""Test invalid password hashes are rejected."""
self.client.force_login(self.admin)
response = self._set_password_hash(self.user, INVALID_PASSWORD_HASH)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{"password": [INVALID_PASSWORD_HASH_ERROR]},
)
def test_recovery(self):
"""Test user recovery link"""
flow = create_test_flow(
@@ -299,29 +261,6 @@ class TestUsersAPI(APITestCase):
self.assertTrue(token_filter.exists())
self.assertTrue(token_filter.first().expiring)
def test_service_account_set_password_hash(self):
"""Service account password hash can be set through the API."""
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-service-account"),
data={
"name": "test-sa",
"create_group": False,
},
)
self.assertEqual(response.status_code, 200, response.data)
body = loads(response.content)
user = User.objects.get(pk=body["user_pk"])
self.assertEqual(user.type, UserTypes.SERVICE_ACCOUNT)
self.assertFalse(user.has_usable_password())
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(user, password_hash)
self._assert_password_hash_set(user, password, password_hash, response)
def test_service_account_no_expire(self):
"""Service account creation without token expiration"""
self.client.force_login(self.admin)

View File

@@ -1,6 +1,7 @@
"""authentik URL Configuration"""
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
@@ -18,7 +19,6 @@ from authentik.core.api.sources import (
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet
from authentik.core.setup.views import SetupView
from authentik.core.views.apps import RedirectToAppLaunch
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import (
@@ -35,7 +35,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware
urlpatterns = [
path(
"",
RootRedirectView.as_view(),
login_required(RootRedirectView.as_view()),
name="root-redirect",
),
path(
@@ -62,11 +62,6 @@ urlpatterns = [
FlowInterfaceView.as_view(),
name="if-flow",
),
path(
"setup",
SetupView.as_view(),
name="setup",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path(

View File

@@ -3,7 +3,6 @@
from json import dumps
from typing import Any
from django.contrib.auth.mixins import AccessMixin
from django.http import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import redirect
@@ -15,13 +14,12 @@ from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.brands.models import Brand
from authentik.core.apps import Setup
from authentik.core.models import UserTypes
from authentik.lib.config import CONFIG
from authentik.policies.denied import AccessDeniedResponse
class RootRedirectView(AccessMixin, RedirectView):
class RootRedirectView(RedirectView):
"""Root redirect view, redirect to brand's default application if set"""
pattern_name = "authentik_core:if-user"
@@ -42,10 +40,6 @@ class RootRedirectView(AccessMixin, RedirectView):
return None
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if not Setup.get():
return redirect("authentik_core:setup")
if not request.user.is_authenticated:
return self.handle_no_permission()
if redirect_response := RootRedirectView().redirect_to_app(request):
return redirect_response
return super().dispatch(request, *args, **kwargs)

View File

@@ -1,12 +1,10 @@
from unittest.mock import PropertyMock, patch
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.controller import BaseController
from authentik.endpoints.models import StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.lib.generators import generate_id
@@ -27,22 +25,16 @@ class TestAPI(APITestCase):
)
self.assertEqual(res.status_code, 201)
def test_endpoint_stage_agent_no_stage(self):
connector = AgentConnector.objects.create(name=generate_id())
class controller(BaseController):
def capabilities(self):
return []
with patch.object(AgentConnector, "controller", PropertyMock(return_value=controller)):
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
def test_endpoint_stage_fleet(self):
connector = FleetConnector.objects.create(name=generate_id())
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"connector": ["Selected connector is not compatible with this stage."]}

View File

@@ -1,15 +1,11 @@
import re
from plistlib import loads
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.x509 import load_der_x509_certificate
from django.db import transaction
from requests import RequestException
from rest_framework.exceptions import ValidationError
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
from authentik.endpoints.facts import (
DeviceFacts,
@@ -48,7 +44,7 @@ class FleetController(BaseController[DBC]):
return "fleetdm.com"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
return [Capabilities.ENROLL_AUTOMATIC_API]
def _url(self, path: str) -> str:
return f"{self.connector.url}{path}"
@@ -80,44 +76,8 @@ class FleetController(BaseController[DBC]):
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
@property
def mtls_ca_managed(self) -> str:
return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
def _sync_mtls_ca(self):
"""Sync conditional access Root CA for mTLS"""
try:
# Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
# hence we fetch the apple config profile and extract it
res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
res.raise_for_status()
profile = loads(res.text).get("PayloadContent", [])
raw_cert = None
for payload in profile:
if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
continue
raw_cert = payload.get("PayloadContent")
if not raw_cert:
raise ConnectorSyncException("Failed to get conditional acccess CA")
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
cert = load_der_x509_certificate(raw_cert)
CertificateKeyPair.objects.update_or_create(
managed=self.mtls_ca_managed,
defaults={
"name": f"Fleet Endpoint connector {self.connector.name}",
"certificate_data": cert.public_bytes(
encoding=serialization.Encoding.PEM,
).decode("utf-8"),
},
)
@transaction.atomic
def sync_endpoints(self) -> None:
try:
self._sync_mtls_ca()
except ConnectorSyncException as exc:
self.logger.warning("Failed to sync conditional access CA", exc=exc)
for host in self._paginate_hosts():
serial = host["hardware_serial"]
device, _ = Device.objects.get_or_create(
@@ -238,8 +198,6 @@ class FleetController(BaseController[DBC]):
for policy in host.get("policies", [])
],
"agent_version": fleet_version,
# Host UUID is required for conditional access matching
"uuid": host.get("uuid", "").lower(),
},
},
}

View File

@@ -51,12 +51,6 @@ class FleetConnector(Connector):
def component(self) -> str:
return "ak-endpoints-connector-fleet-form"
@property
def stage(self):
from authentik.enterprise.endpoints.connectors.fleet.stage import FleetStageView
return FleetStageView
class Meta:
verbose_name = _("Fleet Connector")
verbose_name_plural = _("Fleet Connectors")

View File

@@ -1,51 +0,0 @@
from cryptography.x509 import (
Certificate,
Extension,
SubjectAlternativeName,
UniformResourceIdentifier,
)
from rest_framework.exceptions import PermissionDenied
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE, MTLSStageView
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
FLEET_CONDITIONAL_ACCESS_URI_PREFIX = "urn:device:apple:uuid:"
class FleetStageView(MTLSStageView):
def get_authorities(self):
stage: EndpointStage = self.executor.current_stage
connector = FleetConnector.objects.filter(pk=stage.connector_id).first()
controller = connector.controller(connector)
kp = CertificateKeyPair.objects.filter(managed=controller.mtls_ca_managed).first()
return [kp] if kp else None
def lookup_device(self, cert: Certificate, mode: StageMode):
san_ext: Extension[SubjectAlternativeName] = cert.extensions.get_extension_for_oid(
SubjectAlternativeName.oid
)
raw_values = san_ext.value.get_values_for_type(UniformResourceIdentifier)
values = [x.removeprefix(FLEET_CONDITIONAL_ACCESS_URI_PREFIX).lower() for x in raw_values]
self.logger.debug("Looking for devices with uuid", fleet_device_uuid=values)
device = Device.objects.filter(
**{"deviceconnection__devicefactsnapshot__data__vendor__fleetdm.com__uuid__in": values}
).first()
if not device and mode == StageMode.REQUIRED:
raise PermissionDenied("Failed to find device")
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
return self.executor.stage_ok()
def dispatch(self, request, *args, **kwargs):
stage: EndpointStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
return self.lookup_device(cert, stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)

View File

@@ -1,23 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDwDCCAqigAwIBAgIBBDANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi
BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF
UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI2
MDMxODExMTc1NFoXDTI3MDQyMDExMjc1NFowLDEqMCgGA1UEAxMhRmxlZXQgY29u
ZGl0aW9uYWwgYWNjZXNzIGZvciBPa3RhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA3xuKxQQ8JSA4qCJ6RfOB7tbQurhwXiaJSLUDG7R5ncdRcd9LH/9y
5ZyI5kQACOwfICHmv02zR4/CrurfzXabo3CCpvcMdS7JI/FzP1GIIZ5RsR7oPFC6
JJg3m5BHuoHsUtCD7w0D52WiE7XVfbw47h2ChKmGMhkSrBvQnp3dHFEt8ntbl1/q
zCSuQaLeR2sQFurBDVBdinEgsvb1YHaYHi4tdFx5joG64Q/nJXyA2OM4hO9uBF+G
c4UVTzubx5sxwONcPhC9H+eLMpF1VHeU9gAGBlruVusUEYDmlqYQuA+bW5fTr4Zd
ZmJ5e+CzzUBYHduAML9a5S+1jbxSPZFBSwIDAQABo4GvMIGsMA4GA1UdDwEB/wQE
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUPrc1+LvbR9WoJIWZ
7YQa/3IX2w8wHwYDVR0jBBgwFoAUfl92kU2qcH4e+hypez4kEnqMbk4wRQYDVR0R
BD4wPIY6dXJuOmRldmljZTphcHBsZTp1dWlkOjVCRjQyMkQ2LTZFQUItNTE1Ni1B
QzVBLTlFQURDOTUyNDcxMzANBgkqhkiG9w0BAQsFAAOCAQEAGfxJ/u4271tnUUTB
J39YU6z2Ciav+9G3BtbvxBXI57Po7zCE6Z1sVkvYq6Xd0CcItPWRjbSPEy78ZzS0
By+gPy5fkKc8HHJ5I1wK890xbLBUS1P4EbdVBzI9ggouEa3B2asE10asnzLoKE4C
0FYWQwrzCsso8yxsJj1S8RKtd6MMbCis/9OQSC8om2tu6cLO+OftVn5DHtNWFidw
tAl/oHn2cZPUfZGpJGrHNZlp5w1c1dYfQeiPayoQIbsF+8eMV424G76z/8UPhMBs
R23LByv4TlUOPAGn2TRa2WtLIXs7FgqXRIFW4CjsPsEpXSVNlkYcn/VHY7Jl13zz
CRQ1Pg==
-----END CERTIFICATE-----

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<!-- Trusted CA certificate -->
<dict>
<key>PayloadCertificateFileName</key>
<string>conditional_access_ca.der</string>
<key>PayloadContent</key>
<data>MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAiBgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NFUCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1MTIwOTEyMjI1MVoXDTM1MTIwOTEyMjI1MVowaTEJMAcGA1UEBhMAMSQwIgYDVQQKExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0ExJDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrgCcpzQci2UhH+Dn0eHopnnbx3HbMabMCHXm6xteMVFLrdQJDTFrZCQzcexUgbpPJ0az6mn4szo+E3stn0y2PPWsiAiVhFwp5M9HwNg18rPgDmITv2pM3l/hlEsfggjq6TEVO2gRcq4NujEGagcYX6kp6nWxh6bbRngQ/hlK6mXItWV3x0G9eTcbFObwZhbuC2dNbccytdqbVEIpBjp6fftQnQwAaUVjoyZBFlf1C1cDV4+1jpaVsIj11U1olA33GJCHcZQ4CJEsgh8yiSsvkH5RNf94CGINB5ixsMfppjSXV/vNkWDKEfmUXW2q4ft7KK/L/SRq8QSB4VqTAp2GsCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH5fdpFNqnB+HvocqXs+JBJ6jG5OMA0GCSqGSIb3DQEBCwUAA4IBAQAJr4bTGlrANoHStu4Y+OXjGbEQjZOe546Bcln4eWrEB16eaVzfKuZgjJYdcOmp36/v34QY/OCXEIsixrBU5aW/Sr53IK6UQSZV3O3xbBc4Aert7AbeJ4NVGZyelfVQo/5G0qM6k9p0+zpIZqNAzFbhcSPIzuE7ig2OGsFoQU+bXhzk09bsZ+u4BXibzVNfMuMG+DHNv0PRjll272nEPI3bGwHF5tdrnfJG6e9t+qK9j9UqmSlBknHQJNeU5o8IDcmWYjWtOuBzecYsg8pZzXabJqlHTBIz/h7waRe7jtrK+XopK3jghRf9JTL+i0Y8NbVjoNkIoS3xMeRhnNbR9lw1</data>
<key>PayloadDescription</key>
<string>Fleet conditional access CA certificate</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access CA</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>ef1b2231-ad80-5511-9893-1f9838295147</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures SCEP enrollment for Okta conditional access</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access for Okta</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-okta</string>
<key>PayloadOrganization</key>
<string>Fleet Device Management</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>User</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>6fa509a3-feca-56f7-a283-d6a81c733ed2</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View File

@@ -1,27 +1,27 @@
{
"created_at": "2026-02-18T16:31:34Z",
"updated_at": "2026-03-18T11:29:18Z",
"created_at": "2025-06-25T22:21:35Z",
"updated_at": "2025-12-20T11:42:09Z",
"software": null,
"software_updated_at": "2026-03-18T11:29:17Z",
"id": 19,
"detail_updated_at": "2026-03-18T11:29:18Z",
"label_updated_at": "2026-03-18T11:29:18Z",
"policy_updated_at": "2026-03-18T11:29:18Z",
"last_enrolled_at": "2026-02-18T16:31:45Z",
"seen_time": "2026-03-18T11:31:34Z",
"software_updated_at": "2025-10-22T02:24:25Z",
"id": 1,
"detail_updated_at": "2025-10-23T23:30:31Z",
"label_updated_at": "2025-10-23T23:30:31Z",
"policy_updated_at": "2025-10-23T23:02:11Z",
"last_enrolled_at": "2025-06-25T22:21:37Z",
"seen_time": "2025-10-23T23:59:08Z",
"refetch_requested": false,
"hostname": "jens-mac-vm.local",
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
"platform": "darwin",
"osquery_version": "5.21.0",
"osquery_version": "5.19.0",
"orbit_version": null,
"fleet_desktop_version": null,
"scripts_enabled": null,
"os_version": "macOS 26.3",
"build": "25D125",
"os_version": "macOS 26.0.1",
"build": "25A362",
"platform_like": "darwin",
"code_name": "",
"uptime": 653014000000000,
"uptime": 256356000000000,
"memory": 4294967296,
"cpu_type": "arm64e",
"cpu_subtype": "ARM64E",
@@ -31,41 +31,38 @@
"hardware_vendor": "Apple Inc.",
"hardware_model": "VirtualMac2,1",
"hardware_version": "",
"hardware_serial": "ZV35VFDD50",
"hardware_serial": "Z5DDF07GK6",
"computer_name": "jens-mac-vm",
"timezone": null,
"public_ip": "92.116.179.252",
"primary_ip": "192.168.64.7",
"primary_mac": "5e:72:1c:89:98:29",
"primary_ip": "192.168.85.3",
"primary_mac": "e6:9d:21:c2:2f:19",
"distributed_interval": 10,
"config_tls_refresh": 60,
"logger_tls_period": 10,
"team_id": 5,
"team_id": 2,
"pack_stats": null,
"team_name": "dev",
"gigs_disk_space_available": 16.52,
"percent_disk_space_available": 26,
"team_name": "prod",
"gigs_disk_space_available": 23.82,
"percent_disk_space_available": 37,
"gigs_total_disk_space": 62.83,
"gigs_all_disk_space": null,
"issues": {
"failing_policies_count": 1,
"critical_vulnerabilities_count": 0,
"total_issues_count": 1
"critical_vulnerabilities_count": 2,
"total_issues_count": 3
},
"device_mapping": null,
"mdm": {
"enrollment_status": "On (manual)",
"dep_profile_error": false,
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
"name": "Fleet",
"encryption_key_available": false,
"connected_to_fleet": true
},
"refetch_critical_queries_until": null,
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
"status": "online",
"last_restarted_at": "2025-10-21T00:17:55Z",
"status": "offline",
"display_text": "jens-mac-vm.local",
"display_name": "jens-mac-vm",
"fleet_id": 5,
"fleet_name": "dev"
"display_name": "jens-mac-vm"
}

View File

@@ -21,19 +21,12 @@ TEST_HOST = {"hosts": [TEST_HOST_UBUNTU, TEST_HOST_MACOS, TEST_HOST_WINDOWS, TES
class TestFleetConnector(APITestCase):
def setUp(self):
self.connector = FleetConnector.objects.create(
name=generate_id(),
url="http://localhost",
token=generate_id(),
map_teams_access_group=True,
name=generate_id(), url="http://localhost", token=generate_id()
)
def test_sync(self):
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -47,9 +40,6 @@ class TestFleetConnector(APITestCase):
identifier="VMware-56 4d 4a 5a b0 22 7b d7-9b a5 0b dc 8f f2 3b 60"
).first()
self.assertIsNotNone(device)
group = device.access_group
self.assertIsNotNone(group)
self.assertEqual(group.name, "prod")
self.assertEqual(
device.cached_facts.data,
{
@@ -60,13 +50,7 @@ class TestFleetConnector(APITestCase):
"version": "24.04.3 LTS",
},
"disks": [],
"vendor": {
"fleetdm.com": {
"policies": [],
"agent_version": "",
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
}
},
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
"hardware": {
"model": "VMware20,1",
@@ -88,10 +72,6 @@ class TestFleetConnector(APITestCase):
self.connector.save()
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -101,13 +81,11 @@ class TestFleetConnector(APITestCase):
json={"hosts": []},
)
controller.sync_endpoints()
self.assertEqual(mock.call_count, 3)
self.assertEqual(mock.call_count, 2)
self.assertEqual(mock.request_history[0].method, "GET")
self.assertEqual(mock.request_history[0].headers["foo"], "bar")
self.assertEqual(mock.request_history[1].method, "GET")
self.assertEqual(mock.request_history[1].headers["foo"], "bar")
self.assertEqual(mock.request_history[2].method, "GET")
self.assertEqual(mock.request_history[2].headers["foo"], "bar")
def test_map_host_linux(self):
controller = self.connector.controller(self.connector)
@@ -150,6 +128,6 @@ class TestFleetConnector(APITestCase):
"arch": "arm64e",
"family": OSFamily.macOS,
"name": "macOS",
"version": "26.3",
"version": "26.0.1",
},
)

View File

@@ -1,84 +0,0 @@
from json import loads
from ssl import PEM_FOOTER, PEM_HEADER
from django.urls import reverse
from requests_mock import Mocker
from authentik.core.tests.utils import (
create_test_flow,
)
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class FleetConnectorStageTests(FlowTestCase):
def setUp(self):
super().setUp()
self.connector = FleetConnector.objects.create(
name=generate_id(), url="http://localhost", token=generate_id()
)
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": [loads(load_fixture("fixtures/host_macos.json"))]},
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=1&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": []},
)
controller.sync_endpoints()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = EndpointStage.objects.create(
name=generate_id(),
mode=StageMode.REQUIRED,
connector=self.connector,
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.host_cert = load_fixture("fixtures/cond_acc_host.pem")
def _format_traefik(self, cert: str | None = None):
cert = cert if cert else self.host_cert
return cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", "")
def test_assoc(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
plan = plan()
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], dev)
self.assertEqual(
plan.context[PLAN_CONTEXT_CERTIFICATE]["subject"],
"CN=Fleet conditional access for Okta",
)
def test_assoc_not_found(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
dev.delete()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
plan = plan()
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)

View File

@@ -12,7 +12,7 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.signals import password_changed, password_hash_changed
from authentik.core.signals import password_changed
from authentik.enterprise.providers.ssf.models import (
EventTypes,
SSFProvider,
@@ -84,13 +84,14 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
)
def _send_password_credential_change(user: User, change_type: str):
@receiver(password_changed)
def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_):
"""Credential change trigger (password changed)"""
send_ssf_events(
EventTypes.CAEP_CREDENTIAL_CHANGE,
{
"credential_type": "password",
"change_type": change_type,
"change_type": "revoke" if password is None else "update",
},
sub_id={
"format": "complex",
@@ -102,16 +103,6 @@ def _send_password_credential_change(user: User, change_type: str):
)
@receiver(password_hash_changed)
@receiver(password_changed)
def ssf_password_changed_cred_change(signal, sender, user: User, password: str | None = None, **_):
"""Credential change trigger (password changed)"""
if signal is password_hash_changed:
_send_password_credential_change(user, "update")
return
_send_password_credential_change(user, "revoke" if password is None else "update")
device_type_map = {
StaticDevice: "pin",
TOTPDevice: "pin",

View File

@@ -1,6 +1,5 @@
from uuid import uuid4
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -53,21 +52,6 @@ class TestSignals(APITestCase):
)
self.assertEqual(res.status_code, 201, res.content)
def _assert_password_credential_change(self, user, change_type: str):
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], change_type)
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
def test_signal_logout(self):
"""Test user logout"""
user = create_test_user()
@@ -95,25 +79,19 @@ class TestSignals(APITestCase):
user.set_password(generate_id())
user.save()
self._assert_password_credential_change(user, "update")
def test_signal_password_change_from_hash(self):
"""Test user password change from a pre-hashed password."""
user = create_test_user()
self.client.force_login(user)
user.set_password_from_hash(make_password(generate_id()))
user.save()
self._assert_password_credential_change(user, "update")
def test_signal_password_revoke(self):
"""Test explicit password revoke."""
user = create_test_user()
self.client.force_login(user)
user.set_password(None)
user.save()
self._assert_password_credential_change(user, "revoke")
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], "update")
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
def test_signal_authenticator_added(self):
"""Test authenticator creation signal"""

View File

@@ -15,7 +15,6 @@ from cryptography.x509 import (
)
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import PermissionDenied
from authentik.brands.models import Brand
from authentik.core.models import User
@@ -26,6 +25,7 @@ from authentik.enterprise.stages.mtls.models import (
MutualTLSStage,
UserAttributes,
)
from authentik.flows.challenge import AccessDeniedChallenge
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
@@ -217,7 +217,8 @@ class MTLSStageView(ChallengeStageView):
return None
return str(_cert_attr[0])
def get_cert(self, mode: StageMode):
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
certs = [
*self._parse_cert_xfcc(),
*self._parse_cert_nginx(),
@@ -227,26 +228,21 @@ class MTLSStageView(ChallengeStageView):
authorities = self.get_authorities()
if not authorities:
self.logger.warning("No Certificate authority found")
if mode == StageMode.OPTIONAL:
return None
if mode == StageMode.REQUIRED:
raise PermissionDenied("Unknown error")
if stage.mode == StageMode.OPTIONAL:
return self.executor.stage_ok()
if stage.mode == StageMode.REQUIRED:
return super().dispatch(request, *args, **kwargs)
cert = self.validate_cert(authorities, certs)
if not cert and mode == StageMode.REQUIRED:
if not cert and stage.mode == StageMode.REQUIRED:
self.logger.warning("Client certificate required but no certificates given")
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
if not cert and mode == StageMode.OPTIONAL:
return super().dispatch(
request,
*args,
error_message=_("Certificate required but no certificate was given."),
**kwargs,
)
if not cert and stage.mode == StageMode.OPTIONAL:
self.logger.info("No certificate given, continuing")
return None
return cert
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
existing_user = self.check_if_user(cert)
@@ -255,5 +251,15 @@ class MTLSStageView(ChallengeStageView):
elif existing_user:
self.auth_user(existing_user, cert)
else:
return self.executor.stage_invalid(_("No user found for certificate."))
return super().dispatch(
request, *args, error_message=_("No user found for certificate."), **kwargs
)
return self.executor.stage_ok()
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
return AccessDeniedChallenge(
data={
"component": "ak-stage-access-denied",
"error_message": str(error_message or "Unknown error"),
}
)

View File

@@ -11,7 +11,7 @@ from django.http import HttpRequest
from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User
from authentik.core.signals import login_failed, password_changed, password_hash_changed
from authentik.core.signals import login_failed, password_changed
from authentik.events.models import Event, EventAction
from authentik.flows.models import Stage
from authentik.flows.planner import (
@@ -112,15 +112,8 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
)
@receiver(password_hash_changed)
@receiver(password_changed)
def on_password_changed(
sender,
user: User,
password: str | None = None,
request: HttpRequest | None = None,
**_,
):
def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_):
"""Log password change"""
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user)

View File

@@ -2,7 +2,6 @@
from urllib.parse import urlencode
from django.contrib.auth.hashers import make_password
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.views.debug import SafeExceptionReporterFilter
@@ -11,7 +10,7 @@ from guardian.shortcuts import get_anonymous_user
from authentik.brands.models import Brand
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.events.models import Event
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
@@ -214,14 +213,3 @@ class TestEvents(TestCase):
event = Event.new("unittest", foo="foo bar \u0000 baz")
event.save()
self.assertEqual(event.context["foo"], "foo bar baz")
def test_password_set_signal_on_set_password_from_hash(self):
"""Changing password from hash should still emit an audit event."""
user = create_test_user()
old_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
user.set_password_from_hash(make_password(generate_id()))
user.save()
new_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
self.assertEqual(new_count, old_count + 1)

View File

@@ -11,10 +11,6 @@ class FlowNonApplicableException(SentryIgnoredException):
policy_result: PolicyResult | None = None
def __init__(self, policy_result: PolicyResult | None = None, *args):
super().__init__(*args)
self.policy_result = policy_result
@property
def messages(self) -> str:
"""Get messages from policy result, fallback to generic reason"""

View File

@@ -42,7 +42,6 @@ class Migration(migrations.Migration):
("require_superuser", "Require Superuser"),
("require_redirect", "Require Redirect"),
("require_outpost", "Require Outpost"),
("require_token", "Require Token"),
],
default="none",
help_text="Required level of authentication and authorization to access a flow.",

View File

@@ -40,7 +40,6 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_SUPERUSER = "require_superuser"
REQUIRE_REDIRECT = "require_redirect"
REQUIRE_OUTPOST = "require_outpost"
REQUIRE_TOKEN = "require_token"
class NotConfiguredAction(models.TextChoices):

View File

@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from sentry_sdk import start_span
from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger
@@ -27,7 +26,6 @@ from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import redirect_with_qs
from authentik.outposts.models import Outpost
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.root.middleware import ClientIPMiddleware
if TYPE_CHECKING:
@@ -228,15 +226,6 @@ class FlowPlanner:
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
):
raise FlowNonApplicableException()
if (
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_TOKEN
and context.get(PLAN_CONTEXT_IS_RESTORED) is None
):
raise FlowNonApplicableException(
PolicyResult(
False, _("This link is invalid or has expired. Please request a new one.")
)
)
outpost_user = ClientIPMiddleware.get_outpost_user(request)
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
if not outpost_user:
@@ -284,7 +273,9 @@ class FlowPlanner:
engine.build()
result = engine.result
if not result.passing:
raise FlowNonApplicableException(result)
exc = FlowNonApplicableException()
exc.policy_result = result
raise exc
# User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)

View File

@@ -1,6 +1,5 @@
"""flow views tests"""
from datetime import timedelta
from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode
@@ -8,7 +7,6 @@ from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils.timezone import now
from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User
@@ -19,7 +17,6 @@ from authentik.flows.models import (
FlowDeniedAction,
FlowDesignation,
FlowStageBinding,
FlowToken,
InvalidResponseAction,
)
from authentik.flows.planner import FlowPlan, FlowPlanner
@@ -27,7 +24,6 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import (
NEXT_ARG_NAME,
QS_KEY_TOKEN,
QS_QUERY,
SESSION_KEY_PLAN,
FlowExecutorView,
@@ -744,77 +740,3 @@ class TestFlowExecutor(FlowTestCase):
"title": flow.title,
},
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_expired_flow_token(self):
"""Test that an expired flow token shows an appropriate error message"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
user = create_test_user()
plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[], markers=[])
token = FlowToken.objects.create(
user=user,
identifier=generate_id(),
flow=flow,
_plan=FlowToken.pickle(plan),
expires=now() - timedelta(hours=1),
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: token.key})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_flow_token_require_token(self):
"""Test that an invalid/nonexistent token on a REQUIRE_TOKEN flow shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: 'invalid-token'})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_no_token_require_token(self):
"""Test that accessing a REQUIRE_TOKEN flow without any token shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)

View File

@@ -26,7 +26,6 @@ from authentik.flows.models import (
)
from authentik.flows.planner import (
PLAN_CONTEXT_IS_REDIRECTED,
PLAN_CONTEXT_IS_RESTORED,
PLAN_CONTEXT_PENDING_USER,
FlowPlanner,
cache_key,
@@ -130,22 +129,6 @@ class TestFlowPlanner(TestCase):
planner.allow_empty_flows = True
planner.plan(request)
def test_authentication_require_token(self):
"""Test flow authentication (require_token)"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.REQUIRE_TOKEN
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
with self.assertRaises(FlowNonApplicableException):
planner.plan(request)
context = {PLAN_CONTEXT_IS_RESTORED: True}
planner.plan(request, context)
@patch(
"authentik.policies.engine.PolicyEngine.result",
POLICY_RETURN_FALSE,

View File

@@ -62,7 +62,6 @@ from authentik.policies.engine import PolicyEngine
LOGGER = get_logger()
# Argument used to redirect user after login
NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "authentik/flows/plan"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"

View File

@@ -71,11 +71,7 @@ class FlowInspectorView(APIView):
flow: Flow
_logger: BoundLogger
def get_permissions(self):
if settings.DEBUG:
return []
return [IsAuthenticated()]
permission_classes = [IsAuthenticated]
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)

View File

@@ -14,16 +14,7 @@ def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
qs = qs.order_by("pk")
pks = qs.values_list("pk", flat=True)
# The outer queryset.exists() guard can race with a concurrent
# transaction that deletes the last matching row (or with a
# different isolation-level snapshot), so by the time this
# generator starts iterating the queryset may be empty and
# pks[0] would raise IndexError and crash the caller. Using
# .first() returns None on an empty queryset, which we bail
# out on cleanly. See goauthentik/authentik#21643.
start_pk = pks.first()
if start_pk is None:
return
start_pk = pks[0]
while True:
try:
end_pk = pks.filter(pk__gte=start_pk)[chunk_size]

View File

@@ -65,7 +65,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
fields = ProviderSerializer.Meta.fields + [
"authorization_flow",
"client_type",
"grant_types",
"client_id",
"client_secret",
"access_code_validity",

View File

@@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from authentik.events.models import Event, EventAction
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.views import bad_request_message
from authentik.providers.oauth2.models import GrantType, RedirectURI
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
class OAuth2Error(SentryIgnoredException):
@@ -182,7 +182,7 @@ class AuthorizeError(OAuth2Error):
# See:
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
fragment_or_query = (
"#" if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID] else "?"
"#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
)
uri = (
@@ -225,7 +225,7 @@ class TokenError(OAuth2Error):
),
}
def __init__(self, error: str):
def __init__(self, error):
super().__init__()
self.error = error
self.description = self.errors[error]

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-17 11:04
import django.contrib.postgres.fields
from django.db import migrations, models
def migrate_default_grant_types():
from authentik.providers.oauth2.models import GrantType
return [
GrantType.AUTHORIZATION_CODE,
GrantType.HYBRID,
GrantType.IMPLICIT,
GrantType.CLIENT_CREDENTIALS,
GrantType.PASSWORD,
GrantType.DEVICE_CODE,
GrantType.REFRESH_TOKEN,
]
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_oauth2",
"0031_remove_oauth2provider_backchannel_logout_uri_and_more",
),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="grant_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
("authorization_code", "Authorization Code"),
("implicit", "Implicit"),
("hybrid", "Hybrid"),
("refresh_token", "Refresh Token"),
("client_credentials", "Client Credentials"),
("password", "Password"),
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
]
),
default=migrate_default_grant_types,
size=None,
),
),
migrations.AlterField(
model_name="oauth2provider",
name="grant_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
("authorization_code", "Authorization Code"),
("implicit", "Implicit"),
("hybrid", "Hybrid"),
("refresh_token", "Refresh Token"),
("client_credentials", "Client Credentials"),
("password", "Password"),
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
]
),
default=list,
size=None,
),
),
]

View File

@@ -19,7 +19,6 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from dacite import Config
from dacite.core import from_dict
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import HashIndex
from django.db import models
from django.http import HttpRequest
@@ -34,16 +33,7 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.brands.models import WebfingerProvider
from authentik.common.oauth.constants import (
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
GRANT_TYPE_DEVICE_CODE,
GRANT_TYPE_HYBRID,
GRANT_TYPE_IMPLICIT,
GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN,
SubModes,
)
from authentik.common.oauth.constants import SubModes
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
@@ -68,7 +58,7 @@ def generate_client_secret() -> str:
return generate_id(128)
class ClientType(models.TextChoices):
class ClientTypes(models.TextChoices):
"""Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable."""
@@ -76,16 +66,12 @@ class ClientType(models.TextChoices):
PUBLIC = "public", _("Public")
class GrantType(models.TextChoices):
class GrantTypes(models.TextChoices):
"""OAuth2 Grant types we support"""
AUTHORIZATION_CODE = GRANT_TYPE_AUTHORIZATION_CODE
IMPLICIT = GRANT_TYPE_IMPLICIT
HYBRID = GRANT_TYPE_HYBRID
REFRESH_TOKEN = GRANT_TYPE_REFRESH_TOKEN
CLIENT_CREDENTIALS = GRANT_TYPE_CLIENT_CREDENTIALS
PASSWORD = GRANT_TYPE_PASSWORD
DEVICE_CODE = GRANT_TYPE_DEVICE_CODE
AUTHORIZATION_CODE = "authorization_code"
IMPLICIT = "implicit"
HYBRID = "hybrid"
class ResponseMode(models.TextChoices):
@@ -202,15 +188,14 @@ class OAuth2Provider(WebfingerProvider, Provider):
client_type = models.CharField(
max_length=30,
choices=ClientType.choices,
default=ClientType.CONFIDENTIAL,
choices=ClientTypes.choices,
default=ClientTypes.CONFIDENTIAL,
verbose_name=_("Client Type"),
help_text=_(
"Confidential clients are capable of maintaining the confidentiality "
"of their credentials. Public clients are incapable"
),
)
grant_types = ArrayField(models.TextField(choices=GrantType.choices), default=list)
client_id = models.CharField(
max_length=255,
unique=True,

View File

@@ -22,7 +22,7 @@ from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, Red
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantType,
GrantTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -41,34 +41,12 @@ class TestAuthorize(OAuthTestCase):
super().setUp()
self.factory = RequestFactory()
def test_disallowed_grant_type(self):
"""Test with disallowed grant type"""
OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
grant_types=[],
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
"/",
data={
"response_type": "code",
"client_id": "test",
"redirect_uri": "http://local.invalid/Foo",
},
)
OAuthAuthorizationParams.from_request(request)
self.assertEqual(cm.exception.error, "invalid_request")
def test_invalid_grant_type(self):
"""Test with invalid grant type"""
OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
with self.assertRaises(AuthorizeError) as cm:
@@ -96,7 +74,6 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
grant_types=[GrantType.AUTHORIZATION_CODE],
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
@@ -164,6 +141,26 @@ class TestAuthorize(OAuthTestCase):
OAuthAuthorizationParams.from_request(request)
self.assertEqual(cm.exception.cause, "redirect_uri_forbidden_scheme")
def test_invalid_redirect_uri_empty(self):
"""test missing/invalid redirect URI"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[],
)
request = self.factory.get(
"/",
data={
"response_type": "code",
"client_id": "test",
"redirect_uri": "+",
},
)
OAuthAuthorizationParams.from_request(request)
provider.refresh_from_db()
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
def test_invalid_redirect_uri_regex(self):
"""test missing/invalid redirect URI"""
OAuth2Provider.objects.create(
@@ -211,7 +208,6 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, ".+")],
grant_types=[GrantType.AUTHORIZATION_CODE],
)
request = self.factory.get(
"/",
@@ -230,7 +226,6 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
provider.property_mappings.set(
@@ -252,14 +247,12 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantType.AUTHORIZATION_CODE,
GrantTypes.AUTHORIZATION_CODE,
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).redirect_uri,
"http://local.invalid/Foo",
)
provider.grant_types = [GrantType.IMPLICIT]
provider.save()
request = self.factory.get(
"/",
data={
@@ -273,7 +266,7 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantType.IMPLICIT,
GrantTypes.IMPLICIT,
)
# Implicit without openid scope
with self.assertRaises(AuthorizeError) as cm:
@@ -288,10 +281,8 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantType.IMPLICIT,
GrantTypes.IMPLICIT,
)
provider.grant_types = [GrantType.HYBRID]
provider.save()
request = self.factory.get(
"/",
data={
@@ -303,7 +294,7 @@ class TestAuthorize(OAuthTestCase):
},
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type, GrantType.HYBRID
OAuthAuthorizationParams.from_request(request).grant_type, GrantTypes.HYBRID
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
@@ -326,7 +317,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -363,7 +353,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -435,7 +424,6 @@ class TestAuthorize(OAuthTestCase):
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
encryption_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -498,7 +486,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -548,7 +535,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -606,7 +592,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.AUTHORIZATION_CODE],
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id()
@@ -647,7 +632,6 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.IMPLICIT],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
)
request = self.factory.get(
@@ -671,7 +655,6 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -704,7 +687,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -735,7 +717,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -775,7 +756,6 @@ class TestAuthorize(OAuthTestCase):
authentication_flow=auth_flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -802,7 +782,6 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()

View File

@@ -6,11 +6,10 @@ from urllib.parse import quote
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@@ -22,7 +21,6 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.DEVICE_CODE],
)
self.application = Application.objects.create(
name=generate_id(),
@@ -43,21 +41,6 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
reverse("authentik_providers_oauth2:device"),
)
self.assertEqual(res.status_code, 400)
def test_backchannel_invalid_no_grant(self):
"""Test backchannel"""
self.provider.grant_types = []
self.provider.save()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
data={
"client_id": "test",
},
)
self.assertEqual(res.status_code, 400)
def test_backchannel_invalid_no_app(self):
"""Test backchannel"""
# test without application
self.application.provider = None
self.application.save()
@@ -127,57 +110,3 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes_extra(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email foo"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)

View File

@@ -9,7 +9,7 @@ from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -22,7 +22,6 @@ class TesOAuth2DeviceInit(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.DEVICE_CODE],
)
self.application = Application.objects.create(
name=generate_id(),

View File

@@ -14,7 +14,7 @@ from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientType,
ClientTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -173,7 +173,7 @@ class TesOAuth2Introspection(OAuthTestCase):
def test_introspect_provider_public(self):
"""Test introspect"""
self.provider.client_type = ClientType.PUBLIC
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
token = AccessToken.objects.create(
provider=self.provider,
@@ -208,7 +208,7 @@ class TesOAuth2Introspection(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientType.PUBLIC,
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)

View File

@@ -13,7 +13,7 @@ from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientType,
ClientTypes,
DeviceToken,
OAuth2Provider,
RedirectURI,
@@ -126,7 +126,7 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_public(self):
"""Test revoke public client"""
self.provider.client_type = ClientType.PUBLIC
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
token = AccessToken.objects.create(
provider=self.provider,
@@ -241,7 +241,7 @@ class TesOAuth2Revoke(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientType.PUBLIC,
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
@@ -270,14 +270,14 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_provider_fed_public(self):
"""Test revoke with federation. self.provider is a public
client and other_provider is a public client."""
self.provider.client_type = ClientType.PUBLIC
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientType.PUBLIC,
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)

View File

@@ -25,7 +25,6 @@ from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -45,39 +44,11 @@ class TestToken(OAuthTestCase):
self.factory = RequestFactory()
self.app = Application.objects.create(name=generate_id(), slug="test")
def test_invalid_grant_type(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
signing_key=self.keypair,
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
code = AuthorizationCode.objects.create(
code="foobar", provider=provider, user=user, auth_time=timezone.now()
)
request = self.factory.post(
"/",
data={
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
"code": code.code,
"redirect_uri": "http://TestServer",
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
with self.assertRaises(TokenError) as cm:
TokenParams.parse(request, provider, provider.client_id, provider.client_secret)
self.assertEqual(cm.exception.cause, "grant_type_not_configured")
def test_request_auth_code(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
signing_key=self.keypair,
)
@@ -105,7 +76,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.keypair,
)
@@ -127,7 +97,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -170,7 +139,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -211,7 +179,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
encryption_key=self.keypair,
@@ -243,7 +210,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -305,7 +271,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -363,7 +328,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.keypair,
)
@@ -436,7 +400,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
refresh_token_threshold="hours=1", # nosec
@@ -534,7 +497,6 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
include_claims_in_id_token=True,

View File

@@ -22,7 +22,6 @@ from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
AccessToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -56,7 +55,6 @@ class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
grant_types=[GrantType.CLIENT_CREDENTIALS],
)
self.provider.jwt_federation_providers.add(self.other_provider)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@@ -20,7 +20,6 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -69,7 +68,6 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
grant_types=[GrantType.CLIENT_CREDENTIALS],
)
self.provider.jwt_federation_sources.add(self.source)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@@ -21,7 +21,6 @@ from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -42,7 +41,6 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -22,7 +22,6 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -43,7 +42,6 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -25,7 +25,6 @@ from authentik.core.tests.utils import (
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -46,7 +45,6 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -17,7 +17,6 @@ from authentik.lib.generators import generate_code_fixed_length, generate_id
from authentik.providers.oauth2.models import (
AccessToken,
DeviceToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -38,7 +37,6 @@ class TestTokenDeviceCode(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.DEVICE_CODE],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
@@ -50,7 +48,6 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
},
)
@@ -69,7 +66,6 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
@@ -78,26 +74,6 @@ class TestTokenDeviceCode(OAuthTestCase):
body = loads(res.content.decode())
self.assertEqual(body["error"], "authorization_pending")
def test_code_no_auth(self):
"""Test code with user"""
device_token = DeviceToken.objects.create(
provider=self.provider,
user_code=generate_code_fixed_length(),
device_code=generate_id(),
user=self.user,
)
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
)
self.assertEqual(res.status_code, 400)
body = loads(res.content.decode())
self.assertEqual(body["error"], "invalid_client")
def test_code(self):
"""Test code with user"""
device_token = DeviceToken.objects.create(
@@ -110,7 +86,6 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
@@ -130,7 +105,6 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",

View File

@@ -11,7 +11,6 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
AuthorizationCode,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -38,7 +37,6 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -97,7 +95,6 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -154,7 +151,6 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -200,7 +196,6 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()

View File

@@ -57,9 +57,11 @@ from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantType,
GrantTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RedirectURIType,
ResponseMode,
ResponseTypes,
ScopeMapping,
@@ -164,31 +166,28 @@ class OAuthAuthorizationParams:
"""Check grant"""
# Determine which flow to use.
if self.response_type in [ResponseTypes.CODE]:
self.grant_type = GrantType.AUTHORIZATION_CODE
self.grant_type = GrantTypes.AUTHORIZATION_CODE
elif self.response_type in [
ResponseTypes.ID_TOKEN,
ResponseTypes.ID_TOKEN_TOKEN,
]:
self.grant_type = GrantType.IMPLICIT
self.grant_type = GrantTypes.IMPLICIT
elif self.response_type in [
ResponseTypes.CODE_TOKEN,
ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
]:
self.grant_type = GrantType.HYBRID
self.grant_type = GrantTypes.HYBRID
# Grant type validation.
if not self.grant_type:
LOGGER.warning("Invalid response type", type=self.response_type)
raise AuthorizeError(self.redirect_uri, "unsupported_response_type", "", self.state)
if self.grant_type not in self.provider.grant_types:
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
if self.response_mode not in ResponseMode.values:
self.response_mode = ResponseMode.QUERY
if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID]:
if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
self.response_mode = ResponseMode.FRAGMENT
def check_redirect_uri(self):
@@ -198,6 +197,18 @@ class OAuthAuthorizationParams:
LOGGER.warning("Missing redirect uri.")
raise RedirectUriError("", allowed_redirect_urls).with_cause("redirect_uri_missing")
if len(allowed_redirect_urls) < 1:
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
self.provider.redirect_uris = [
RedirectURI(
RedirectURIMatchingMode.STRICT,
self.redirect_uri,
RedirectURIType.AUTHORIZATION,
)
]
self.provider.save()
allowed_redirect_urls = self.provider.authorization_redirect_uris
match_found = False
for allowed in allowed_redirect_urls:
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
@@ -249,7 +260,7 @@ class OAuthAuthorizationParams:
)
self.scope = self.scope.intersection(default_scope_names)
if SCOPE_OPENID not in self.scope and (
self.grant_type == GrantType.HYBRID
self.grant_type == GrantTypes.HYBRID
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
):
LOGGER.warning("Missing 'openid' scope.")
@@ -600,8 +611,8 @@ class OAuthFulfillmentStage(StageView):
code = None
if self.params.grant_type in [
GrantType.AUTHORIZATION_CODE,
GrantType.HYBRID,
GrantTypes.AUTHORIZATION_CODE,
GrantTypes.HYBRID,
]:
code = self.params.create_code(self.request)
code.save()
@@ -616,7 +627,7 @@ class OAuthFulfillmentStage(StageView):
if self.params.response_mode == ResponseMode.FRAGMENT:
query_fragment = {}
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
query_fragment["code"] = code.code
query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
else:
@@ -630,7 +641,7 @@ class OAuthFulfillmentStage(StageView):
if self.params.response_mode == ResponseMode.FORM_POST:
post_params = {}
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
post_params["code"] = code.code
post_params["state"] = [str(self.params.state) if self.params.state else ""]
else:
@@ -699,7 +710,7 @@ class OAuthFulfillmentStage(StageView):
token.save()
# Code parameter must be present if it's Hybrid Flow.
if self.params.grant_type == GrantType.HYBRID:
if self.params.grant_type == GrantTypes.HYBRID:
query_fragment["code"] = code.code
query_fragment["token_type"] = TOKEN_TYPE

View File

@@ -15,7 +15,7 @@ from authentik.core.models import Application
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.errors import DeviceCodeError
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -28,7 +28,7 @@ class DeviceView(View):
client_id: str
provider: OAuth2Provider
scopes: set[str] = []
scopes: list[str] = []
def parse_request(self):
"""Parse incoming request"""
@@ -42,25 +42,9 @@ class DeviceView(View):
_ = provider.application
except Application.DoesNotExist:
raise DeviceCodeError("invalid_client") from None
if GrantType.DEVICE_CODE not in provider.grant_types:
raise DeviceCodeError("invalid_client")
self.provider = provider
self.client_id = client_id
scopes_to_check = set(self.request.POST.get("scope", "").split())
default_scope_names = set(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
"scope_name", flat=True
)
)
self.scopes = scopes_to_check
if not scopes_to_check.issubset(default_scope_names):
LOGGER.info(
"Application requested scopes not configured, setting to overlap",
scope_allowed=default_scope_names,
scope_given=self.scopes,
)
self.scopes = self.scopes.intersection(default_scope_names)
self.scopes = self.request.POST.get("scope", "").split(" ")
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
throttle = AnonRateThrottle()

View File

@@ -11,7 +11,7 @@ from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenIntrospectionError
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger()
@@ -45,7 +45,7 @@ class TokenIntrospectionParams:
if not provider:
LOGGER.info("Failed to authenticate introspection request")
raise TokenIntrospectionError
if provider.client_type != ClientType.CONFIDENTIAL:
if provider.client_type != ClientTypes.CONFIDENTIAL:
LOGGER.info("Introspection request from public provider, denying.")
raise TokenIntrospectionError

View File

@@ -58,7 +58,7 @@ from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
ClientType,
ClientTypes,
DeviceToken,
OAuth2Provider,
RedirectURIMatchingMode,
@@ -165,20 +165,8 @@ class TokenParams:
raise TokenError("invalid_grant")
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
if self.grant_type not in self.provider.grant_types:
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
raise TokenError("invalid_grant").with_cause("grant_type_not_configured")
# Confidential clients MUST authenticate to the token endpoint per
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
# that requirement - the device_code alone is not a substitute for
# client credentials.
if self.grant_type in [
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN,
GRANT_TYPE_DEVICE_CODE,
]:
if self.provider.client_type == ClientType.CONFIDENTIAL and not compare_digest(
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
self.provider.client_secret, self.client_secret
):
LOGGER.warning(
@@ -610,10 +598,10 @@ class TokenView(View):
if not self.provider:
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
raise TokenError("invalid_client")
CTX_AUTH_VIA.set("oauth_client_secret")
self.params = self.params_class.parse(
request, self.provider, client_id, client_secret
)
CTX_AUTH_VIA.set("oauth_client_secret")
with start_span(
op="authentik.providers.oauth2.post.response",

View File

@@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenRevocationError
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import (
TokenResponse,
authenticate_provider,
@@ -33,13 +33,11 @@ class TokenRevocationParams:
raw_token = request.POST.get("token")
provider, _, _ = provider_from_request(request)
if provider and provider.client_type == ClientType.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")
# By default clients can only revoke their own tokens
query = Q(provider=provider, token=raw_token)
if provider.client_type == ClientType.CONFIDENTIAL:
if provider.client_type == ClientTypes.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")

View File

@@ -16,8 +16,7 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
from authentik.outposts.models import OutpostModel
from authentik.providers.oauth2.models import (
ClientType,
GrantType,
ClientTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -162,12 +161,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct"""
self.grant_types = [
GrantType.AUTHORIZATION_CODE,
GrantType.CLIENT_CREDENTIALS,
GrantType.PASSWORD,
]
self.client_type = ClientType.CONFIDENTIAL
self.client_type = ClientTypes.CONFIDENTIAL
self.signing_key = None
self.include_claims_in_id_token = True
scopes = ScopeMapping.objects.filter(

View File

@@ -9,7 +9,7 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.models import ClientType
from authentik.providers.oauth2.models import ClientTypes
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
@@ -96,7 +96,7 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 201)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
def test_update_defaults(self):
"""Test create"""
@@ -114,8 +114,8 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 201)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
provider.client_type = ClientType.PUBLIC
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
provider.client_type = ClientTypes.PUBLIC
provider.save()
response = self.client.put(
reverse("authentik_api:proxyprovider-detail", kwargs={"pk": provider.pk}),
@@ -130,7 +130,7 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 200)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
def test_sa_fetch(self):
"""Test fetching the outpost config as the service account"""

View File

@@ -24,11 +24,7 @@ from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
SAML_BINDING_POST,
SAML_BINDING_REDIRECT,
)
from authentik.common.saml.constants import SAML_BINDING_POST, SAML_BINDING_REDIRECT
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
@@ -59,7 +55,6 @@ class SAMLProviderSerializer(ProviderSerializer):
"""SAMLProvider Serializer"""
url_download_metadata = SerializerMethodField()
url_issuer = SerializerMethodField()
url_sso_post = SerializerMethodField()
url_sso_redirect = SerializerMethodField()
@@ -90,23 +85,6 @@ class SAMLProviderSerializer(ProviderSerializer):
+ "?download"
)
def get_url_issuer(self, instance: SAMLProvider) -> str:
"""Get Issuer/EntityID URL"""
if instance.issuer_override:
return instance.issuer_override
if "request" not in self._context:
return DEFAULT_ISSUER
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return DEFAULT_ISSUER
def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL"""
if "request" not in self._context:
@@ -220,7 +198,7 @@ class SAMLProviderSerializer(ProviderSerializer):
"acs_url",
"sls_url",
"audience",
"issuer_override",
"issuer",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
@@ -242,7 +220,6 @@ class SAMLProviderSerializer(ProviderSerializer):
"default_relay_state",
"default_name_id_policy",
"url_download_metadata",
"url_issuer",
"url_sso_post",
"url_sso_redirect",
"url_sso_init",

View File

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

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-24 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0021_samlprovider_sign_logout_response"),
]
operations = [
migrations.RenameField(
model_name="samlprovider",
old_name="issuer",
new_name="issuer_override",
),
migrations.AlterField(
model_name="samlprovider",
name="issuer_override",
field=models.TextField(
blank=True,
default="",
help_text="Also known as EntityID. Providing a value overrides the default issuer generated by authentik.",
),
),
migrations.AddField(
model_name="samlsession",
name="issuer",
field=models.TextField(
default=None, help_text="SAML Issuer used for this session", null=True
),
),
]

View File

@@ -77,14 +77,7 @@ class SAMLProvider(Provider):
"no audience restriction will be added."
),
)
issuer_override = models.TextField(
blank=True,
default="",
help_text=_(
"Also known as EntityID. Providing a value overrides the default issuer "
"generated by authentik."
),
)
issuer = models.TextField(help_text=_("Also known as EntityID"), default="authentik")
sls_url = models.TextField(
blank=True,
validators=[DomainlessURLValidator(schemes=("http", "https"))],
@@ -325,9 +318,6 @@ class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
session_index = models.TextField(help_text=_("SAML SessionIndex for this session"))
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"))
issuer = models.TextField(
default=None, null=True, help_text=_("SAML Issuer used for this session")
)
created = models.DateTimeField(auto_now_add=True)
@property

View File

@@ -6,7 +6,6 @@ from types import GeneratorType
import xmlsec
from django.http import HttpRequest
from django.urls import reverse
from django.utils.timezone import now
from lxml import etree # nosec
from lxml.etree import Element, SubElement, _Element # nosec
@@ -64,7 +63,6 @@ class AssertionProcessor:
session_index: str
name_id: str
name_id_format: str
issuer: str
session_not_on_or_after_datetime: datetime
def __init__(self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest):
@@ -139,24 +137,10 @@ class AssertionProcessor:
continue
return attribute_statement
def _get_issuer_value(self) -> str:
"""Get issuer value, with fallback to generated URL if empty"""
# If user has set an override issuer, use it
if self.provider.issuer_override:
return self.provider.issuer_override
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
def get_issuer(self) -> Element:
"""Get Issuer Element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
self.issuer = self._get_issuer_value()
issuer.text = self.issuer
issuer.text = self.provider.issuer
return issuer
def get_assertion_auth_n_statement(self) -> Element:

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

@@ -8,7 +8,6 @@ from lxml import etree # nosec
from lxml.etree import Element, _Element
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
@@ -34,12 +33,11 @@ class LogoutRequestProcessor:
name_id_format: str
session_index: str | None
relay_state: str | None
issuer: str | None
_issue_instant: str
_request_id: str
def __init__( # noqa: PLR0913
def __init__(
self,
provider: SAMLProvider,
user: User | None,
@@ -48,7 +46,6 @@ class LogoutRequestProcessor:
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
session_index: str | None = None,
relay_state: str | None = None,
issuer: str | None = None,
):
self.provider = provider
self.user = user
@@ -57,23 +54,14 @@ class LogoutRequestProcessor:
self.name_id_format = name_id_format
self.session_index = session_index
self.relay_state = relay_state
self.issuer = issuer
self._issue_instant = get_time_string()
self._request_id = get_random_id()
def _get_issuer_value(self) -> str:
"""Get issuer value from session, with fallback to provider"""
if self.issuer:
return self.issuer
if self.provider.issuer_override:
return self.provider.issuer_override
return DEFAULT_ISSUER
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self._get_issuer_value()
issuer.text = self.provider.issuer
return issuer
def get_name_id(self) -> Element:

View File

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

View File

@@ -8,15 +8,14 @@ from lxml import etree
from lxml.etree import Element, SubElement
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
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
@@ -29,38 +28,27 @@ class LogoutResponseProcessor:
logout_request: LogoutRequest
destination: str | None
relay_state: str | None
issuer: str | None
_issue_instant: str
_response_id: str
def __init__( # noqa: PLR0913
def __init__(
self,
provider: SAMLProvider,
logout_request: LogoutRequest,
destination: str | None = None,
relay_state: str | None = None,
issuer: str | None = None,
):
self.provider = provider
self.logout_request = logout_request
self.destination = destination
self.relay_state = relay_state or (logout_request.relay_state if logout_request else None)
self.issuer = issuer
self._issue_instant = get_time_string()
self._response_id = get_random_id()
def _get_issuer_value(self) -> str:
"""Get issuer value from session, with fallback to provider"""
if self.issuer:
return self.issuer
if self.provider.issuer_override:
return self.provider.issuer_override
return DEFAULT_ISSUER
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self._get_issuer_value()
issuer.text = self.provider.issuer
return issuer
def build(self, status: str = "Success") -> Element:

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