mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 19:06:39 +02:00
Compare commits
1 Commits
version-20
...
sdko/atpro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d4f85579 |
6
.github/actions/setup/action.yml
vendored
6
.github/actions/setup/action.yml
vendored
@@ -25,7 +25,7 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
|
||||
with:
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext libclang-dev libkadm5clnt-mit12 libkadm5clnt7t64-heimdal libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
||||
update: true
|
||||
upgrade: false
|
||||
install-recommends: false
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --all-extras --dev --locked
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup rust (stable)
|
||||
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1
|
||||
@@ -64,7 +64,7 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@711e1c3275189d76dcc4d34ddea63bf96ac49090 # v2
|
||||
uses: taiki-e/install-action@51cd0b8c0499559d9a4d75c0f5c67bec3a894ec8 # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
|
||||
2
.github/workflows/release-branch-off.yml
vendored
2
.github/workflows/release-branch-off.yml
vendored
@@ -68,8 +68,6 @@ jobs:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: "system,python,go,node,runtime,rust-nightly"
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
|
||||
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@@ -82,14 +82,10 @@ jobs:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: "system,python,go,node,runtime,rust-nightly"
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
- name: Re-generate API Clients
|
||||
run: make gen
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
|
||||
129
Cargo.lock
generated
129
Cargo.lock
generated
@@ -171,7 +171,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2026.5.0-rc2"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"argh",
|
||||
@@ -196,7 +196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik-axum"
|
||||
version = "2026.5.0-rc2"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"authentik-common",
|
||||
"axum",
|
||||
@@ -216,7 +216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik-client"
|
||||
version = "2026.5.0-rc2"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"reqwest",
|
||||
@@ -232,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik-common"
|
||||
version = "2026.5.0-rc2"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"authentik-client",
|
||||
@@ -1003,17 +1003,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "evmap"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
|
||||
dependencies = [
|
||||
"hashbag",
|
||||
"left-right",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -1230,21 +1219,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1326,12 +1300,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbag"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1889,17 +1857,6 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "left-right"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"loom",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -1971,19 +1928,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -2033,12 +1977,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.18.3"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
|
||||
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"evmap",
|
||||
"indexmap",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
@@ -2057,7 +2000,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"metrics",
|
||||
"quanta",
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.2",
|
||||
"rand_xoshiro",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
@@ -2744,7 +2687,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2804,9 +2747,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
@@ -3160,12 +3103,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -3203,9 +3140,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "sentry"
|
||||
version = "0.48.0"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8ac94aab850a23d7507307cc505332ed2bafd36c65930dfc5c43610f9e9b477"
|
||||
checksum = "eb25f439f97d26fea01d717fa626167ceffcd981addaa670001e70505b72acbb"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"httpdate",
|
||||
@@ -3224,9 +3161,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-backtrace"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc84c325ace9ca2388e510fe7d6672b5d60cd8b3bd0eb4bb4ee8314c323cd686"
|
||||
checksum = "46a8c2c1bd5c1f735e84f28b48e7d72efcaafc362b7541bc8253e60e8fcdffc6"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"regex",
|
||||
@@ -3235,9 +3172,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-contexts"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "896c1ab62dbfe1746fb262bbf72e6feb2fb9dfb2c14709077bf71beb532e44b2"
|
||||
checksum = "9b88a90baa654d7f0e1f4b667f6b434293d9f72c71bef16b197c76af5b7d5803"
|
||||
dependencies = [
|
||||
"hostname",
|
||||
"libc",
|
||||
@@ -3249,11 +3186,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-core"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5f5abf20c42cb1593ec1638976e2647da55f79bccac956444c1707b6cce259a"
|
||||
checksum = "0ac170a5bba8bec6e3339c90432569d89641fa7a3d3e4f44987d24f0762e6adf"
|
||||
dependencies = [
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.2",
|
||||
"sentry-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3262,9 +3199,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-debug-images"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b88bbe6a760d5724bb40689827e82e8db1e275947df2c59abe171bfc30bb671"
|
||||
checksum = "dd9646a972b57896d4a92ed200cf76139f8e30b3cfd03b6662ae59926d26633c"
|
||||
dependencies = [
|
||||
"findshlibs",
|
||||
"sentry-core",
|
||||
@@ -3272,9 +3209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-panic"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0260dcb52562b6a79ae7702312a26dba94b79fb5baee7301087529e5ca4e872e"
|
||||
checksum = "6127d3d304ba5ce0409401e85aae538e303a569f8dbb031bf64f9ba0f7174346"
|
||||
dependencies = [
|
||||
"sentry-backtrace",
|
||||
"sentry-core",
|
||||
@@ -3282,9 +3219,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-tower"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d669616d5d5279b5712febfc80c343acc3695e499de0d101ed70fceacadf37f2"
|
||||
checksum = "61c5253dc4ad89863a866b93aeaaac1c9d60f2f774663b5024afe2d57e0a101c"
|
||||
dependencies = [
|
||||
"sentry-core",
|
||||
"tower-layer",
|
||||
@@ -3293,9 +3230,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-tracing"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1c035f3a0a8671ae1a231c5b457abb68b71acba2bf3054dab2a09a9d4ea487e"
|
||||
checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"sentry-backtrace",
|
||||
@@ -3306,13 +3243,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-types"
|
||||
version = "0.48.1"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82d8e81058ec155992191f61c7b29bfa7b2cf12012131e7cdc0678020898a7c9"
|
||||
checksum = "56780cb5597d676bf22e6c11d1f062eb4def46390ea3bfb047bcbcf7dfd19bdb"
|
||||
dependencies = [
|
||||
"debugid",
|
||||
"hex",
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3934,9 +3871,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4214,7 +4151,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -8,7 +8,7 @@ members = [
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "2026.5.0-rc2"
|
||||
version = "2026.5.0-rc1"
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
description = "Making authentication simple."
|
||||
edition = "2024"
|
||||
@@ -44,7 +44,7 @@ hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
metrics = "= 0.24.5"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.3", default-features = false }
|
||||
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
|
||||
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
@@ -67,7 +67,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.40", features = ["fips"] }
|
||||
sentry = { version = "= 0.48.0", default-features = false, features = [
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -97,7 +97,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.3", features = ["full", "tracing"] }
|
||||
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
@@ -115,9 +115,9 @@ url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
|
||||
which = "= 8.0.2"
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc2", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.5.0-rc2", path = "./packages/client-rust" }
|
||||
ak-common = { package = "authentik-common", version = "2026.5.0-rc2", path = "./packages/ak-common", default-features = false }
|
||||
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 }
|
||||
|
||||
[workspace.lints.rust]
|
||||
ambiguous_negative_literals = "warn"
|
||||
|
||||
2
Makefile
2
Makefile
@@ -160,7 +160,7 @@ endif
|
||||
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
|
||||
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' ${PWD}/pyproject.toml
|
||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' ${PWD}/authentik/__init__.py
|
||||
$(SED_INPLACE) "s/version = \"${current_version}\"/version = \"$(version)\"/" ${PWD}/Cargo.toml ${PWD}/Cargo.lock
|
||||
$(SED_INPLACE) "s/version = \"${current_version}\"/version = \"$(version)\"" ${PWD}/Cargo.toml ${PWD}/Cargo.lock
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
$(SED_INPLACE) "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/package-lock.json ${PWD}/web/package.json ${PWD}/web/package-lock.json
|
||||
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.5.0-rc2"
|
||||
VERSION = "2026.5.0-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -42,29 +42,11 @@ def validate_auth(header: bytes, format="bearer") -> str | None:
|
||||
return auth_credentials
|
||||
|
||||
|
||||
class VirtualUser(AnonymousUser):
|
||||
is_active = True
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def all_roles(self):
|
||||
return []
|
||||
|
||||
|
||||
class IPCUser(VirtualUser):
|
||||
class IPCUser(AnonymousUser):
|
||||
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
|
||||
|
||||
username = "authentik:system"
|
||||
is_active = True
|
||||
is_superuser = True
|
||||
|
||||
@property
|
||||
@@ -80,6 +62,17 @@ class IPCUser(VirtualUser):
|
||||
def has_module_perms(self, module):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def all_roles(self):
|
||||
return []
|
||||
|
||||
|
||||
class TokenAuthentication(BaseAuthentication):
|
||||
"""Token-based authentication using HTTP Bearer authentication"""
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
from django.db.models import F, QuerySet
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class NullsAwareOrderingFilter(OrderingFilter):
|
||||
"""OrderingFilter that sorts NULL values consistently.
|
||||
|
||||
For any nullable field, NULLs are treated as the smallest possible value:
|
||||
- ascending → NULLs appear first (nulls_first=True)
|
||||
- descending → NULLs appear last (nulls_last=True)
|
||||
"""
|
||||
|
||||
def _nullable_field_names(self, queryset: QuerySet) -> set[str]:
|
||||
return {f.name for f in queryset.model._meta.get_fields() if hasattr(f, "null") and f.null}
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView):
|
||||
queryset = super().filter_queryset(request, queryset, view)
|
||||
ordering = queryset.query.order_by
|
||||
if not ordering:
|
||||
return queryset
|
||||
nullable = self._nullable_field_names(queryset)
|
||||
new_ordering = []
|
||||
changed = False
|
||||
for term in ordering:
|
||||
name = term.lstrip("-")
|
||||
if name in nullable:
|
||||
changed = True
|
||||
if term.startswith("-"):
|
||||
new_ordering.append(F(name).desc(nulls_last=True))
|
||||
else:
|
||||
new_ordering.append(F(name).asc(nulls_first=True))
|
||||
else:
|
||||
new_ordering.append(term)
|
||||
return queryset.order_by(*new_ordering) if changed else queryset
|
||||
@@ -1,59 +0,0 @@
|
||||
from django.db.models import OrderBy
|
||||
from django.test import TestCase
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from authentik.api.ordering import NullsAwareOrderingFilter
|
||||
from authentik.core.models import Token, User
|
||||
|
||||
|
||||
class MockView:
|
||||
ordering_fields = "__all__"
|
||||
ordering = None
|
||||
|
||||
|
||||
class TestNullsAwareOrderingFilter(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.filter = NullsAwareOrderingFilter()
|
||||
self.view = MockView()
|
||||
factory = APIRequestFactory()
|
||||
self._req = lambda ordering: Request(factory.get("/", {"ordering": ordering}))
|
||||
|
||||
def _order_by(self, model, ordering):
|
||||
qs = model.objects.all()
|
||||
return self.filter.filter_queryset(self._req(ordering), qs, self.view).query.order_by
|
||||
|
||||
def test_nullable_asc_nulls_first(self):
|
||||
"""Ascending sort on a nullable field rewrites to nulls_first=True."""
|
||||
(expr,) = self._order_by(User, "last_login")
|
||||
self.assertIsInstance(expr, OrderBy)
|
||||
self.assertFalse(expr.descending)
|
||||
self.assertTrue(expr.nulls_first)
|
||||
|
||||
def test_nullable_desc_nulls_last(self):
|
||||
"""Descending sort on a nullable field rewrites to nulls_last=True."""
|
||||
(expr,) = self._order_by(User, "-last_login")
|
||||
self.assertIsInstance(expr, OrderBy)
|
||||
self.assertTrue(expr.descending)
|
||||
self.assertTrue(expr.nulls_last)
|
||||
|
||||
def test_non_nullable_passes_through(self):
|
||||
"""Non-nullable fields are left as plain string terms."""
|
||||
(expr,) = self._order_by(User, "username")
|
||||
self.assertEqual(expr, "username")
|
||||
|
||||
def test_mixed_ordering(self):
|
||||
"""Only nullable terms are rewritten; non-nullable terms pass through unchanged."""
|
||||
first, second = self._order_by(User, "username,-last_login")
|
||||
self.assertEqual(first, "username")
|
||||
self.assertIsInstance(second, OrderBy)
|
||||
self.assertTrue(second.descending)
|
||||
self.assertTrue(second.nulls_last)
|
||||
|
||||
def test_expires_nullable(self):
|
||||
"""expires on ExpiringModel is nullable and is rewritten correctly."""
|
||||
(expr,) = self._order_by(Token, "-expires")
|
||||
self.assertIsInstance(expr, OrderBy)
|
||||
self.assertTrue(expr.descending)
|
||||
self.assertTrue(expr.nulls_last)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Serializer mixin for managed models"""
|
||||
|
||||
from json import JSONDecodeError, loads
|
||||
from typing import cast
|
||||
|
||||
from django.conf import settings
|
||||
@@ -45,7 +44,6 @@ class BlueprintUploadSerializer(PassiveSerializer):
|
||||
|
||||
file = FileField(required=False)
|
||||
path = CharField(required=False)
|
||||
context = CharField(required=False, allow_blank=True)
|
||||
|
||||
def validate_path(self, path: str) -> str:
|
||||
"""Ensure the path (if set) specified is retrievable"""
|
||||
@@ -56,18 +54,6 @@ class BlueprintUploadSerializer(PassiveSerializer):
|
||||
raise ValidationError(_("Blueprint file does not exist"))
|
||||
return path
|
||||
|
||||
def validate_context(self, context: str) -> dict:
|
||||
"""Parse context as a JSON object"""
|
||||
if not context:
|
||||
return {}
|
||||
try:
|
||||
parsed = loads(context)
|
||||
except JSONDecodeError as exc:
|
||||
raise ValidationError(_("Context must be valid JSON")) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValidationError(_("Context must be a JSON object"))
|
||||
return parsed
|
||||
|
||||
|
||||
class ManagedSerializer:
|
||||
"""Managed Serializer"""
|
||||
@@ -217,7 +203,10 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@extend_schema(
|
||||
request={"multipart/form-data": BlueprintUploadSerializer},
|
||||
responses={200: BlueprintImportResultSerializer},
|
||||
responses={
|
||||
204: BlueprintImportResultSerializer,
|
||||
400: BlueprintImportResultSerializer,
|
||||
},
|
||||
)
|
||||
@action(url_path="import", detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
|
||||
@validate(
|
||||
@@ -235,8 +224,7 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
||||
).retrieve_file()
|
||||
else:
|
||||
raise ValidationError("Either path or file must be set")
|
||||
context = body.validated_data.get("context") or {}
|
||||
importer = Importer.from_string(string_contents, context)
|
||||
importer = Importer.from_string(string_contents)
|
||||
|
||||
check_blueprint_perms(importer.blueprint, request.user)
|
||||
|
||||
@@ -244,13 +232,21 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
import_response = self.BlueprintImportResultSerializer(
|
||||
data={
|
||||
"logs": [LogEventSerializer(log).data for log in logs],
|
||||
"success": valid,
|
||||
"logs": [],
|
||||
"success": False,
|
||||
}
|
||||
)
|
||||
import_response.is_valid(raise_exception=True)
|
||||
|
||||
if valid:
|
||||
import_response.initial_data["success"] = importer.apply()
|
||||
import_response.is_valid()
|
||||
import_response.initial_data["logs"] = [LogEventSerializer(log).data for log in logs]
|
||||
import_response.initial_data["success"] = valid
|
||||
import_response.is_valid()
|
||||
if not valid:
|
||||
return Response(data=import_response.initial_data, status=200)
|
||||
|
||||
successful = importer.apply()
|
||||
import_response.initial_data["success"] = successful
|
||||
import_response.is_valid()
|
||||
if not successful:
|
||||
return Response(data=import_response.initial_data, status=200)
|
||||
return Response(data=import_response.initial_data, status=200)
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"""Test blueprints v1 api"""
|
||||
|
||||
from json import dumps, loads
|
||||
from json import loads
|
||||
from tempfile import NamedTemporaryFile, mkdtemp
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from yaml import dump
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.invitation.models import InvitationStage
|
||||
from authentik.stages.user_write.models import UserWriteStage
|
||||
|
||||
TMP = mkdtemp("authentik-blueprints")
|
||||
|
||||
@@ -85,121 +80,3 @@ class TestBlueprintsV1API(APITestCase):
|
||||
res.content.decode(),
|
||||
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
|
||||
)
|
||||
|
||||
def test_api_import_with_context(self):
|
||||
"""Test that the import endpoint applies the supplied context to the real blueprint"""
|
||||
slug = f"invitation-enrollment-{generate_id()}"
|
||||
flow_name = f"Invitation Enrollment {generate_id()}"
|
||||
stage_name = f"invitation-stage-{generate_id()}"
|
||||
user_type = "internal"
|
||||
continue_without_invitation = True
|
||||
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={
|
||||
"path": "example/flows-invitation-enrollment-minimal.yaml",
|
||||
"context": dumps(
|
||||
{
|
||||
"flow_slug": slug,
|
||||
"flow_name": flow_name,
|
||||
"stage_name": stage_name,
|
||||
"continue_flow_without_invitation": continue_without_invitation,
|
||||
"user_type": user_type,
|
||||
}
|
||||
),
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertTrue(res.json()["success"])
|
||||
|
||||
flow = Flow.objects.get(slug=slug)
|
||||
self.assertEqual(flow.name, flow_name)
|
||||
self.assertEqual(flow.title, flow_name)
|
||||
|
||||
invitation_stage = InvitationStage.objects.get(name=stage_name)
|
||||
self.assertEqual(
|
||||
invitation_stage.continue_flow_without_invitation,
|
||||
continue_without_invitation,
|
||||
)
|
||||
|
||||
user_write_stage = UserWriteStage.objects.get(
|
||||
name=f"invitation-enrollment-user-write-{slug}"
|
||||
)
|
||||
self.assertEqual(user_write_stage.user_type, user_type)
|
||||
self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")
|
||||
|
||||
def test_api_import_blank_path(self):
|
||||
"""Validator returns empty path unchanged (covers api.py:53)."""
|
||||
with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
|
||||
file.write(dump({"version": 1, "entries": []}))
|
||||
file.flush()
|
||||
file.seek(0)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={"path": "", "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_api_import_invalid_blueprint_returns_result_payload(self):
|
||||
"""Invalid blueprint content returns a result payload instead of a 400 response."""
|
||||
file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
|
||||
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertFalse(res.json()["success"])
|
||||
self.assertGreater(len(res.json()["logs"]), 0)
|
||||
|
||||
def test_api_import_unknown_path(self):
|
||||
"""Path not in available blueprints is rejected (covers api.py:56)."""
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={"path": "does/not/exist.yaml"},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Blueprint file does not exist", res.content.decode())
|
||||
|
||||
def test_api_import_blank_context(self):
|
||||
"""Blank context is normalized to empty dict (covers api.py:62)."""
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={
|
||||
"path": "example/flows-invitation-enrollment-minimal.yaml",
|
||||
"context": "",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_api_import_invalid_json_context(self):
|
||||
"""Malformed JSON context raises ValidationError (covers api.py:65-66)."""
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={
|
||||
"path": "example/flows-invitation-enrollment-minimal.yaml",
|
||||
"context": "{not json",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Context must be valid JSON", res.content.decode())
|
||||
|
||||
def test_api_import_non_object_context(self):
|
||||
"""JSON context that isn't an object is rejected (covers api.py:68)."""
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={
|
||||
"path": "example/flows-invitation-enrollment-minimal.yaml",
|
||||
"context": "[1, 2, 3]",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Context must be a JSON object", res.content.decode())
|
||||
|
||||
@@ -32,19 +32,19 @@ from authentik.rbac.decorators import permission_required
|
||||
class UserAgentDeviceDict(TypedDict):
|
||||
"""User agent device"""
|
||||
|
||||
brand: str | None = None
|
||||
brand: str
|
||||
family: str
|
||||
model: str | None = None
|
||||
model: str
|
||||
|
||||
|
||||
class UserAgentOSDict(TypedDict):
|
||||
"""User agent os"""
|
||||
|
||||
family: str
|
||||
major: str | None = None
|
||||
minor: str | None = None
|
||||
patch: str | None = None
|
||||
patch_minor: str | None = None
|
||||
major: str
|
||||
minor: str
|
||||
patch: str
|
||||
patch_minor: str
|
||||
|
||||
|
||||
class UserAgentBrowserDict(TypedDict):
|
||||
|
||||
@@ -246,25 +246,6 @@ class GroupSerializer(ModelSerializer):
|
||||
)
|
||||
return superuser
|
||||
|
||||
def validate_users(self, users: list) -> list:
|
||||
"""Require add_user_to_group permission when adding new members via group PATCH."""
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return users
|
||||
if not self.instance:
|
||||
return users
|
||||
# BulkManyRelatedField returns raw PKs, not model instances
|
||||
current_user_pks = set(self.instance.users.values_list("pk", flat=True))
|
||||
new_users = [u for u in users if u not in current_user_pks]
|
||||
if not new_users:
|
||||
return users
|
||||
has_perm = request.user.has_perm(
|
||||
"authentik_core.add_user_to_group"
|
||||
) or request.user.has_perm("authentik_core.add_user_to_group", self.instance)
|
||||
if not has_perm:
|
||||
raise ValidationError(_("User does not have permission to add members to this group."))
|
||||
return users
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = [
|
||||
|
||||
@@ -297,36 +297,6 @@ class UserSerializer(ModelSerializer):
|
||||
raise ValidationError(_("Setting a user to internal service account is not allowed."))
|
||||
return user_type
|
||||
|
||||
def validate_groups(self, groups: list) -> list:
|
||||
"""Require enable_group_superuser permission when adding a user to a superuser group."""
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return groups
|
||||
current_groups = set(self.instance.groups.all()) if self.instance else set()
|
||||
for group in groups:
|
||||
if not group.is_superuser:
|
||||
continue
|
||||
if group in current_groups:
|
||||
continue
|
||||
if not request.user.has_perm("authentik_core.enable_group_superuser"):
|
||||
raise ValidationError(
|
||||
_("User does not have permission to add members to a superuser group.")
|
||||
)
|
||||
return groups
|
||||
|
||||
def validate_roles(self, roles: list) -> list:
|
||||
"""Require change_role permission when assigning new roles to a user."""
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return roles
|
||||
current_roles = set(self.instance.roles.all()) if self.instance else set()
|
||||
new_roles = [r for r in roles if r not in current_roles]
|
||||
if not new_roles:
|
||||
return roles
|
||||
if not request.user.has_perm("authentik_rbac.change_role"):
|
||||
raise ValidationError(_("User does not have permission to assign roles."))
|
||||
return roles
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
raise ValidationError(_("Can't modify internal service account users"))
|
||||
|
||||
@@ -158,58 +158,3 @@ class TestGroupsAPI(APITestCase):
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_patch_users_no_perm(self):
|
||||
"""PATCH group with new users without add_user_to_group must be rejected."""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"users": [self.user.pk]},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_patch_users_with_global_perm(self):
|
||||
"""PATCH group with new users with global add_user_to_group must succeed."""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group")
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"users": [self.user.pk]},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_users_with_obj_perm(self):
|
||||
"""PATCH group with new users with object-level add_user_to_group must succeed."""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"users": [self.user.pk]},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_existing_users_no_perm(self):
|
||||
"""PATCH group keeping existing membership without add_user_to_group must succeed."""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(self.user)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
|
||||
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"users": [self.user.pk]},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
@@ -12,7 +12,6 @@ from authentik.brands.models import Brand
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
AuthenticatedSession,
|
||||
Group,
|
||||
Session,
|
||||
Token,
|
||||
User,
|
||||
@@ -26,7 +25,6 @@ from authentik.core.tests.utils import (
|
||||
)
|
||||
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.stages.email.models import EmailStage
|
||||
|
||||
INVALID_PASSWORD_HASH = "not-a-valid-hash"
|
||||
@@ -941,79 +939,3 @@ class TestUsersAPI(APITestCase):
|
||||
self.assertIn(user2.pk, pks)
|
||||
# Verify user2 comes before user1 in descending order
|
||||
self.assertLess(pks.index(user2.pk), pks.index(user1.pk))
|
||||
|
||||
|
||||
class TestUsersAPIGroupRoleValidation(APITestCase):
|
||||
"""Test that PATCH /api/v3/core/users/{pk}/ enforces group and role permission checks."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.actor = create_test_user()
|
||||
self.target = create_test_user()
|
||||
|
||||
def _patch(self, data: dict):
|
||||
self.client.force_login(self.actor)
|
||||
return self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.target.pk}),
|
||||
data=data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_patch_superuser_group_no_perm(self):
|
||||
"""Assigning a superuser group without enable_group_superuser must be rejected."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
res = self._patch({"groups": [str(group.pk)]})
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_patch_superuser_group_with_perm(self):
|
||||
"""Assigning a superuser group with enable_group_superuser must succeed."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.enable_group_superuser")
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
res = self._patch({"groups": [str(group.pk)]})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_non_superuser_group_no_perm(self):
|
||||
"""Assigning a non-superuser group without special permission must succeed."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=False)
|
||||
res = self._patch({"groups": [str(group.pk)]})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_existing_superuser_group_no_perm(self):
|
||||
"""Keeping an existing superuser group membership without the permission must succeed."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
self.target.groups.add(group)
|
||||
res = self._patch({"groups": [str(group.pk)]})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_role_no_perm(self):
|
||||
"""Assigning a new role without change_role must be rejected."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
role = Role.objects.create(name=generate_id())
|
||||
res = self._patch({"roles": [str(role.pk)]})
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_patch_role_with_perm(self):
|
||||
"""Assigning a new role with change_role must succeed."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
self.actor.assign_perms_to_managed_role("authentik_rbac.change_role")
|
||||
role = Role.objects.create(name=generate_id())
|
||||
res = self._patch({"roles": [str(role.pk)]})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_patch_existing_role_no_perm(self):
|
||||
"""Keeping an existing role without change_role must succeed."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
|
||||
role = Role.objects.create(name=generate_id())
|
||||
self.target.roles.add(role)
|
||||
res = self._patch({"roles": [str(role.pk)]})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
@@ -7,7 +7,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@@ -44,6 +44,7 @@ from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_ME
|
||||
|
||||
|
||||
class AgentConnectorSerializer(ConnectorSerializer):
|
||||
|
||||
class Meta(ConnectorSerializer.Meta):
|
||||
model = AgentConnector
|
||||
fields = ConnectorSerializer.Meta.fields + [
|
||||
@@ -62,6 +63,7 @@ class AgentConnectorSerializer(ConnectorSerializer):
|
||||
|
||||
|
||||
class MDMConfigSerializer(PassiveSerializer):
|
||||
|
||||
platform = ChoiceField(choices=OSFamily.choices)
|
||||
enrollment_token = PrimaryKeyRelatedField(
|
||||
queryset=EnrollmentToken.objects.including_expired().all()
|
||||
@@ -87,6 +89,7 @@ class AgentConnectorViewSet(
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
|
||||
queryset = AgentConnector.objects.all()
|
||||
serializer_class = AgentConnectorSerializer
|
||||
search_fields = ["name"]
|
||||
@@ -118,8 +121,6 @@ class AgentConnectorViewSet(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
authentication_classes=[AgentEnrollmentAuth],
|
||||
# Permissions are handled via AgentEnrollmentAuth
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
def enroll(self, request: Request):
|
||||
token: EnrollmentToken = request.auth
|
||||
@@ -150,13 +151,7 @@ class AgentConnectorViewSet(
|
||||
request=OpenApiTypes.NONE,
|
||||
responses=AgentConfigSerializer(),
|
||||
)
|
||||
@action(
|
||||
methods=["GET"],
|
||||
detail=False,
|
||||
authentication_classes=[AgentAuth],
|
||||
# Permissions are handled via AgentAuth
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
@action(methods=["GET"], detail=False, authentication_classes=[AgentAuth])
|
||||
def agent_config(self, request: Request):
|
||||
token: DeviceToken = request.auth
|
||||
connector: AgentConnector = token.device.connector.agentconnector
|
||||
@@ -170,13 +165,7 @@ class AgentConnectorViewSet(
|
||||
request=DeviceFacts(),
|
||||
responses={204: OpenApiResponse(description="Successfully checked in")},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
authentication_classes=[AgentAuth],
|
||||
# Permissions are handled via AgentAuth
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
|
||||
def check_in(self, request: Request):
|
||||
token: DeviceToken = request.auth
|
||||
data = DeviceFacts(data=request.data)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
@@ -10,7 +9,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import VirtualUser, validate_auth
|
||||
from authentik.api.authentication import IPCUser, validate_auth
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
@@ -26,18 +25,9 @@ LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
|
||||
|
||||
class DeviceUser(VirtualUser):
|
||||
|
||||
class DeviceUser(IPCUser):
|
||||
username = "authentik:endpoints:device"
|
||||
|
||||
def has_perm(self, perm: str, obj: Model | None = None) -> bool:
|
||||
if perm in [
|
||||
"authentik_core.view_user",
|
||||
"authentik_core.view_group",
|
||||
]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AgentEnrollmentAuth(BaseAuthentication):
|
||||
|
||||
|
||||
@@ -223,17 +223,3 @@ class TestAgentAPI(APITestCase):
|
||||
data={"platform": OSFamily.macOS, "enrollment_token": self.token.pk},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_users_list(self):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_other_api_forbidden(self):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -2,7 +2,6 @@ from django.urls import reverse
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from structlog.stdlib import get_logger
|
||||
@@ -26,13 +25,7 @@ class AgentConnectorViewSetMixin:
|
||||
request=OpenApiTypes.NONE,
|
||||
responses=AgentAuthenticationResponse(),
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
authentication_classes=[AgentAuth],
|
||||
# Permissions are handled via AgentAuth
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
|
||||
@enterprise_action
|
||||
def auth_ia(self, request: Request) -> Response:
|
||||
token: DeviceToken = request.auth
|
||||
|
||||
@@ -1,72 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMProvider
|
||||
from authentik.sources.oauth.models import UserOAuthSourceConnection
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode
|
||||
|
||||
|
||||
class SCIMProviderSerializerMixin:
|
||||
|
||||
def _get_token(self, instance: SCIMProvider) -> UserOAuthSourceConnection | None:
|
||||
user = instance.auth_oauth_user
|
||||
conn = UserOAuthSourceConnection.objects.filter(
|
||||
user=user, source=instance.auth_oauth
|
||||
).first()
|
||||
return conn
|
||||
|
||||
def get_auth_oauth_token_last_updated(self, instance: SCIMProvider) -> datetime | None:
|
||||
conn = self._get_token(instance)
|
||||
return conn.last_updated if conn else None
|
||||
|
||||
def get_auth_oauth_token_expires(self, instance: SCIMProvider) -> datetime | None:
|
||||
conn = self._get_token(instance)
|
||||
return conn.expires if conn else None
|
||||
|
||||
def get_auth_oauth_url_callback(self, instance: SCIMProvider) -> str | None:
|
||||
if (
|
||||
instance.auth_mode
|
||||
in [
|
||||
SCIMAuthenticationMode.TOKEN,
|
||||
SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
]
|
||||
or not instance.backchannel_application
|
||||
):
|
||||
return None
|
||||
relative_url = reverse(
|
||||
"authentik_enterprise_providers_scim:callback",
|
||||
kwargs={"application_slug": instance.backchannel_application.slug},
|
||||
)
|
||||
if "request" not in self.context:
|
||||
return relative_url
|
||||
return self.context["request"].build_absolute_uri(relative_url)
|
||||
|
||||
def get_auth_oauth_url_start(self, instance: SCIMProvider) -> str | None:
|
||||
if (
|
||||
instance.auth_mode
|
||||
in [
|
||||
SCIMAuthenticationMode.TOKEN,
|
||||
SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
]
|
||||
or not instance.backchannel_application
|
||||
):
|
||||
return None
|
||||
relative_url = reverse(
|
||||
"authentik_enterprise_providers_scim:start",
|
||||
kwargs={"application_slug": instance.backchannel_application.slug},
|
||||
)
|
||||
if "request" not in self.context:
|
||||
return relative_url
|
||||
return self.context["request"].build_absolute_uri(relative_url)
|
||||
|
||||
def validate_auth_mode(self, auth_mode: SCIMAuthenticationMode) -> SCIMAuthenticationMode:
|
||||
if auth_mode in [
|
||||
SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
|
||||
]:
|
||||
if auth_mode == SCIMAuthenticationMode.OAUTH:
|
||||
if not LicenseKey.cached_summary().status.is_valid:
|
||||
raise ValidationError(_("Enterprise is required to use the OAuth mode."))
|
||||
return auth_mode
|
||||
|
||||
@@ -7,4 +7,3 @@ class AuthentikEnterpriseProviderSCIMConfig(EnterpriseConfig):
|
||||
label = "authentik_enterprise_providers_scim"
|
||||
verbose_name = "authentik Enterprise.Providers.SCIM"
|
||||
default = True
|
||||
mountpoint = "application/scim/"
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.utils.timezone import now
|
||||
from requests import Request, RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.oauth.constants import GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN
|
||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode
|
||||
from authentik.sources.oauth.clients.base import BaseOAuthClient
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -20,26 +18,23 @@ class SCIMOAuthException(SCIMRequestException):
|
||||
|
||||
|
||||
class SCIMOAuthAuth:
|
||||
|
||||
def __init__(self, provider: SCIMProvider):
|
||||
self.provider = provider
|
||||
self.user = provider.auth_oauth_user
|
||||
self.logger = get_logger().bind()
|
||||
self.connection = self.get_connection()
|
||||
|
||||
def retrieve_token(self, conn: UserOAuthSourceConnection | None) -> dict[str, Any]:
|
||||
def retrieve_token(self):
|
||||
if not self.provider.auth_oauth:
|
||||
return None
|
||||
source: OAuthSource = self.provider.auth_oauth
|
||||
client: BaseOAuthClient = source.source_type.callback_view(request=None).get_client(source)
|
||||
client = OAuth2Client(source, None)
|
||||
access_token_url = source.source_type.access_token_url or ""
|
||||
if source.source_type.urls_customizable and source.access_token_url:
|
||||
access_token_url = source.access_token_url
|
||||
data = client.get_access_token_args(None, None)
|
||||
if self.provider.auth_mode == SCIMAuthenticationMode.OAUTH_SILENT:
|
||||
data["grant_type"] = GRANT_TYPE_PASSWORD
|
||||
elif self.provider.auth_mode == SCIMAuthenticationMode.OAUTH_INTERACTIVE:
|
||||
data["grant_type"] = GRANT_TYPE_REFRESH_TOKEN
|
||||
if not conn:
|
||||
raise SCIMOAuthException(None, "Could not refresh SCIM OAuth token")
|
||||
data["refresh_token"] = conn.refresh_token
|
||||
data["grant_type"] = "password"
|
||||
data.update(self.provider.auth_oauth_params)
|
||||
try:
|
||||
response = client.do_request(
|
||||
@@ -59,14 +54,12 @@ class SCIMOAuthAuth:
|
||||
raise SCIMOAuthException(exc.response, message="Failed to get OAuth token") from exc
|
||||
|
||||
def get_connection(self):
|
||||
if not self.provider.auth_oauth:
|
||||
return None
|
||||
conn = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.provider.auth_oauth, user=self.user
|
||||
token = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.provider.auth_oauth, user=self.user, expires__gt=now()
|
||||
).first()
|
||||
if conn and conn.access_token and conn.expires > now():
|
||||
return conn
|
||||
token = self.retrieve_token(conn)
|
||||
if token and token.access_token:
|
||||
return token
|
||||
token = self.retrieve_token()
|
||||
access_token = token["access_token"]
|
||||
expires_in = int(token.get("expires_in", 0))
|
||||
token, _ = UserOAuthSourceConnection.objects.update_or_create(
|
||||
@@ -74,10 +67,7 @@ class SCIMOAuthAuth:
|
||||
user=self.user,
|
||||
defaults={
|
||||
"access_token": access_token,
|
||||
"refresh_token": token.get("refresh_token"),
|
||||
"expires": now() + timedelta(seconds=expires_in),
|
||||
# When using `update_or_create`, `last_updated` is not updated
|
||||
"last_updated": now(),
|
||||
},
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -14,10 +14,7 @@ def scim_provider_post_save(sender: type[Model], instance: SCIMProvider, created
|
||||
"""Create service account before provider is saved"""
|
||||
identifier = f"ak-providers-scim-{instance.pk}"
|
||||
with audit_ignore():
|
||||
if instance.auth_mode in [
|
||||
SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
|
||||
]:
|
||||
if instance.auth_mode == SCIMAuthenticationMode.OAUTH:
|
||||
user, user_created = User.objects.update_or_create(
|
||||
username=identifier,
|
||||
defaults={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from base64 import b64encode
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
@@ -11,14 +11,17 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.tests.test_license import expiry_valid
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.tenants.models import Tenant
|
||||
from tests.live import create_test_admin_user
|
||||
|
||||
|
||||
class TestSCIMOAuthToken(APITestCase):
|
||||
class SCIMOAuthTests(APITestCase):
|
||||
"""SCIM User tests"""
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
@@ -39,7 +42,7 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
self.provider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
auth_mode=SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
auth_mode=SCIMAuthenticationMode.OAUTH,
|
||||
auth_oauth=self.source,
|
||||
auth_oauth_params={
|
||||
"foo": "bar",
|
||||
@@ -57,9 +60,8 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
self.provider.property_mappings_group.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
|
||||
)
|
||||
self.admin = create_test_admin_user()
|
||||
|
||||
def test_retrieve_token_silent(self):
|
||||
def test_retrieve_token(self):
|
||||
"""Test token retrieval"""
|
||||
with Mocker() as mocker:
|
||||
token = generate_id()
|
||||
@@ -84,44 +86,6 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
)
|
||||
self.assertEqual(mocker.request_history[0].body, "grant_type=password&foo=bar")
|
||||
|
||||
def test_retrieve_token_interactive(self):
|
||||
"""Test token retrieval"""
|
||||
self.provider.auth_mode = SCIMAuthenticationMode.OAUTH_INTERACTIVE
|
||||
self.provider.save()
|
||||
refresh_token = generate_id()
|
||||
access_token = generate_id()
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=self.provider.auth_oauth_user,
|
||||
source=self.source,
|
||||
refresh_token=refresh_token,
|
||||
access_token=access_token,
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
token = generate_id()
|
||||
mocker.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
|
||||
self.provider.scim_auth()
|
||||
conn = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.source,
|
||||
user=self.provider.auth_oauth_user,
|
||||
).first()
|
||||
self.assertIsNotNone(conn)
|
||||
self.assertTrue(conn.is_valid)
|
||||
auth = (
|
||||
b64encode(
|
||||
b":".join((self.source.consumer_key.encode(), self.source.consumer_secret.encode()))
|
||||
)
|
||||
.strip()
|
||||
.decode()
|
||||
)
|
||||
self.assertEqual(
|
||||
mocker.request_history[0].headers["Authorization"],
|
||||
f"Basic {auth}",
|
||||
)
|
||||
self.assertEqual(
|
||||
mocker.request_history[0].body,
|
||||
f"grant_type=refresh_token&refresh_token={refresh_token}&foo=bar",
|
||||
)
|
||||
|
||||
def test_existing_token(self):
|
||||
"""Test existing token"""
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
@@ -134,54 +98,96 @@ class TestSCIMOAuthToken(APITestCase):
|
||||
self.provider.scim_auth()
|
||||
self.assertEqual(len(mocker.request_history), 0)
|
||||
|
||||
def test_interactive_start(self):
|
||||
self.client.force_login(self.admin)
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_enterprise_providers_scim:start",
|
||||
kwargs={
|
||||
"application_slug": self.app.slug,
|
||||
@Mocker()
|
||||
def test_user_create(self, mock: Mocker):
|
||||
"""Test user creation"""
|
||||
scim_id = generate_id()
|
||||
token = generate_id()
|
||||
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=f"{uid} {uid}",
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
self.assertEqual(mock.request_history[1].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[2].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": uid,
|
||||
"formatted": f"{uid} {uid}",
|
||||
"givenName": uid,
|
||||
},
|
||||
)
|
||||
"displayName": f"{uid} {uid}",
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
query = parse_qs(urlparse(res.url).query)
|
||||
self.assertEqual(query["client_id"], [self.source.consumer_key])
|
||||
self.assertEqual(
|
||||
query["redirect_uri"],
|
||||
[f"http://testserver/application/scim/{self.app.slug}/oauth2/callback/"],
|
||||
)
|
||||
self.assertEqual(query["response_type"], ["code"])
|
||||
|
||||
def test_interactive_callback(self):
|
||||
self.client.force_login(self.admin)
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_enterprise_providers_scim:start",
|
||||
kwargs={
|
||||
"application_slug": self.app.slug,
|
||||
},
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_api_create(self):
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(create_test_admin_user())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:scimprovider-list"),
|
||||
{
|
||||
"name": generate_id(),
|
||||
"url": "http://localhost",
|
||||
"auth_mode": "oauth",
|
||||
"auth_oauth": str(self.source.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
query = parse_qs(urlparse(res.url).query)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
with Mocker() as mock:
|
||||
token = generate_id()
|
||||
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
|
||||
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_enterprise_providers_scim:callback",
|
||||
kwargs={
|
||||
"application_slug": self.app.slug,
|
||||
},
|
||||
)
|
||||
+ "?"
|
||||
+ urlencode({"state": query["state"][0], "code": generate_id()})
|
||||
)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
|
||||
conn = UserOAuthSourceConnection.objects.filter(source=self.source).first()
|
||||
self.assertIsNotNone(conn)
|
||||
self.assertTrue(conn.is_valid)
|
||||
@patch(
|
||||
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
|
||||
PropertyMock(return_value=False),
|
||||
)
|
||||
def test_api_create_no_license(self):
|
||||
self.client.force_login(create_test_admin_user())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:scimprovider-list"),
|
||||
{
|
||||
"name": generate_id(),
|
||||
"url": "http://localhost",
|
||||
"auth_mode": "oauth",
|
||||
"auth_oauth": str(self.source.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"auth_mode": ["Enterprise is required to use the OAuth mode."]}
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""SCIM OAuth tests"""
|
||||
|
||||
from unittest.mock import MagicMock, 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.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.tests.test_license import expiry_valid
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class TestSCIMOAuthAPI(APITestCase):
|
||||
"""SCIM User tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = OAuthSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
access_token_url="http://localhost/token", # nosec
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_api_create(self):
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(create_test_admin_user())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:scimprovider-list"),
|
||||
{
|
||||
"name": generate_id(),
|
||||
"url": "http://localhost",
|
||||
"auth_mode": "oauth",
|
||||
"auth_oauth": str(self.source.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
|
||||
PropertyMock(return_value=False),
|
||||
)
|
||||
def test_api_create_no_license(self):
|
||||
self.client.force_login(create_test_admin_user())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:scimprovider-list"),
|
||||
{
|
||||
"name": generate_id(),
|
||||
"url": "http://localhost",
|
||||
"auth_mode": "oauth",
|
||||
"auth_oauth": str(self.source.pk),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"auth_mode": ["Enterprise is required to use the OAuth mode."]}
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""SCIM OAuth tests"""
|
||||
|
||||
from requests_mock import Mocker
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
class TestSCIMOAuthAuth(APITestCase):
|
||||
"""SCIM User tests"""
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
def setUp(self) -> None:
|
||||
# Delete all users and groups as the mocked HTTP responses only return one ID
|
||||
# which will cause errors with multiple users
|
||||
Tenant.objects.update(avatars="none")
|
||||
User.objects.all().exclude_anonymous().delete()
|
||||
Group.objects.all().delete()
|
||||
self.source = OAuthSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
access_token_url="http://localhost/token", # nosec
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
)
|
||||
self.provider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
auth_mode=SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
auth_oauth=self.source,
|
||||
auth_oauth_params={
|
||||
"foo": "bar",
|
||||
},
|
||||
exclude_users_service_account=True,
|
||||
)
|
||||
self.app: Application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
)
|
||||
self.app.backchannel_providers.add(self.provider)
|
||||
self.provider.property_mappings.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
|
||||
)
|
||||
self.provider.property_mappings_group.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
|
||||
)
|
||||
|
||||
@Mocker()
|
||||
def test_user_create(self, mock: Mocker):
|
||||
"""Test user creation"""
|
||||
scim_id = generate_id()
|
||||
token = generate_id()
|
||||
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=f"{uid} {uid}",
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
self.assertEqual(mock.request_history[1].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[2].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": uid,
|
||||
"formatted": f"{uid} {uid}",
|
||||
"givenName": uid,
|
||||
},
|
||||
"displayName": f"{uid} {uid}",
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.enterprise.providers.scim.views import SCIMOAuthStart, SCIMRedirectCallback
|
||||
|
||||
urlpatterns = [
|
||||
path("<slug:application_slug>/oauth2/start/", SCIMOAuthStart.as_view(), name="start"),
|
||||
path(
|
||||
"<slug:application_slug>/oauth2/callback/", SCIMRedirectCallback.as_view(), name="callback"
|
||||
),
|
||||
]
|
||||
@@ -1,70 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
from authentik.sources.oauth.clients.base import BaseOAuthClient
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.registry import RequestKind, registry
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
||||
|
||||
class SCIMOAuthViewMixin:
|
||||
|
||||
provider: SCIMProvider
|
||||
|
||||
def get_client(self, source: OAuthSource, **kwargs) -> BaseOAuthClient:
|
||||
source: OAuthSource = self.provider.auth_oauth
|
||||
source_cls = registry.find(source.provider_type, kind=RequestKind.CALLBACK)
|
||||
if not source_cls.client_class:
|
||||
return super().get_client(source, **kwargs)
|
||||
return source_cls.client_class(source, self.request, **kwargs)
|
||||
|
||||
def _get_scim_provider(self, app_slug: str):
|
||||
app = Application.objects.filter(slug=app_slug).first()
|
||||
if not app:
|
||||
return None
|
||||
provider = SCIMProvider.objects.filter(backchannel_application=app)
|
||||
return provider.first()
|
||||
|
||||
def dispatch(self, request: HttpRequest, application_slug: str):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionDenied()
|
||||
provider = self._get_scim_provider(application_slug)
|
||||
if not provider or not provider.auth_oauth:
|
||||
raise PermissionDenied()
|
||||
if not request.user.has_perm(
|
||||
"authentik_providers_scim.change_scimprovider",
|
||||
provider,
|
||||
):
|
||||
raise PermissionDenied()
|
||||
self.provider = provider
|
||||
return super().dispatch(request, source_slug=provider.auth_oauth.slug)
|
||||
|
||||
|
||||
class SCIMOAuthStart(SCIMOAuthViewMixin, OAuthRedirect):
|
||||
|
||||
def get_callback_url(self, source: OAuthSource):
|
||||
return reverse("authentik_enterprise_providers_scim:callback", kwargs=self.kwargs)
|
||||
|
||||
|
||||
class SCIMRedirectCallback(SCIMOAuthViewMixin, OAuthCallback):
|
||||
|
||||
def redirect_flow_manager(self, client: BaseOAuthClient):
|
||||
expires_in = int(self.token.get("expires_in", 0))
|
||||
UserOAuthSourceConnection.objects.update_or_create(
|
||||
source=self.provider.auth_oauth,
|
||||
user=self.provider.auth_oauth_user,
|
||||
defaults={
|
||||
"access_token": self.token.get("access_token"),
|
||||
"refresh_token": self.token.get("refresh_token"),
|
||||
"expires": now() + timedelta(seconds=expires_in),
|
||||
},
|
||||
)
|
||||
return redirect("authentik_core:if-admin")
|
||||
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -56,9 +55,7 @@ class SignInRequest:
|
||||
_, provider = req.get_app_provider()
|
||||
if not req.wreply:
|
||||
req.wreply = provider.acs_url
|
||||
reply = urlparse(req.wreply)
|
||||
configured = urlparse(provider.acs_url)
|
||||
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
|
||||
if not req.wreply.startswith(provider.acs_url):
|
||||
raise ValueError("Invalid wreply")
|
||||
return req
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -33,9 +32,7 @@ class SignOutRequest:
|
||||
_, provider = req.get_app_provider()
|
||||
if not req.wreply:
|
||||
req.wreply = provider.acs_url
|
||||
reply = urlparse(req.wreply)
|
||||
configured = urlparse(provider.acs_url)
|
||||
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
|
||||
if not req.wreply.startswith(provider.acs_url):
|
||||
raise ValueError("Invalid wreply")
|
||||
return req
|
||||
|
||||
|
||||
@@ -27,27 +27,12 @@ class TestWSFedSignIn(TestCase):
|
||||
name=generate_id(),
|
||||
authorization_flow=self.flow,
|
||||
signing_kp=self.cert,
|
||||
acs_url="https://t.goauthentik.io",
|
||||
audience="foo",
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
name=generate_id(), slug=generate_id(), provider=self.provider
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_wreply(self):
|
||||
request = self.factory.get(
|
||||
"/?wreply=https://t.goauthentik.io/foo&wa=wsignin1.0&wtrealm=foo",
|
||||
user=get_anonymous_user(),
|
||||
)
|
||||
SignInRequest.parse(request)
|
||||
with self.assertRaises(ValueError):
|
||||
request = self.factory.get(
|
||||
"/?wreply=https://t.goauthentik.io.invalid.com&wa=wsignin1.0&wtrealm=foo",
|
||||
user=get_anonymous_user(),
|
||||
)
|
||||
SignInRequest.parse(request)
|
||||
|
||||
def test_token_gen(self):
|
||||
request = self.factory.get("/", user=get_anonymous_user())
|
||||
proc = SignInProcessor(
|
||||
|
||||
@@ -11,9 +11,7 @@ from authentik.events.models import NotificationRule
|
||||
class NotificationRuleSerializer(ModelSerializer):
|
||||
"""NotificationRule Serializer"""
|
||||
|
||||
destination_group_obj = GroupSerializer(
|
||||
read_only=True, source="destination_group", required=False, allow_null=True
|
||||
)
|
||||
destination_group_obj = GroupSerializer(read_only=True, source="destination_group")
|
||||
|
||||
class Meta:
|
||||
model = NotificationRule
|
||||
|
||||
@@ -29,7 +29,6 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Refresh other tabs after successful authentication.")
|
||||
deprecated = True
|
||||
|
||||
|
||||
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
|
||||
|
||||
@@ -9,10 +9,10 @@ from rest_framework.fields import CharField, ListField, SerializerMethodField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
|
||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Serializer for BaseGrantModel and ExpiringBaseGrant"""
|
||||
|
||||
user = UserSerializer()
|
||||
provider = ProviderSerializer()
|
||||
provider = OAuth2ProviderSerializer()
|
||||
scope = ListField(child=CharField())
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -61,11 +61,6 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
url_download_metadata = SerializerMethodField()
|
||||
url_issuer = SerializerMethodField()
|
||||
|
||||
# Unified SAML endpoint (primary)
|
||||
url_unified = SerializerMethodField()
|
||||
url_unified_init = SerializerMethodField()
|
||||
|
||||
# Legacy endpoints (for backward compatibility)
|
||||
url_sso_post = SerializerMethodField()
|
||||
url_sso_redirect = SerializerMethodField()
|
||||
url_sso_init = SerializerMethodField()
|
||||
@@ -102,21 +97,6 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
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:metadata-download",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
def get_url_unified(self, instance: SAMLProvider) -> str:
|
||||
"""Get unified SAML endpoint URL (handles SSO and SLO)"""
|
||||
if "request" not in self._context:
|
||||
return ""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
@@ -125,22 +105,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
|
||||
def get_url_unified_init(self, instance: SAMLProvider) -> str:
|
||||
"""Get IdP-initiated SAML URL"""
|
||||
if "request" not in self._context:
|
||||
return ""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:init",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
def get_url_sso_post(self, instance: SAMLProvider) -> str:
|
||||
"""Get SSO Post URL"""
|
||||
@@ -278,8 +243,6 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_issuer",
|
||||
"url_unified",
|
||||
"url_unified_init",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
"url_sso_init",
|
||||
|
||||
@@ -241,7 +241,7 @@ class SAMLProvider(Provider):
|
||||
"""Use IDP-Initiated SAML flow as launch URL"""
|
||||
try:
|
||||
return reverse(
|
||||
"authentik_providers_saml:init",
|
||||
"authentik_providers_saml:sso-init",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
|
||||
@@ -147,7 +147,7 @@ class AssertionProcessor:
|
||||
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:metadata-download",
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ class MetadataProcessor:
|
||||
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:metadata-download",
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
@@ -81,35 +81,54 @@ class MetadataProcessor:
|
||||
element.text = name_id_format
|
||||
yield element
|
||||
|
||||
def _get_unified_url(self) -> str:
|
||||
"""Get the unified SAML endpoint URL"""
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
|
||||
def get_sso_bindings(self) -> Iterator[Element]:
|
||||
"""Get all SSO Bindings - both point to unified endpoint"""
|
||||
unified_url = self._get_unified_url()
|
||||
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
(SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
(SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding_svc, url in binding_url_map.items():
|
||||
binding, svc = binding_svc
|
||||
if self.force_binding and self.force_binding != binding:
|
||||
continue
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = unified_url
|
||||
element.attrib["Location"] = url
|
||||
yield element
|
||||
|
||||
def get_slo_bindings(self) -> Iterator[Element]:
|
||||
"""Get all SLO Bindings - both point to unified endpoint"""
|
||||
unified_url = self._get_unified_url()
|
||||
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
(SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:slo-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
(SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:slo-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding_svc, url in binding_url_map.items():
|
||||
binding, svc = binding_svc
|
||||
if self.force_binding and self.force_binding != binding:
|
||||
continue
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService")
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = unified_url
|
||||
element.attrib["Location"] = url
|
||||
yield element
|
||||
|
||||
def _prepare_signature(self, entity_descriptor: _Element):
|
||||
|
||||
@@ -4,26 +4,19 @@ from django.urls import path
|
||||
|
||||
from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
from authentik.providers.saml.views import metadata, sso, unified
|
||||
from authentik.providers.saml.views import metadata, sso
|
||||
from authentik.providers.saml.views.sp_slo import (
|
||||
SPInitiatedSLOBindingPOSTView,
|
||||
SPInitiatedSLOBindingRedirectView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Unified Endpoint - handles SSO and SLO based on message type
|
||||
# Base path for Issuer/Entity ID
|
||||
path(
|
||||
"<slug:application_slug>/",
|
||||
unified.SAMLUnifiedView.as_view(),
|
||||
sso.SAMLSSOBindingRedirectView.as_view(),
|
||||
name="base",
|
||||
),
|
||||
# IdP-initiated
|
||||
path(
|
||||
"<slug:application_slug>/init/",
|
||||
sso.SAMLSSOBindingInitView.as_view(),
|
||||
name="init",
|
||||
),
|
||||
# LEGACY Endpoints (backward compatibility)
|
||||
# SSO Bindings
|
||||
path(
|
||||
"<slug:application_slug>/sso/binding/redirect/",
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Unified SAML endpoint - handles SSO and SLO based on message type"""
|
||||
|
||||
from base64 import b64decode
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import NS_MAP
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.providers.saml.views.flows import (
|
||||
REQUEST_KEY_SAML_REQUEST,
|
||||
REQUEST_KEY_SAML_RESPONSE,
|
||||
)
|
||||
from authentik.providers.saml.views.sp_slo import (
|
||||
SPInitiatedSLOBindingPOSTView,
|
||||
SPInitiatedSLOBindingRedirectView,
|
||||
)
|
||||
from authentik.providers.saml.views.sso import (
|
||||
SAMLSSOBindingPOSTView,
|
||||
SAMLSSOBindingRedirectView,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# SAML message type constants
|
||||
SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest"
|
||||
SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest"
|
||||
|
||||
|
||||
def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None:
|
||||
"""Parse SAML request to determine if AuthnRequest or LogoutRequest."""
|
||||
try:
|
||||
if is_post_binding:
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
else:
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
|
||||
root = fromstring(decoded_xml)
|
||||
if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)):
|
||||
return SAML_MESSAGE_TYPE_AUTHN_REQUEST
|
||||
if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)):
|
||||
return SAML_MESSAGE_TYPE_LOGOUT_REQUEST
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SAMLUnifiedView(View):
|
||||
"""Unified SAML endpoint - handles SSO and SLO based on message type.
|
||||
|
||||
The operation type is determined by parsing
|
||||
the incoming SAML message:
|
||||
- AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView)
|
||||
- LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
|
||||
- LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
|
||||
"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Route the request based on SAML message type."""
|
||||
# ak user was not logged in, redirected to login, and is back w POST payload in session
|
||||
if SESSION_KEY_POST in request.session:
|
||||
return self._delegate_to_sso(request, application_slug, is_post_binding=True)
|
||||
|
||||
# Determine binding from HTTP method
|
||||
is_post_binding = request.method == "POST"
|
||||
data = request.POST if is_post_binding else request.GET
|
||||
|
||||
# LogoutResponse - delegate to SLO view (handles it in dispatch)
|
||||
if REQUEST_KEY_SAML_RESPONSE in data:
|
||||
return self._delegate_to_slo(request, application_slug, is_post_binding)
|
||||
|
||||
# Check for SAML request
|
||||
if REQUEST_KEY_SAML_REQUEST not in data:
|
||||
LOGGER.info("SAML payload missing")
|
||||
return bad_request_message(request, "The SAML request payload is missing.")
|
||||
|
||||
# Detect message type and delegate
|
||||
saml_request = data[REQUEST_KEY_SAML_REQUEST]
|
||||
message_type = detect_saml_message_type(saml_request, is_post_binding)
|
||||
|
||||
if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST:
|
||||
return self._delegate_to_sso(request, application_slug, is_post_binding)
|
||||
elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST:
|
||||
return self._delegate_to_slo(request, application_slug, is_post_binding)
|
||||
else:
|
||||
LOGGER.warning("Unknown SAML message type", message_type=message_type)
|
||||
return bad_request_message(
|
||||
request, f"Unsupported SAML message type: {message_type or 'unknown'}"
|
||||
)
|
||||
|
||||
def _delegate_to_sso(
|
||||
self, request: HttpRequest, application_slug: str, is_post_binding: bool
|
||||
) -> HttpResponse:
|
||||
"""Delegate to the appropriate SSO view."""
|
||||
if is_post_binding:
|
||||
view = SAMLSSOBindingPOSTView.as_view()
|
||||
else:
|
||||
view = SAMLSSOBindingRedirectView.as_view()
|
||||
return view(request, application_slug=application_slug)
|
||||
|
||||
def _delegate_to_slo(
|
||||
self, request: HttpRequest, application_slug: str, is_post_binding: bool
|
||||
) -> HttpResponse:
|
||||
"""Delegate to the appropriate SLO view."""
|
||||
if is_post_binding:
|
||||
view = SPInitiatedSLOBindingPOSTView.as_view()
|
||||
else:
|
||||
view = SPInitiatedSLOBindingRedirectView.as_view()
|
||||
return view(request, application_slug=application_slug)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""SCIM Provider API Views"""
|
||||
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
@@ -17,11 +16,6 @@ class SCIMProviderSerializer(
|
||||
):
|
||||
"""SCIMProvider Serializer"""
|
||||
|
||||
auth_oauth_token_last_updated = SerializerMethodField()
|
||||
auth_oauth_token_expires = SerializerMethodField()
|
||||
auth_oauth_url_callback = SerializerMethodField()
|
||||
auth_oauth_url_start = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SCIMProvider
|
||||
fields = [
|
||||
@@ -41,10 +35,6 @@ class SCIMProviderSerializer(
|
||||
"auth_mode",
|
||||
"auth_oauth",
|
||||
"auth_oauth_params",
|
||||
"auth_oauth_token_last_updated",
|
||||
"auth_oauth_token_expires",
|
||||
"auth_oauth_url_callback",
|
||||
"auth_oauth_url_start",
|
||||
"compatibility_mode",
|
||||
"service_provider_config_cache_timeout",
|
||||
"exclude_users_service_account",
|
||||
|
||||
@@ -102,16 +102,4 @@ class Migration(migrations.Migration):
|
||||
verbose_name="SCIM Compatibility Mode",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scimprovider",
|
||||
name="auth_mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("token", "Token"),
|
||||
("oauth", "OAuth (Silent)"),
|
||||
("oauth_interactive", "OAuth (interactive)"),
|
||||
],
|
||||
default="token",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -72,8 +72,7 @@ class SCIMAuthenticationMode(models.TextChoices):
|
||||
"""SCIM authentication modes"""
|
||||
|
||||
TOKEN = "token", _("Token")
|
||||
OAUTH_SILENT = "oauth", _("OAuth (Silent)")
|
||||
OAUTH_INTERACTIVE = "oauth_interactive", _("OAuth (interactive)")
|
||||
OAUTH = "oauth", _("OAuth")
|
||||
|
||||
|
||||
class SCIMCompatibilityMode(models.TextChoices):
|
||||
@@ -145,10 +144,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
)
|
||||
|
||||
def scim_auth(self) -> AuthBase:
|
||||
if self.auth_mode in [
|
||||
SCIMAuthenticationMode.OAUTH_SILENT,
|
||||
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
|
||||
]:
|
||||
if self.auth_mode == SCIMAuthenticationMode.OAUTH:
|
||||
try:
|
||||
from authentik.enterprise.providers.scim.auth_oauth2 import SCIMOAuthAuth
|
||||
|
||||
|
||||
@@ -187,7 +187,6 @@ SPECTACULAR_SETTINGS = {
|
||||
"PolicyEngineMode": "authentik.policies.models.PolicyEngineMode",
|
||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||
"RedirectURITypeEnum": "authentik.providers.oauth2.models.RedirectURIType",
|
||||
"SAMLBindingsEnum": "authentik.providers.saml.models.SAMLBindings",
|
||||
"SAMLLogoutMethods": "authentik.providers.saml.models.SAMLLogoutMethods",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
@@ -221,7 +220,7 @@ REST_FRAMEWORK = {
|
||||
"authentik.api.search.ql.QLSearch",
|
||||
"authentik.rbac.filters.ObjectFilter",
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"authentik.api.ordering.NullsAwareOrderingFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",),
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
|
||||
@@ -33,6 +33,7 @@ class SourceTypeSerializer(PassiveSerializer):
|
||||
profile_url = CharField(read_only=True, allow_null=True)
|
||||
oidc_well_known_url = CharField(read_only=True, allow_null=True)
|
||||
oidc_jwks_url = CharField(read_only=True, allow_null=True)
|
||||
client_secret_required = BooleanField()
|
||||
|
||||
|
||||
class OAuthSourceSerializer(SourceSerializer):
|
||||
@@ -65,6 +66,15 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
)
|
||||
source_type = registry.find_type(provider_type_name)
|
||||
|
||||
if not source_type.client_secret_required and "consumer_secret" not in attrs:
|
||||
attrs["consumer_secret"] = ""
|
||||
if (
|
||||
source_type.client_secret_required
|
||||
and not self.instance
|
||||
and not attrs.get("consumer_secret")
|
||||
):
|
||||
raise ValidationError({"consumer_secret": "This field is required."})
|
||||
|
||||
well_known = attrs.get("oidc_well_known_url") or source_type.oidc_well_known_url
|
||||
inferred_oidc_jwks_url = None
|
||||
|
||||
@@ -149,7 +159,7 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
"authorization_code_auth_method",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"consumer_secret": {"write_only": True},
|
||||
"consumer_secret": {"write_only": True, "allow_blank": True, "required": False},
|
||||
"request_token_url": {"allow_blank": True},
|
||||
"authorization_url": {"allow_blank": True},
|
||||
"access_token_url": {"allow_blank": True},
|
||||
|
||||
@@ -10,6 +10,7 @@ LOGGER = get_logger()
|
||||
|
||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||
"authentik.sources.oauth.types.apple",
|
||||
"authentik.sources.oauth.types.atproto",
|
||||
"authentik.sources.oauth.types.azure_ad",
|
||||
"authentik.sources.oauth.types.discord",
|
||||
"authentik.sources.oauth.types.entra_id",
|
||||
|
||||
@@ -271,6 +271,15 @@ class EntraIDOAuthSource(CreatableType, OAuthSource):
|
||||
verbose_name_plural = _("Entra ID OAuth Sources")
|
||||
|
||||
|
||||
class AtProtoOAuthSource(CreatableType, OAuthSource):
|
||||
"""Social Login using AT Protocol."""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
verbose_name = _("AT Protocol OAuth Source")
|
||||
verbose_name_plural = _("AT Protocol OAuth Sources")
|
||||
|
||||
|
||||
class OpenIDConnectOAuthSource(CreatableType, OAuthSource):
|
||||
"""Login using a Generic OpenID-Connect compliant provider."""
|
||||
|
||||
|
||||
284
authentik/sources/oauth/tests/test_type_atproto.py
Normal file
284
authentik/sources/oauth/tests/test_type_atproto.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""AT Protocol OAuth Source tests"""
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
from jwt import decode, get_unverified_header
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.sources.oauth.api.source import OAuthSourceSerializer
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.atproto import (
|
||||
BSKY_AUTHORIZATION_URL_DEFAULT,
|
||||
BSKY_PAR_URL_DEFAULT,
|
||||
BSKY_PUBLIC_PROFILE_URL_DEFAULT,
|
||||
BSKY_TOKEN_URL_DEFAULT,
|
||||
AtProtoOAuthClient,
|
||||
AtProtoType,
|
||||
)
|
||||
|
||||
ATPROTO_DID = "did:plc:z72i7hdynmk6r22z27h6tvur"
|
||||
ATPROTO_PDS = "https://puffball.us-east.host.bsky.network"
|
||||
ATPROTO_CLIENT_ID = "https://authentik.example/application/o/atproto/client-metadata.json"
|
||||
|
||||
ATPROTO_DID_DOCUMENT = {
|
||||
"id": ATPROTO_DID,
|
||||
"alsoKnownAs": ["at://bsky.app"],
|
||||
"service": [
|
||||
{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": ATPROTO_PDS,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
ATPROTO_PROFILE = {
|
||||
"did": ATPROTO_DID,
|
||||
"handle": "bsky.app",
|
||||
"displayName": "Bluesky",
|
||||
}
|
||||
CUSTOM_ISSUER = "https://auth.example"
|
||||
CUSTOM_AUTHORIZATION_URL = f"{CUSTOM_ISSUER}/oauth/authorize"
|
||||
CUSTOM_PAR_URL = f"{CUSTOM_ISSUER}/oauth/par"
|
||||
CUSTOM_TOKEN_URL = f"{CUSTOM_ISSUER}/oauth/token"
|
||||
CUSTOM_PROFILE_URL = f"{CUSTOM_ISSUER}/xrpc/app.bsky.actor.getProfile"
|
||||
|
||||
|
||||
def private_key_pem() -> str:
|
||||
"""Generate an ES256 private key for DPoP tests."""
|
||||
return (
|
||||
ec.generate_private_key(ec.SECP256R1())
|
||||
.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||
.decode()
|
||||
)
|
||||
|
||||
|
||||
class TestTypeAtProto(SimpleTestCase):
|
||||
"""AT Protocol OAuth Source tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = OAuthSource(
|
||||
name="test",
|
||||
slug="test",
|
||||
provider_type="atproto",
|
||||
consumer_key=ATPROTO_CLIENT_ID,
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def get_request(self):
|
||||
request = self.factory.get("/")
|
||||
request.session = {}
|
||||
return request
|
||||
|
||||
def get_callback_request(self, issuer: str = "https://bsky.social"):
|
||||
request = self.factory.get(f"/?state=state&iss={issuer}&code=code")
|
||||
request.session = {
|
||||
"authentik/sources/oauth/atproto/test": {
|
||||
"state": "state",
|
||||
"code_verifier": "verifier",
|
||||
"issuer": issuer,
|
||||
"private_key": private_key_pem(),
|
||||
"dpop_nonce": "nonce-1",
|
||||
"login_hint": None,
|
||||
"expected_did": None,
|
||||
}
|
||||
}
|
||||
return request
|
||||
|
||||
def test_enroll_context(self):
|
||||
"""Test AT Protocol enrollment context."""
|
||||
ak_context = AtProtoType().get_base_user_properties(
|
||||
source=self.source,
|
||||
info=ATPROTO_PROFILE,
|
||||
)
|
||||
self.assertEqual(ak_context["username"], ATPROTO_PROFILE["handle"])
|
||||
self.assertEqual(ak_context["name"], ATPROTO_PROFILE["displayName"])
|
||||
self.assertIsNone(ak_context["email"])
|
||||
|
||||
def test_serializer_allows_missing_secret(self):
|
||||
"""Test AT Protocol sources can be created without a client secret."""
|
||||
serializer = OAuthSourceSerializer()
|
||||
validated = serializer.validate(
|
||||
{
|
||||
"name": "test-atproto",
|
||||
"slug": "test-atproto",
|
||||
"provider_type": "atproto",
|
||||
"consumer_key": ATPROTO_CLIENT_ID,
|
||||
}
|
||||
)
|
||||
self.assertEqual(validated["consumer_secret"], "")
|
||||
|
||||
@Mocker()
|
||||
def test_redirect_uses_par_dpop_pkce_and_no_secret(self, mock: Mocker):
|
||||
"""Test authorization starts with a DPoP-bound pushed authorization request."""
|
||||
mock.post(
|
||||
BSKY_PAR_URL_DEFAULT,
|
||||
json={"request_uri": "urn:request:123"},
|
||||
headers={"DPoP-Nonce": "nonce-1"},
|
||||
)
|
||||
|
||||
request = self.get_request()
|
||||
client = AtProtoOAuthClient(self.source, request, callback="/callback/")
|
||||
redirect_url = client.get_redirect_url({"scope": ["atproto", "transition:generic"]})
|
||||
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
parsed_query = parse_qs(parsed_redirect.query)
|
||||
parsed_redirect_url = (
|
||||
f"{parsed_redirect.scheme}://{parsed_redirect.netloc}{parsed_redirect.path}"
|
||||
)
|
||||
self.assertEqual(parsed_redirect_url, BSKY_AUTHORIZATION_URL_DEFAULT)
|
||||
self.assertEqual(parsed_query["client_id"], [ATPROTO_CLIENT_ID])
|
||||
self.assertEqual(parsed_query["request_uri"], ["urn:request:123"])
|
||||
self.assertEqual(len(mock.request_history), 1)
|
||||
par_request = mock.request_history[0]
|
||||
self.assertIn("DPoP", par_request.headers)
|
||||
self.assertEqual(par_request.text.count("client_secret"), 0)
|
||||
self.assertIn("client_id=https%3A%2F%2Fauthentik.example", par_request.text)
|
||||
self.assertIn("code_challenge_method=S256", par_request.text)
|
||||
self.assertIn("scope=atproto+transition%3Ageneric", par_request.text)
|
||||
|
||||
header = get_unverified_header(par_request.headers["DPoP"])
|
||||
payload = decode(par_request.headers["DPoP"], options={"verify_signature": False})
|
||||
self.assertEqual(header["typ"], "dpop+jwt")
|
||||
self.assertEqual(header["alg"], "ES256")
|
||||
self.assertEqual(payload["htm"], "POST")
|
||||
self.assertEqual(payload["htu"], BSKY_PAR_URL_DEFAULT)
|
||||
|
||||
@Mocker()
|
||||
def test_custom_urls_override_bluesky_defaults(self, mock: Mocker):
|
||||
"""Test non-Bluesky AT Protocol endpoint configuration."""
|
||||
source = OAuthSource(
|
||||
name="test",
|
||||
slug="test",
|
||||
provider_type="atproto",
|
||||
consumer_key=ATPROTO_CLIENT_ID,
|
||||
authorization_url=CUSTOM_AUTHORIZATION_URL,
|
||||
request_token_url=CUSTOM_PAR_URL,
|
||||
access_token_url=CUSTOM_TOKEN_URL,
|
||||
profile_url=CUSTOM_PROFILE_URL,
|
||||
)
|
||||
mock.post(
|
||||
CUSTOM_PAR_URL,
|
||||
json={"request_uri": "urn:request:custom"},
|
||||
headers={"DPoP-Nonce": "nonce-custom"},
|
||||
)
|
||||
|
||||
request = self.get_request()
|
||||
client = AtProtoOAuthClient(source, request, callback="/callback/")
|
||||
redirect_url = client.get_redirect_url({"scope": ["atproto"]})
|
||||
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
self.assertEqual(
|
||||
f"{parsed_redirect.scheme}://{parsed_redirect.netloc}{parsed_redirect.path}",
|
||||
CUSTOM_AUTHORIZATION_URL,
|
||||
)
|
||||
self.assertEqual(request.session[client.session_key]["issuer"], CUSTOM_ISSUER)
|
||||
self.assertEqual(mock.request_history[0].url, CUSTOM_PAR_URL)
|
||||
|
||||
@Mocker()
|
||||
def test_access_token_validates_subject_scope_and_issuer(self, mock: Mocker):
|
||||
"""Test callback token response validation."""
|
||||
mock.post(
|
||||
BSKY_TOKEN_URL_DEFAULT,
|
||||
json={
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"token_type": "DPoP",
|
||||
"expires_in": 300,
|
||||
"sub": ATPROTO_DID,
|
||||
"scope": "atproto transition:generic",
|
||||
},
|
||||
headers={"DPoP-Nonce": "nonce-2"},
|
||||
)
|
||||
mock.get(f"https://plc.directory/{ATPROTO_DID}", json=ATPROTO_DID_DOCUMENT)
|
||||
mock.get(
|
||||
f"{ATPROTO_PDS}/.well-known/oauth-protected-resource",
|
||||
json={"authorization_servers": ["https://bsky.social"]},
|
||||
)
|
||||
|
||||
request = self.get_callback_request()
|
||||
|
||||
client = AtProtoOAuthClient(self.source, request, callback="/callback/")
|
||||
token = client.get_access_token()
|
||||
|
||||
self.assertEqual(token["sub"], ATPROTO_DID)
|
||||
self.assertEqual(token["pds_url"], ATPROTO_PDS)
|
||||
token_request = mock.request_history[0]
|
||||
self.assertIn("DPoP", token_request.headers)
|
||||
self.assertEqual(token_request.text.count("client_secret"), 0)
|
||||
self.assertIn("code_verifier=verifier", token_request.text)
|
||||
|
||||
@Mocker()
|
||||
def test_access_token_rejects_non_dpop_token_type(self, mock: Mocker):
|
||||
"""Test callback rejects token responses that are not DPoP-bound."""
|
||||
mock.post(
|
||||
BSKY_TOKEN_URL_DEFAULT,
|
||||
json={
|
||||
"access_token": "access",
|
||||
"token_type": "Bearer",
|
||||
"sub": ATPROTO_DID,
|
||||
"scope": "atproto",
|
||||
},
|
||||
headers={"DPoP-Nonce": "nonce-2"},
|
||||
)
|
||||
|
||||
client = AtProtoOAuthClient(self.source, self.get_callback_request(), callback="/callback/")
|
||||
token = client.get_access_token()
|
||||
|
||||
self.assertEqual(token["error"], "Token response did not include a DPoP token type.")
|
||||
|
||||
@Mocker()
|
||||
def test_did_web_localhost_uses_http_for_local_testing(self, mock: Mocker):
|
||||
"""Test did:web localhost resolution for the local AT Protocol simulator."""
|
||||
mock.get("http://localhost:8787/.well-known/did.json", json={"id": "did:web:localhost"})
|
||||
client = AtProtoOAuthClient(self.source, self.get_request(), callback="/callback/")
|
||||
document = client.get_did_document("did:web:localhost%3A8787")
|
||||
self.assertEqual(document["id"], "did:web:localhost")
|
||||
|
||||
@Mocker()
|
||||
def test_profile_info(self, mock: Mocker):
|
||||
"""Test public Bluesky profile lookup."""
|
||||
mock.get(BSKY_PUBLIC_PROFILE_URL_DEFAULT, json=ATPROTO_PROFILE)
|
||||
client = AtProtoOAuthClient(self.source, self.get_request(), callback="/callback/")
|
||||
profile = client.get_profile_info({"sub": ATPROTO_DID})
|
||||
self.assertEqual(profile["did"], ATPROTO_DID)
|
||||
self.assertEqual(profile["handle"], "bsky.app")
|
||||
|
||||
@Mocker()
|
||||
def test_profile_info_with_transition_email(self, mock: Mocker):
|
||||
"""Test private session email lookup when transition:email is granted."""
|
||||
mock.get(BSKY_PUBLIC_PROFILE_URL_DEFAULT, json=ATPROTO_PROFILE)
|
||||
mock.get(
|
||||
f"{ATPROTO_PDS}/xrpc/com.atproto.server.getSession",
|
||||
json={"email": "user@example.com", "emailConfirmed": True},
|
||||
headers={"DPoP-Nonce": "nonce-3"},
|
||||
)
|
||||
request = self.get_request()
|
||||
request.session = {
|
||||
"authentik/sources/oauth/atproto/test": {
|
||||
"state": "state",
|
||||
"code_verifier": "verifier",
|
||||
"issuer": "https://bsky.social",
|
||||
"private_key": private_key_pem(),
|
||||
"dpop_nonce": "nonce-2",
|
||||
"login_hint": None,
|
||||
"expected_did": None,
|
||||
}
|
||||
}
|
||||
client = AtProtoOAuthClient(self.source, request, callback="/callback/")
|
||||
profile = client.get_profile_info(
|
||||
{
|
||||
"sub": ATPROTO_DID,
|
||||
"scope": "atproto transition:email",
|
||||
"access_token": "access",
|
||||
"pds_url": ATPROTO_PDS,
|
||||
}
|
||||
)
|
||||
self.assertEqual(profile["email"], "user@example.com")
|
||||
session_request = mock.request_history[1]
|
||||
self.assertEqual(session_request.headers["Authorization"], "DPoP access")
|
||||
payload = decode(session_request.headers["DPoP"], options={"verify_signature": False})
|
||||
self.assertIn("ath", payload)
|
||||
486
authentik/sources/oauth/types/atproto.py
Normal file
486
authentik/sources/oauth/types/atproto.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""AT Protocol OAuth Views"""
|
||||
|
||||
from time import time
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunparse
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA256, Hash
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
load_pem_private_key,
|
||||
)
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import constant_time_compare, get_random_string
|
||||
from jwt import encode
|
||||
from jwt.algorithms import ECAlgorithm
|
||||
from jwt.utils import base64url_encode
|
||||
from requests.exceptions import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.utils import pkce_s256_challenge
|
||||
from authentik.sources.oauth.clients.base import BaseOAuthClient
|
||||
from authentik.sources.oauth.models import OAuthSource, PKCEMethod
|
||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# Bluesky defaults. AT Protocol OAuth requires these endpoint roles, but
|
||||
# non-Bluesky deployments can use different hosts through the source URL fields.
|
||||
BSKY_AUTHORIZATION_URL_DEFAULT = "https://bsky.social/oauth/authorize"
|
||||
BSKY_TOKEN_URL_DEFAULT = "https://bsky.social/oauth/token" # nosec
|
||||
BSKY_PAR_URL_DEFAULT = "https://bsky.social/oauth/par"
|
||||
BSKY_ISSUER_DEFAULT = "https://bsky.social"
|
||||
BSKY_PUBLIC_PROFILE_URL_DEFAULT = "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"
|
||||
HTTP_STATUS_BAD_REQUEST = 400
|
||||
|
||||
SESSION_KEY_ATPROTO = "authentik/sources/oauth/atproto"
|
||||
|
||||
|
||||
class AtProtoOAuthClient(BaseOAuthClient):
|
||||
"""AT Protocol OAuth client.
|
||||
|
||||
AT Protocol looks like OAuth2 from a distance, but the required security
|
||||
profile is different enough that sharing the generic OAuth2 client would
|
||||
hide important behavior: PAR is mandatory, access tokens are DPoP-bound,
|
||||
public clients use metadata URLs instead of secrets, and the token subject
|
||||
is the user's DID rather than an OIDC userinfo subject.
|
||||
"""
|
||||
|
||||
def get_client_id(self) -> str:
|
||||
"""Return the public client metadata URL."""
|
||||
return self.source.consumer_key
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
return f"{SESSION_KEY_ATPROTO}/{self.source.slug}"
|
||||
|
||||
def get_authorization_url(self) -> str:
|
||||
if self.source.source_type.urls_customizable and self.source.authorization_url:
|
||||
return self.source.authorization_url
|
||||
return self.source.source_type.authorization_url or BSKY_AUTHORIZATION_URL_DEFAULT
|
||||
|
||||
def get_token_url(self) -> str:
|
||||
if self.source.source_type.urls_customizable and self.source.access_token_url:
|
||||
return self.source.access_token_url
|
||||
return self.source.source_type.access_token_url or BSKY_TOKEN_URL_DEFAULT
|
||||
|
||||
def get_par_url(self) -> str:
|
||||
if self.source.source_type.urls_customizable and self.source.request_token_url:
|
||||
return self.source.request_token_url
|
||||
return self.source.source_type.request_token_url or BSKY_PAR_URL_DEFAULT
|
||||
|
||||
def get_issuer(self) -> str:
|
||||
parsed_url = urlparse(self.get_authorization_url())
|
||||
if parsed_url.scheme and parsed_url.netloc:
|
||||
return f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
return BSKY_ISSUER_DEFAULT
|
||||
|
||||
def get_redirect_args(self) -> dict[str, str]:
|
||||
"""AT Protocol redirects are built from PAR responses instead."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_redirect_url(self, parameters=None):
|
||||
"""Create a PAR request and redirect with request_uri."""
|
||||
request_uri = self.create_pushed_authorization_request(parameters or {})
|
||||
parsed_url = urlparse(self.get_authorization_url())
|
||||
parsed_args = parse_qs(parsed_url.query)
|
||||
args = {
|
||||
"client_id": self.get_client_id(),
|
||||
"request_uri": request_uri,
|
||||
}
|
||||
args.update(parsed_args)
|
||||
params = urlencode(args, quote_via=quote, doseq=True)
|
||||
return urlunparse(parsed_url._replace(query=params))
|
||||
|
||||
def create_pushed_authorization_request(self, parameters: dict[str, Any]) -> str:
|
||||
"""Create the pushed authorization request and persist session data."""
|
||||
state = get_random_string(32)
|
||||
code_verifier = generate_id(length=128)
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
login_hint = parameters.pop("login_hint", None)
|
||||
scope = parameters.pop("scope", [])
|
||||
if isinstance(scope, str):
|
||||
scopes = scope.split()
|
||||
else:
|
||||
scopes = list(scope)
|
||||
if "atproto" not in scopes:
|
||||
scopes.append("atproto")
|
||||
|
||||
# The DPoP key and PKCE verifier must survive the browser redirect so
|
||||
# the callback can prove it is the same client that created the PAR.
|
||||
session_data = {
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"issuer": self.get_issuer(),
|
||||
"private_key": private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.PKCS8,
|
||||
NoEncryption(),
|
||||
).decode(),
|
||||
"dpop_nonce": None,
|
||||
"login_hint": login_hint,
|
||||
"expected_did": self.resolve_identifier(login_hint) if login_hint else None,
|
||||
}
|
||||
self.request.session[self.session_key] = session_data
|
||||
|
||||
# AT Protocol starts the browser flow with a PAR request. The browser
|
||||
# only receives a request_uri, not the full authorization parameters.
|
||||
body = {
|
||||
"client_id": self.get_client_id(),
|
||||
"response_type": "code",
|
||||
"redirect_uri": self.request.build_absolute_uri(self.callback),
|
||||
"scope": " ".join(sorted(set(scopes))),
|
||||
"state": state,
|
||||
"code_challenge": pkce_s256_challenge(code_verifier),
|
||||
"code_challenge_method": PKCEMethod.S256,
|
||||
}
|
||||
if login_hint:
|
||||
body["login_hint"] = login_hint
|
||||
body.update(parameters)
|
||||
response = self.request_with_dpop("post", self.get_par_url(), data=body)
|
||||
try:
|
||||
request_uri = response.json().get("request_uri")
|
||||
except ValueError as exc:
|
||||
raise RequestException("PAR response was not valid JSON", response=response) from exc
|
||||
if not request_uri:
|
||||
raise RequestException("PAR response did not include request_uri", response=response)
|
||||
return request_uri
|
||||
|
||||
def get_access_token(self, **request_kwargs) -> dict[str, Any] | None:
|
||||
"""Fetch the initial access token from the callback code."""
|
||||
session_data = self.request.session.get(self.session_key)
|
||||
if not session_data:
|
||||
LOGGER.warning("No AT Protocol OAuth session found")
|
||||
return {"error": "No AT Protocol OAuth session found."}
|
||||
if not constant_time_compare(session_data["state"], self.get_request_arg("state", "")):
|
||||
LOGGER.warning("AT Protocol OAuth state check failed")
|
||||
return {"error": "State check failed."}
|
||||
issuer = self.get_request_arg("iss")
|
||||
if not issuer or not constant_time_compare(session_data["issuer"], issuer):
|
||||
LOGGER.warning("AT Protocol OAuth issuer check failed", issuer=issuer)
|
||||
return {"error": "Issuer check failed."}
|
||||
code = self.get_request_arg("code")
|
||||
if not code:
|
||||
return {"error": self.get_request_arg("error_description") or "No token received."}
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.get_client_id(),
|
||||
"redirect_uri": self.request.build_absolute_uri(self.callback),
|
||||
"code": code,
|
||||
"code_verifier": session_data["code_verifier"],
|
||||
}
|
||||
try:
|
||||
response = self.request_with_dpop("post", self.get_token_url(), data=data)
|
||||
token = response.json()
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("AT Protocol token response was not valid JSON", exc=exc)
|
||||
return None
|
||||
except RequestException as exc:
|
||||
LOGGER.warning(
|
||||
"Unable to fetch AT Protocol access token",
|
||||
exc=exc,
|
||||
response=exc.response.text if exc.response is not None else str(exc),
|
||||
)
|
||||
return None
|
||||
|
||||
validation_error = self.validate_token_response(token, session_data, issuer)
|
||||
if validation_error:
|
||||
return {"error": validation_error}
|
||||
return token
|
||||
|
||||
def validate_token_response(
|
||||
self,
|
||||
token: dict[str, Any],
|
||||
session_data: dict[str, Any],
|
||||
issuer: str,
|
||||
) -> str | None:
|
||||
"""Validate AT Protocol token claims and attach the verified PDS URL."""
|
||||
# The token response identifies the account by DID. That DID becomes
|
||||
# the stable source connection identifier in authentik.
|
||||
did = token.get("sub")
|
||||
if not did:
|
||||
return "Token response did not include an account DID."
|
||||
if "atproto" not in token.get("scope", "").split():
|
||||
return "Token response did not include the atproto scope."
|
||||
if token.get("token_type") != "DPoP":
|
||||
return "Token response did not include a DPoP token type."
|
||||
expected_did = session_data.get("expected_did")
|
||||
if expected_did and not constant_time_compare(expected_did, did):
|
||||
LOGGER.warning("AT Protocol OAuth subject check failed", expected=expected_did, did=did)
|
||||
return "Subject check failed."
|
||||
# Verify the DID document's PDS points back to the authorization server
|
||||
# that issued the callback, otherwise a token could claim another DID.
|
||||
pds_url = self.get_pds_url_for_subject(did, issuer)
|
||||
if not pds_url:
|
||||
return "Issuer is not authoritative for this account."
|
||||
token["pds_url"] = pds_url
|
||||
return None
|
||||
|
||||
def get_profile_info(self, token: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Fetch public profile data for the authenticated DID."""
|
||||
did = token.get("sub")
|
||||
if not did:
|
||||
return None
|
||||
profile_url = BSKY_PUBLIC_PROFILE_URL_DEFAULT
|
||||
if self.source.source_type.urls_customizable and self.source.profile_url:
|
||||
profile_url = self.source.profile_url
|
||||
response = self.session.get(profile_url, params={"actor": did})
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning(
|
||||
"Unable to fetch AT Protocol profile",
|
||||
exc=exc,
|
||||
response=exc.response.text if exc.response is not None else str(exc),
|
||||
)
|
||||
return {"did": did}
|
||||
profile = response.json()
|
||||
profile["did"] = did
|
||||
if "transition:email" in token.get("scope", "").split() and token.get("pds_url"):
|
||||
profile.update(self.get_session_info(token))
|
||||
return profile
|
||||
|
||||
def request_with_dpop(self, method: str, url: str, **kwargs):
|
||||
"""Make a DPoP request, retrying once when the server provides a fresh nonce."""
|
||||
response = self.do_dpop_request(method, url, **kwargs)
|
||||
if response.status_code == HTTP_STATUS_BAD_REQUEST and response.headers.get("DPoP-Nonce"):
|
||||
self.update_dpop_nonce(response.headers["DPoP-Nonce"])
|
||||
response = self.do_dpop_request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
nonce = response.headers.get("DPoP-Nonce")
|
||||
if not nonce:
|
||||
raise RequestException("DPoP response did not include DPoP-Nonce", response=response)
|
||||
self.update_dpop_nonce(nonce)
|
||||
return response
|
||||
|
||||
def get_session_info(self, token: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Fetch private session data when transition:email was granted."""
|
||||
pds_url = token["pds_url"].rstrip("/")
|
||||
session_url = f"{pds_url}/xrpc/com.atproto.server.getSession"
|
||||
headers = {
|
||||
"Authorization": f"DPoP {token['access_token']}",
|
||||
}
|
||||
response = self.do_dpop_request(
|
||||
"get",
|
||||
session_url,
|
||||
headers=headers,
|
||||
access_token=token["access_token"],
|
||||
)
|
||||
if response.status_code == HTTP_STATUS_BAD_REQUEST and response.headers.get("DPoP-Nonce"):
|
||||
self.update_dpop_nonce(response.headers["DPoP-Nonce"])
|
||||
response = self.do_dpop_request(
|
||||
"get",
|
||||
session_url,
|
||||
headers=headers,
|
||||
access_token=token["access_token"],
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning(
|
||||
"Unable to fetch AT Protocol session info",
|
||||
exc=exc,
|
||||
response=exc.response.text if exc.response is not None else str(exc),
|
||||
)
|
||||
return {}
|
||||
nonce = response.headers.get("DPoP-Nonce")
|
||||
if nonce:
|
||||
self.update_dpop_nonce(nonce)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("AT Protocol session response was not valid JSON", exc=exc)
|
||||
return {}
|
||||
|
||||
def do_dpop_request(self, method: str, url: str, **kwargs):
|
||||
access_token = kwargs.pop("access_token", None)
|
||||
headers = dict(kwargs.pop("headers", {}))
|
||||
headers["Accept"] = "application/json"
|
||||
headers["DPoP"] = self.build_dpop_proof(method, url, access_token)
|
||||
return self.session.request(method, url, headers=headers, **kwargs)
|
||||
|
||||
def build_dpop_proof(self, method: str, url: str, access_token: str | None = None) -> str:
|
||||
session_data = self.request.session[self.session_key]
|
||||
private_key = load_pem_private_key(session_data["private_key"].encode(), password=None)
|
||||
if not isinstance(private_key, EllipticCurvePrivateKey):
|
||||
raise TypeError("DPoP private key must be an EC key")
|
||||
payload = {
|
||||
"jti": generate_id(),
|
||||
"htm": method.upper(),
|
||||
"htu": url,
|
||||
"iat": int(time()),
|
||||
}
|
||||
if session_data.get("dpop_nonce"):
|
||||
payload["nonce"] = session_data["dpop_nonce"]
|
||||
if access_token:
|
||||
# Resource requests bind the proof to the access token with ath.
|
||||
digest = Hash(SHA256())
|
||||
digest.update(access_token.encode())
|
||||
payload["ath"] = base64url_encode(digest.finalize()).decode()
|
||||
public_jwk = ECAlgorithm.to_jwk(private_key.public_key(), as_dict=True)
|
||||
public_jwk.pop("kid", None)
|
||||
return encode(
|
||||
payload,
|
||||
private_key,
|
||||
algorithm="ES256",
|
||||
headers={
|
||||
"typ": "dpop+jwt",
|
||||
"jwk": public_jwk,
|
||||
},
|
||||
)
|
||||
|
||||
def update_dpop_nonce(self, nonce: str) -> None:
|
||||
session_data = self.request.session[self.session_key]
|
||||
session_data["dpop_nonce"] = nonce
|
||||
self.request.session[self.session_key] = session_data
|
||||
|
||||
def get_request_arg(self, key: str, default: Any | None = None) -> Any:
|
||||
if self.request.method == "POST":
|
||||
return self.request.POST.get(key, default)
|
||||
return self.request.GET.get(key, default)
|
||||
|
||||
def resolve_identifier(self, identifier: str | None) -> str | None:
|
||||
"""Resolve a handle or DID to a DID."""
|
||||
if not identifier:
|
||||
return None
|
||||
if identifier.startswith("did:"):
|
||||
return identifier
|
||||
response = self.session.get(
|
||||
f"{self.get_issuer()}/xrpc/com.atproto.identity.resolveHandle",
|
||||
params={"handle": identifier.removeprefix("@")},
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning(
|
||||
"Unable to resolve AT Protocol login hint",
|
||||
identifier=identifier,
|
||||
exc=exc,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return response.json().get("did")
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("AT Protocol handle resolution response was not valid JSON", exc=exc)
|
||||
return None
|
||||
|
||||
def get_pds_url_for_subject(self, did: str, issuer: str) -> str | None:
|
||||
"""Verify that the DID's PDS resolves to the callback issuer."""
|
||||
try:
|
||||
did_document = self.get_did_document(did)
|
||||
pds_url = self.get_pds_url(did_document)
|
||||
if not pds_url:
|
||||
LOGGER.warning("DID document does not include an atproto PDS", did=did)
|
||||
return None
|
||||
resource_metadata = self.session.get(
|
||||
f"{pds_url.rstrip('/')}/.well-known/oauth-protected-resource"
|
||||
)
|
||||
resource_metadata.raise_for_status()
|
||||
try:
|
||||
authorization_servers = resource_metadata.json().get("authorization_servers", [])
|
||||
except ValueError as exc:
|
||||
raise RequestException(
|
||||
"OAuth protected resource metadata was not valid JSON",
|
||||
response=resource_metadata,
|
||||
) from exc
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to verify AT Protocol issuer", did=did, issuer=issuer, exc=exc)
|
||||
return None
|
||||
if issuer in authorization_servers:
|
||||
return pds_url
|
||||
return None
|
||||
|
||||
def get_did_document(self, did: str) -> dict[str, Any]:
|
||||
if did.startswith("did:plc:"):
|
||||
response = self.session.get(f"https://plc.directory/{did}")
|
||||
elif did.startswith("did:web:"):
|
||||
# did:web resolves by fetching a DID document from the hostname in the DID.
|
||||
# The AT Protocol local simulator uses did:web:localhost, which cannot use
|
||||
# HTTPS locally; real did:web identities should resolve over HTTPS.
|
||||
did_parts = [unquote(part) for part in did.removeprefix("did:web:").split(":")]
|
||||
host = did_parts[0]
|
||||
path = "/".join(did_parts[1:])
|
||||
scheme = "http" if host.startswith(("localhost", "127.0.0.1")) else "https"
|
||||
did_path = f"{path}/did.json" if path else ".well-known/did.json"
|
||||
response = self.session.get(f"{scheme}://{host}/{did_path}")
|
||||
else:
|
||||
raise RequestException(f"Unsupported DID method: {did}")
|
||||
response.raise_for_status()
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise RequestException("DID document was not valid JSON", response=response) from exc
|
||||
|
||||
def get_pds_url(self, did_document: dict[str, Any]) -> str | None:
|
||||
for service in did_document.get("service", []):
|
||||
if service.get("id") == "#atproto_pds":
|
||||
return service.get("serviceEndpoint")
|
||||
if service.get("type") == "AtprotoPersonalDataServer":
|
||||
return service.get("serviceEndpoint")
|
||||
return None
|
||||
|
||||
|
||||
class AtProtoOAuthRedirect(OAuthRedirect):
|
||||
"""AT Protocol OAuth redirect."""
|
||||
|
||||
client_class = AtProtoOAuthClient
|
||||
|
||||
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
|
||||
return {
|
||||
"scope": ["atproto"],
|
||||
}
|
||||
|
||||
|
||||
class AtProtoOAuthCallback(OAuthCallback):
|
||||
"""AT Protocol OAuth callback."""
|
||||
|
||||
client_class = AtProtoOAuthClient
|
||||
|
||||
def get_callback_url(self, source: OAuthSource) -> str:
|
||||
return reverse(
|
||||
"authentik_sources_oauth:oauth-client-callback",
|
||||
kwargs={"source_slug": source.slug},
|
||||
)
|
||||
|
||||
def get_user_id(self, info: dict[str, Any]) -> str | None:
|
||||
return info.get("did")
|
||||
|
||||
|
||||
@registry.register()
|
||||
class AtProtoType(SourceType):
|
||||
"""AT Protocol Type definition"""
|
||||
|
||||
callback_view = AtProtoOAuthCallback
|
||||
redirect_view = AtProtoOAuthRedirect
|
||||
verbose_name = "AT Protocol"
|
||||
name = "atproto"
|
||||
|
||||
# Defaults target Bluesky. They are editable because other AT Protocol
|
||||
# authorization servers can expose the same endpoint roles on different URLs.
|
||||
authorization_url = BSKY_AUTHORIZATION_URL_DEFAULT
|
||||
request_token_url = BSKY_PAR_URL_DEFAULT
|
||||
access_token_url = BSKY_TOKEN_URL_DEFAULT
|
||||
profile_url = BSKY_PUBLIC_PROFILE_URL_DEFAULT
|
||||
|
||||
urls_customizable = True
|
||||
pkce = PKCEMethod.S256
|
||||
client_secret_required = False
|
||||
|
||||
def icon_url(self) -> str:
|
||||
return static("authentik/sources/atproto.svg")
|
||||
|
||||
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
|
||||
return {
|
||||
"username": info.get("handle") or info.get("did"),
|
||||
"email": info.get("email"),
|
||||
"name": info.get("displayName") or info.get("handle"),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Source type manager"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -41,6 +42,8 @@ class SourceType:
|
||||
oidc_jwks_url: str | None = None
|
||||
pkce: PKCEMethod = PKCEMethod.NONE
|
||||
|
||||
client_secret_required = True
|
||||
|
||||
authorization_code_auth_method: AuthorizationCodeAuthMethod = (
|
||||
AuthorizationCodeAuthMethod.BASIC_AUTH
|
||||
)
|
||||
@@ -113,7 +116,7 @@ class SourceTypeRegistry:
|
||||
)
|
||||
return found_type
|
||||
|
||||
def find(self, type_name: str, kind: RequestKind) -> type[OAuthCallback | OAuthRedirect]:
|
||||
def find(self, type_name: str, kind: RequestKind) -> Callable:
|
||||
"""Find fitting Source Type"""
|
||||
found_type = self.find_type(type_name)
|
||||
if kind == RequestKind.CALLBACK:
|
||||
|
||||
@@ -15,7 +15,6 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.sources.oauth.clients.base import BaseOAuthClient
|
||||
from authentik.sources.oauth.models import (
|
||||
GroupOAuthSourceConnection,
|
||||
OAuthSource,
|
||||
@@ -30,7 +29,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||
"Base OAuth callback view."
|
||||
|
||||
source: OAuthSource
|
||||
token: dict[str, Any] | None = None
|
||||
token: dict | None = None
|
||||
|
||||
def dispatch(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
|
||||
"""View Get handler"""
|
||||
@@ -50,31 +49,20 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||
if "error" in self.token:
|
||||
return self.handle_login_failure(self.token["error"])
|
||||
# Fetch profile info
|
||||
try:
|
||||
res = self.redirect_flow_manager(client)
|
||||
except ValueError as exc:
|
||||
# if we're authenticated and not in a source stage and this new flag is enabled,
|
||||
# just continue
|
||||
if self.request.user.is_authenticated:
|
||||
pass
|
||||
return self.handle_login_failure(exc.args[0])
|
||||
return res
|
||||
|
||||
def redirect_flow_manager(self, client: BaseOAuthClient) -> HttpResponse:
|
||||
try:
|
||||
raw_info = client.get_profile_info(self.token)
|
||||
if raw_info is None:
|
||||
raise ValueError("Could not retrieve profile.")
|
||||
return self.handle_login_failure("Could not retrieve profile.")
|
||||
except JSONDecodeError as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Failed to JSON-decode profile.",
|
||||
raw_profile=exc.doc,
|
||||
).from_http(self.request)
|
||||
raise ValueError("Could not retrieve profile.") from None
|
||||
return self.handle_login_failure("Could not retrieve profile.")
|
||||
identifier = self.get_user_id(info=raw_info)
|
||||
if identifier is None:
|
||||
raise ValueError("Could not determine id.")
|
||||
return self.handle_login_failure("Could not determine id.")
|
||||
sfm = OAuthSourceFlowManager(
|
||||
source=self.source,
|
||||
request=self.request,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""authentik saml source processor"""
|
||||
|
||||
from base64 import b64decode
|
||||
from datetime import UTC, datetime
|
||||
from time import mktime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -41,7 +40,6 @@ from authentik.sources.saml.exceptions import (
|
||||
InvalidSignature,
|
||||
MismatchedRequestID,
|
||||
MissingSAMLResponse,
|
||||
SAMLException,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import (
|
||||
@@ -97,7 +95,6 @@ class ResponseProcessor:
|
||||
|
||||
self._verify_request_id()
|
||||
self._verify_status()
|
||||
self._verify_conditions()
|
||||
|
||||
def _decrypt_response(self):
|
||||
"""Decrypt SAMLResponse EncryptedAssertion Element"""
|
||||
@@ -129,20 +126,6 @@ class ResponseProcessor:
|
||||
)
|
||||
self._assertion = decrypted_assertion
|
||||
|
||||
def _verify_conditions(self):
|
||||
conditions = self.get_assertion().find(f"{{{NS_SAML_ASSERTION}}}Conditions")
|
||||
if conditions is None:
|
||||
return
|
||||
_now = now()
|
||||
before = conditions.attrib.get("NotBefore")
|
||||
if before:
|
||||
if datetime.fromisoformat(before).replace(tzinfo=UTC) > _now:
|
||||
raise SAMLException("Assertion is not valid yet or expired.")
|
||||
on_or_after = conditions.attrib.get("NotOnOrAfter")
|
||||
if on_or_after:
|
||||
if datetime.fromisoformat(on_or_after).replace(tzinfo=UTC) < _now:
|
||||
raise SAMLException("Assertion is not valid yet or expired.")
|
||||
|
||||
def _verify_signature(self, signature_node: _Element):
|
||||
"""Verify a single signature node"""
|
||||
xmlsec.tree.add_ids(self._root, ["ID"])
|
||||
@@ -232,9 +215,10 @@ class ResponseProcessor:
|
||||
user has an attribute that refers to our Source for cleanup. The user is also deleted
|
||||
on logout and periodically."""
|
||||
# Create a temporary User
|
||||
name_id_el, name_id = self._get_name_id()
|
||||
name_id = self._get_name_id()
|
||||
username = name_id.text
|
||||
# trim username to ensure it is max 150 chars
|
||||
username = f"ak-{name_id[: USERNAME_MAX_LENGTH - 14]}-transient"
|
||||
username = f"ak-{username[: USERNAME_MAX_LENGTH - 14]}-transient"
|
||||
expiry = mktime(
|
||||
(now() + timedelta_from_string(self._source.temporary_user_delete_after)).timetuple()
|
||||
)
|
||||
@@ -250,18 +234,20 @@ class ResponseProcessor:
|
||||
},
|
||||
path=self._source.get_user_path(),
|
||||
)
|
||||
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
|
||||
LOGGER.debug("Created temporary user for NameID Transient", username=name_id.text)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id)
|
||||
UserSAMLSourceConnection.objects.create(
|
||||
source=self._source, user=user, identifier=name_id.text
|
||||
)
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
identifier=str(name_id),
|
||||
identifier=str(name_id.text),
|
||||
user_info={
|
||||
"root": self._root,
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id_el,
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={},
|
||||
)
|
||||
@@ -272,7 +258,7 @@ class ResponseProcessor:
|
||||
return self._assertion
|
||||
return self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
|
||||
def _get_name_id(self) -> tuple[Element, str]:
|
||||
def _get_name_id(self) -> Element:
|
||||
"""Get NameID Element"""
|
||||
assertion = self.get_assertion()
|
||||
if assertion is None:
|
||||
@@ -283,11 +269,12 @@ class ResponseProcessor:
|
||||
name_id = subject.find(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
if name_id is None:
|
||||
raise ValueError("NameID element not found")
|
||||
return name_id, "".join(name_id.itertext())
|
||||
return name_id
|
||||
|
||||
def _get_name_id_filter(self) -> dict[str, str]:
|
||||
"""Returns the subject's NameID as a Filter for the `User`"""
|
||||
name_id_el, name_id = self._get_name_id()
|
||||
name_id_el = self._get_name_id()
|
||||
name_id = name_id_el.text
|
||||
if not name_id:
|
||||
raise UnsupportedNameIDFormat("Subject's NameID is empty.")
|
||||
_format = name_id_el.attrib["Format"]
|
||||
@@ -308,26 +295,26 @@ class ResponseProcessor:
|
||||
|
||||
def prepare_flow_manager(self) -> SourceFlowManager:
|
||||
"""Prepare flow plan depending on whether or not the user exists"""
|
||||
name_id_el, name_id = self._get_name_id()
|
||||
name_id = self._get_name_id()
|
||||
# Sanity check, show a warning if NameIDPolicy doesn't match what we go
|
||||
if self._source.name_id_policy != name_id_el.attrib["Format"]:
|
||||
if self._source.name_id_policy != name_id.attrib["Format"]:
|
||||
LOGGER.warning(
|
||||
"NameID from IdP doesn't match our policy",
|
||||
expected=self._source.name_id_policy,
|
||||
got=name_id_el.attrib["Format"],
|
||||
got=name_id.attrib["Format"],
|
||||
)
|
||||
# transient NameIDs are handled separately as they don't have to go through flows.
|
||||
if name_id_el.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
return self._handle_name_id_transient()
|
||||
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
identifier=str(name_id),
|
||||
identifier=str(name_id.text),
|
||||
user_info={
|
||||
"root": self._root,
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id_el,
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={
|
||||
"saml_response": etree.tostring(self._root),
|
||||
|
||||
@@ -4,7 +4,6 @@ from base64 import b64encode
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from django.test import TestCase
|
||||
from freezegun import freeze_time
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_ASSERTION
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_flow
|
||||
@@ -35,7 +34,6 @@ class TestPropertyMappings(TestCase):
|
||||
pre_authentication_flow=create_test_flow(),
|
||||
)
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_user_base_properties(self):
|
||||
"""Test user base properties"""
|
||||
properties = self.source.get_base_user_properties(
|
||||
@@ -63,7 +61,6 @@ class TestPropertyMappings(TestCase):
|
||||
properties = self.source.get_base_group_properties(root=ROOT, group_id=group_id)
|
||||
self.assertEqual(properties, {"name": group_id})
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_user_property_mappings(self):
|
||||
"""Test user property mappings"""
|
||||
self.source.user_property_mappings.add(
|
||||
@@ -97,7 +94,6 @@ class TestPropertyMappings(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_group_property_mappings(self):
|
||||
"""Test group property mappings"""
|
||||
self.source.group_property_mappings.add(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from base64 import b64encode
|
||||
|
||||
from django.test import TestCase
|
||||
from freezegun import freeze_time
|
||||
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
@@ -47,7 +46,6 @@ class TestResponseProcessor(TestCase):
|
||||
):
|
||||
ResponseProcessor(self.source, request).parse()
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_success(self):
|
||||
"""Test success"""
|
||||
request = self.factory.post(
|
||||
@@ -74,7 +72,6 @@ class TestResponseProcessor(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
@freeze_time("2022-10-14T14:16:40Z")
|
||||
def test_success_with_status_message_and_detail(self):
|
||||
"""Test success with StatusMessage and StatusDetail present (should not raise error)"""
|
||||
request = self.factory.post(
|
||||
@@ -91,7 +88,6 @@ class TestResponseProcessor(TestCase):
|
||||
sfm = parser.prepare_flow_manager()
|
||||
self.assertEqual(sfm.user_properties["username"], "jens@goauthentik.io")
|
||||
|
||||
@freeze_time("2022-10-14T14:16:40Z")
|
||||
def test_error_with_message_and_detail(self):
|
||||
"""Test error status with StatusMessage and StatusDetail includes both in error"""
|
||||
request = self.factory.post(
|
||||
@@ -109,7 +105,6 @@ class TestResponseProcessor(TestCase):
|
||||
self.assertIn("User account is disabled", str(ctx.exception))
|
||||
self.assertIn("Authentication failed", str(ctx.exception))
|
||||
|
||||
@freeze_time("2024-08-07T15:48:09.325Z")
|
||||
def test_encrypted_correct(self):
|
||||
"""Test encrypted"""
|
||||
key = load_fixture("fixtures/encrypted-key.pem")
|
||||
@@ -147,7 +142,6 @@ class TestResponseProcessor(TestCase):
|
||||
with self.assertRaises(InvalidEncryption):
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2022-10-14T14:16:40Z")
|
||||
def test_verification_assertion(self):
|
||||
"""Test verifying signature inside assertion"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
@@ -170,7 +164,6 @@ class TestResponseProcessor(TestCase):
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2014-07-17T01:02:18Z")
|
||||
def test_verification_assertion_duplicate(self):
|
||||
"""Test verifying signature inside assertion, where the response has another assertion
|
||||
before our signed assertion"""
|
||||
@@ -193,35 +186,9 @@ class TestResponseProcessor(TestCase):
|
||||
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
self.assertNotEqual(parser._get_name_id()[1], "bad")
|
||||
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
|
||||
self.assertNotEqual(parser._get_name_id().text, "bad")
|
||||
self.assertEqual(parser._get_name_id().text, "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_name_id_comment(self):
|
||||
"""Test comment in name ID"""
|
||||
fixture = load_fixture("fixtures/response_signed_assertion_dup.xml")
|
||||
fixture = fixture.replace(
|
||||
"_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7",
|
||||
"_ce3d2948b4cf20146dee0a0b3dd6f<!--x-->69b6cf86f62d7",
|
||||
)
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
kp = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data=key,
|
||||
)
|
||||
self.source.verification_kp = kp
|
||||
self.source.signed_assertion = True
|
||||
self.source.signed_response = False
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={"SAMLResponse": b64encode(fixture.encode()).decode()},
|
||||
)
|
||||
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
|
||||
|
||||
@freeze_time("2014-07-17T01:02:18Z")
|
||||
def test_verification_response(self):
|
||||
"""Test verifying signature inside response"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
@@ -244,7 +211,6 @@ class TestResponseProcessor(TestCase):
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2024-01-18T06:20:48Z")
|
||||
def test_verification_response_and_assertion(self):
|
||||
"""Test verifying signature inside response and assertion"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
@@ -291,7 +257,6 @@ class TestResponseProcessor(TestCase):
|
||||
with self.assertRaisesMessage(InvalidSignature, ""):
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_verification_no_signature(self):
|
||||
"""Test rejecting response without signature when signed_assertion is True"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
@@ -338,7 +303,6 @@ class TestResponseProcessor(TestCase):
|
||||
with self.assertRaisesMessage(InvalidSignature, ""):
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2025-10-30T05:45:47.619Z")
|
||||
def test_signed_encrypted_response(self):
|
||||
"""Test signed & encrypted response"""
|
||||
verification_key = load_fixture("fixtures/signature_cert2.pem")
|
||||
@@ -366,7 +330,6 @@ class TestResponseProcessor(TestCase):
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
|
||||
@freeze_time("2026-01-21T14:23")
|
||||
def test_transient(self):
|
||||
"""Test SAML transient NameID"""
|
||||
verification_key = load_fixture("fixtures/signature_cert2.pem")
|
||||
|
||||
@@ -4,7 +4,6 @@ from base64 import b64encode
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
from freezegun import freeze_time
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan
|
||||
@@ -27,7 +26,6 @@ class TestViews(TestCase):
|
||||
pre_authentication_flow=create_test_flow(),
|
||||
)
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_enroll(self):
|
||||
"""Enroll"""
|
||||
flow = create_test_flow()
|
||||
@@ -54,7 +52,6 @@ class TestViews(TestCase):
|
||||
plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN)
|
||||
self.assertIsNotNone(plan)
|
||||
|
||||
@freeze_time("2022-10-14T14:15:00")
|
||||
def test_enroll_redirect(self):
|
||||
"""Enroll when attempting to access a provider"""
|
||||
initial_redirect = f"http://{generate_id()}"
|
||||
|
||||
@@ -389,19 +389,17 @@ class ThrottlingMixin(models.Model):
|
||||
"""Check if throttling is enabled"""
|
||||
return self.get_throttle_factor() > 0
|
||||
|
||||
def get_throttle_factor(self) -> float: # pragma: no cover
|
||||
def get_throttle_factor(self): # pragma: no cover
|
||||
"""
|
||||
Returns the throttling factor.
|
||||
"""
|
||||
return getattr(self, "_throttle_factor", 1.0)
|
||||
|
||||
def set_throttle_factor(self, throttle_factor: float) -> None:
|
||||
"""
|
||||
Sets the throttle factor to use. Call this to override the default value of 1.
|
||||
This must be implemented to return the throttle factor.
|
||||
|
||||
The number of seconds required between verification attempts will be
|
||||
:math:`c2^{n-1}` where `c` is this factor and `n` is the number of
|
||||
previous failures. A factor of 1 translates to delays of 1, 2, 4, 8,
|
||||
etc. seconds. A factor of 0 disables the throttling.
|
||||
|
||||
Normally this is just a wrapper for a plugin-specific setting like
|
||||
:setting:`OTP_EMAIL_THROTTLE_FACTOR`.
|
||||
|
||||
"""
|
||||
self._throttle_factor = throttle_factor
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -6,6 +6,7 @@ from threading import Thread
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import connection
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
from freezegun import freeze_time
|
||||
|
||||
@@ -109,24 +110,8 @@ class ThrottlingTestMixin:
|
||||
self.assertEqual(verify_is_allowed3, True)
|
||||
self.assertEqual(data3, None)
|
||||
|
||||
def test_set_throttle_factor_is_reflected(self):
|
||||
"""`set_throttle_factor` must drive `get_throttle_factor`."""
|
||||
self.device.set_throttle_factor(5.5)
|
||||
self.assertEqual(self.device.get_throttle_factor(), 5.5)
|
||||
self.device.set_throttle_factor(0)
|
||||
self.assertEqual(self.device.get_throttle_factor(), 0)
|
||||
|
||||
def test_throttling_disabled_by_factor_zero(self):
|
||||
"""Setting the throttle factor to 0 must actually disable throttling.
|
||||
|
||||
A failed attempt followed by a successful one must succeed. The lockout
|
||||
path must not kick in when the factor is 0.
|
||||
"""
|
||||
self.device.set_throttle_factor(0)
|
||||
self.assertFalse(self.device.verify_token(self.invalid_token()))
|
||||
self.assertTrue(self.device.verify_token(self.valid_token()))
|
||||
|
||||
|
||||
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
|
||||
class APITestCase(TestCase):
|
||||
"""Test API"""
|
||||
|
||||
@@ -134,7 +119,6 @@ class APITestCase(TestCase):
|
||||
self.alice = create_test_admin_user("alice")
|
||||
self.bob = create_test_admin_user("bob")
|
||||
device = self.alice.staticdevice_set.create()
|
||||
device.set_throttle_factor(0)
|
||||
self.valid = generate_id(length=16)
|
||||
device.token_set.create(token=self.valid)
|
||||
|
||||
@@ -154,8 +138,6 @@ class APITestCase(TestCase):
|
||||
verified = verify_token(self.alice, device.persistent_id, "bogus")
|
||||
self.assertIsNone(verified)
|
||||
|
||||
self.alice.staticdevice_set.get().throttle_reset()
|
||||
|
||||
verified = verify_token(self.alice, device.persistent_id, self.valid)
|
||||
self.assertIsNotNone(verified)
|
||||
|
||||
@@ -164,12 +146,11 @@ class APITestCase(TestCase):
|
||||
verified = match_token(self.alice, "bogus")
|
||||
self.assertIsNone(verified)
|
||||
|
||||
self.alice.staticdevice_set.get().throttle_reset()
|
||||
|
||||
verified = match_token(self.alice, self.valid)
|
||||
self.assertEqual(verified, self.alice.staticdevice_set.first())
|
||||
|
||||
|
||||
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
|
||||
class ConcurrencyTestCase(TransactionTestCase):
|
||||
"""Test concurrent verifications"""
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-02 15:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_email",
|
||||
"0002_alter_authenticatoremailstage_friendly_name",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="emaildevice",
|
||||
name="throttling_failure_count",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of successive failed attempts."
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emaildevice",
|
||||
name="throttling_failure_timestamp",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -14,7 +14,7 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import SideChannelDevice, ThrottlingMixin
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
@@ -116,7 +116,7 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
verbose_name_plural = _("Email Authenticator Setup Stages")
|
||||
|
||||
|
||||
class EmailDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
class EmailDevice(SerializerModel, SideChannelDevice):
|
||||
"""Email Device"""
|
||||
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
@@ -130,20 +130,6 @@ class EmailDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
|
||||
return EmailDeviceSerializer
|
||||
|
||||
def verify_token(self, token: str) -> bool:
|
||||
verify_allowed, _ = self.verify_is_allowed()
|
||||
if verify_allowed:
|
||||
verified = super().verify_token(token)
|
||||
|
||||
if verified:
|
||||
self.throttle_reset()
|
||||
else:
|
||||
self.throttle_increment()
|
||||
else:
|
||||
verified = False
|
||||
|
||||
return verified
|
||||
|
||||
def _compose_email(self) -> TemplateEmailMessage:
|
||||
try:
|
||||
pending_user = self.user
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
||||
from django.db.utils import IntegrityError
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
@@ -17,7 +16,6 @@ from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.stages.authenticator.tests import ThrottlingTestMixin
|
||||
from authentik.stages.authenticator_email.api import (
|
||||
AuthenticatorEmailStageSerializer,
|
||||
EmailDeviceSerializer,
|
||||
@@ -81,7 +79,6 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
self.assertFalse(self.device.verify_token("000000"))
|
||||
|
||||
# Verify correct token (should clear token after verification)
|
||||
self.device.throttle_reset(commit=False)
|
||||
self.assertTrue(self.device.verify_token(token))
|
||||
self.assertIsNone(self.device.token)
|
||||
|
||||
@@ -332,27 +329,3 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
# Test AuthenticatorEmailStage send method
|
||||
self.stage.send(self.device)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
|
||||
class TestEmailDeviceThrottling(ThrottlingTestMixin, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
flow = create_test_flow()
|
||||
user = create_test_user()
|
||||
stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-authenticator-throttle",
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
configure_flow=flow,
|
||||
token_expiry="minutes=30",
|
||||
) # nosec
|
||||
self.device = EmailDevice.objects.create(
|
||||
user=user, stage=stage, email="throttle@authentik.local"
|
||||
)
|
||||
self.device.generate_token()
|
||||
|
||||
def valid_token(self):
|
||||
return self.device.token
|
||||
|
||||
def invalid_token(self):
|
||||
return "000000" if self.device.token != "000000" else "111111"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-16 17:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_authenticator_sms", "0008_alter_authenticatorsmsstage_friendly_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="smsdevice",
|
||||
name="throttling_failure_count",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Number of successive failed attempts."
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="smsdevice",
|
||||
name="throttling_failure_timestamp",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -20,7 +20,7 @@ from authentik.events.utils import sanitize_item
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.stages.authenticator.models import SideChannelDevice, ThrottlingMixin
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -197,7 +197,7 @@ def hash_phone_number(phone_number: str) -> str:
|
||||
return "hash:" + sha256(phone_number.encode()).hexdigest()
|
||||
|
||||
|
||||
class SMSDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
class SMSDevice(SerializerModel, SideChannelDevice):
|
||||
"""SMS Device"""
|
||||
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
@@ -224,19 +224,11 @@ class SMSDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
|
||||
return SMSDeviceSerializer
|
||||
|
||||
def verify_token(self, token: str) -> bool:
|
||||
verify_allowed, _ = self.verify_is_allowed()
|
||||
if verify_allowed:
|
||||
verified = super().verify_token(token)
|
||||
|
||||
if verified:
|
||||
self.throttle_reset()
|
||||
else:
|
||||
self.throttle_increment()
|
||||
else:
|
||||
verified = False
|
||||
|
||||
return verified
|
||||
def verify_token(self, token):
|
||||
valid = super().verify_token(token)
|
||||
if valid:
|
||||
self.save()
|
||||
return valid
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name) or str(self.user_id)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
|
||||
@@ -13,7 +12,6 @@ from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.authenticator.tests import ThrottlingTestMixin
|
||||
from authentik.stages.authenticator_sms.models import (
|
||||
AuthenticatorSMSStage,
|
||||
SMSDevice,
|
||||
@@ -359,30 +357,3 @@ class AuthenticatorSMSStageTests(FlowTestCase):
|
||||
},
|
||||
phone_number_required=False,
|
||||
)
|
||||
|
||||
|
||||
class TestSMSDeviceThrottling(ThrottlingTestMixin, TestCase):
|
||||
"""Test ThrottlingMixin behaviour on SMSDevice.verify_token"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
flow = create_test_flow()
|
||||
user = create_test_admin_user()
|
||||
stage = AuthenticatorSMSStage.objects.create(
|
||||
flow=flow,
|
||||
name="sms-throttle",
|
||||
provider=SMSProviders.GENERIC,
|
||||
from_number="1234",
|
||||
)
|
||||
self.device = SMSDevice.objects.create(
|
||||
user=user,
|
||||
stage=stage,
|
||||
phone_number="+15551230001",
|
||||
)
|
||||
self.device.generate_token()
|
||||
|
||||
def valid_token(self):
|
||||
return self.device.token
|
||||
|
||||
def invalid_token(self):
|
||||
return "000000" if self.device.token != "000000" else "111111"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from base64 import b32encode
|
||||
from os import urandom
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -77,6 +78,9 @@ class StaticDevice(SerializerModel, ThrottlingMixin, Device):
|
||||
|
||||
return StaticDeviceSerializer
|
||||
|
||||
def get_throttle_factor(self):
|
||||
return getattr(settings, "OTP_STATIC_THROTTLE_FACTOR", 1)
|
||||
|
||||
def verify_token(self, token):
|
||||
verify_allowed, _ = self.verify_is_allowed()
|
||||
if verify_allowed:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Static API"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -43,6 +44,9 @@ class DeviceTest(TestCase):
|
||||
str(device)
|
||||
|
||||
|
||||
@override_settings(
|
||||
OTP_STATIC_THROTTLE_FACTOR=1,
|
||||
)
|
||||
class ThrottlingTestCase(ThrottlingTestMixin, TestCase):
|
||||
"""Test static device throttling"""
|
||||
|
||||
|
||||
@@ -194,6 +194,9 @@ class TOTPDevice(SerializerModel, ThrottlingMixin, Device):
|
||||
|
||||
return verified
|
||||
|
||||
def get_throttle_factor(self):
|
||||
return getattr(settings, "OTP_TOTP_THROTTLE_FACTOR", 1)
|
||||
|
||||
@property
|
||||
def config_url(self):
|
||||
"""
|
||||
|
||||
@@ -63,14 +63,11 @@ class TOTPDeviceMixin:
|
||||
|
||||
@override_settings(
|
||||
OTP_TOTP_SYNC=False,
|
||||
OTP_TOTP_THROTTLE_FACTOR=0,
|
||||
)
|
||||
class TOTPTest(TOTPDeviceMixin, TestCase):
|
||||
"""TOTP tests"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.device.set_throttle_factor(0)
|
||||
|
||||
def test_default_key(self):
|
||||
"""Ensure default_key is valid"""
|
||||
device = self.alice.totpdevice_set.create()
|
||||
@@ -193,6 +190,9 @@ class TOTPTest(TOTPDeviceMixin, TestCase):
|
||||
self.assertEqual(params["image"][0], image_url)
|
||||
|
||||
|
||||
@override_settings(
|
||||
OTP_TOTP_THROTTLE_FACTOR=1,
|
||||
)
|
||||
class ThrottlingTestCase(TOTPDeviceMixin, ThrottlingTestMixin, TestCase):
|
||||
"""Test TOTP Throttling"""
|
||||
|
||||
|
||||
@@ -39,10 +39,6 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
||||
"webauthn_hints",
|
||||
"webauthn_allowed_device_types",
|
||||
"webauthn_allowed_device_types_obj",
|
||||
"email_otp_throttling_factor",
|
||||
"sms_otp_throttling_factor",
|
||||
"totp_otp_throttling_factor",
|
||||
"static_otp_throttling_factor",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -30,8 +29,8 @@ from authentik.flows.stage import StageView
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.stages.authenticator import devices_for_user
|
||||
from authentik.stages.authenticator.models import Device, ThrottlingMixin
|
||||
from authentik.stages.authenticator import match_token
|
||||
from authentik.stages.authenticator.models import Device
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_email.models import EmailDevice
|
||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||
@@ -144,20 +143,7 @@ def select_challenge_email(request: HttpRequest, device: EmailDevice):
|
||||
def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device:
|
||||
"""Validate code-based challenges. We test against every device, on purpose, as
|
||||
the user mustn't choose between totp and static devices."""
|
||||
|
||||
with transaction.atomic():
|
||||
for device in devices_for_user(user, for_verify=True):
|
||||
if isinstance(device, ThrottlingMixin):
|
||||
throttling_factor = stage_view.executor.current_stage.get_throttling_factor(
|
||||
DeviceClasses.from_model_label(device.model_label())
|
||||
)
|
||||
if throttling_factor is not None:
|
||||
device.set_throttle_factor(throttling_factor)
|
||||
if device.verify_token(code):
|
||||
break
|
||||
else:
|
||||
device = None
|
||||
|
||||
device = match_token(user, code)
|
||||
if not device:
|
||||
login_failed.send(
|
||||
sender=__name__,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-16 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_validate",
|
||||
"0015_authenticatorvalidatestage_webauthn_hints",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="email_otp_throttling_factor",
|
||||
field=models.FloatField(default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="sms_otp_throttling_factor",
|
||||
field=models.FloatField(default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="static_otp_throttling_factor",
|
||||
field=models.FloatField(default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="totp_otp_throttling_factor",
|
||||
field=models.FloatField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -22,12 +22,6 @@ class DeviceClasses(models.TextChoices):
|
||||
SMS = "sms", _("SMS")
|
||||
EMAIL = "email", _("Email")
|
||||
|
||||
@staticmethod
|
||||
def from_model_label(model_label: str) -> DeviceClasses:
|
||||
return getattr(
|
||||
DeviceClasses, model_label.rsplit(".", maxsplit=1)[-1][: -len("device")].upper()
|
||||
)
|
||||
|
||||
|
||||
def default_device_classes() -> list:
|
||||
"""By default, accept all device classes"""
|
||||
@@ -88,11 +82,6 @@ class AuthenticatorValidateStage(Stage):
|
||||
"authentik_stages_authenticator_webauthn.WebAuthnDeviceType", blank=True
|
||||
)
|
||||
|
||||
email_otp_throttling_factor = models.FloatField(default=1)
|
||||
sms_otp_throttling_factor = models.FloatField(default=1)
|
||||
totp_otp_throttling_factor = models.FloatField(default=1)
|
||||
static_otp_throttling_factor = models.FloatField(default=1)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||
@@ -109,17 +98,6 @@ class AuthenticatorValidateStage(Stage):
|
||||
def component(self) -> str:
|
||||
return "ak-stage-authenticator-validate-form"
|
||||
|
||||
def get_throttling_factor(self, device_class: DeviceClasses) -> float | None:
|
||||
if device_class == DeviceClasses.EMAIL:
|
||||
return self.email_otp_throttling_factor
|
||||
elif device_class == DeviceClasses.SMS:
|
||||
return self.sms_otp_throttling_factor
|
||||
elif device_class == DeviceClasses.TOTP:
|
||||
return self.totp_otp_throttling_factor
|
||||
elif device_class == DeviceClasses.STATIC:
|
||||
return self.static_otp_throttling_factor
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Authenticator Validation Stage")
|
||||
verbose_name_plural = _("Authenticator Validation Stages")
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls.base import reverse
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
from authentik.stages.authenticator_sms.models import (
|
||||
AuthenticatorSMSStage,
|
||||
SMSDevice,
|
||||
SMSProviders,
|
||||
)
|
||||
from authentik.stages.authenticator_validate.challenge import validate_challenge_code
|
||||
from authentik.stages.authenticator_validate.models import (
|
||||
AuthenticatorValidateStage,
|
||||
DeviceClasses,
|
||||
)
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
||||
class DeviceClassesHelperTests(TestCase):
|
||||
"""Tests for the DeviceClasses.from_model_label helper."""
|
||||
|
||||
def test_from_model_label_all_classes(self):
|
||||
cases = {
|
||||
"authentik_stages_authenticator_email.emaildevice": DeviceClasses.EMAIL,
|
||||
"authentik_stages_authenticator_sms.smsdevice": DeviceClasses.SMS,
|
||||
"authentik_stages_authenticator_totp.totpdevice": DeviceClasses.TOTP,
|
||||
"authentik_stages_authenticator_static.staticdevice": DeviceClasses.STATIC,
|
||||
"authentik_stages_authenticator_duo.duodevice": DeviceClasses.DUO,
|
||||
"authentik_stages_authenticator_webauthn.webauthndevice": DeviceClasses.WEBAUTHN,
|
||||
}
|
||||
for label, expected in cases.items():
|
||||
with self.subTest(label=label):
|
||||
self.assertEqual(DeviceClasses.from_model_label(label), expected)
|
||||
|
||||
|
||||
class AuthenticatorValidateStageFactorTests(TestCase):
|
||||
"""Tests for AuthenticatorValidateStage.get_throttling_factor."""
|
||||
|
||||
def test_per_class_factors_returned(self):
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
email_otp_throttling_factor=5,
|
||||
sms_otp_throttling_factor=6,
|
||||
totp_otp_throttling_factor=7,
|
||||
static_otp_throttling_factor=8,
|
||||
)
|
||||
self.assertEqual(stage.get_throttling_factor(DeviceClasses.EMAIL), 5)
|
||||
self.assertEqual(stage.get_throttling_factor(DeviceClasses.SMS), 6)
|
||||
self.assertEqual(stage.get_throttling_factor(DeviceClasses.TOTP), 7)
|
||||
self.assertEqual(stage.get_throttling_factor(DeviceClasses.STATIC), 8)
|
||||
|
||||
def test_no_factor_for_webauthn_or_duo(self):
|
||||
stage = AuthenticatorValidateStage.objects.create(name=generate_id())
|
||||
self.assertIsNone(stage.get_throttling_factor(DeviceClasses.WEBAUTHN))
|
||||
self.assertIsNone(stage.get_throttling_factor(DeviceClasses.DUO))
|
||||
|
||||
|
||||
class ValidateChallengeCodeThrottlingTests(FlowTestCase):
|
||||
"""Tests for validate_challenge_code throttling behavior."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.request_factory = RequestFactory()
|
||||
self.email_stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-stage-validate-throttle",
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
token_expiry="minutes=30",
|
||||
) # nosec
|
||||
self.sms_stage = AuthenticatorSMSStage.objects.create(
|
||||
name="sms-stage-validate-throttle",
|
||||
provider=SMSProviders.GENERIC,
|
||||
from_number="1234",
|
||||
)
|
||||
|
||||
def _validate_stage(self, **factors) -> AuthenticatorValidateStage:
|
||||
return AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
device_classes=[
|
||||
DeviceClasses.EMAIL,
|
||||
DeviceClasses.SMS,
|
||||
DeviceClasses.TOTP,
|
||||
DeviceClasses.STATIC,
|
||||
],
|
||||
**factors,
|
||||
)
|
||||
|
||||
def _stage_view(self, validate_stage: AuthenticatorValidateStage) -> StageView:
|
||||
request = self.request_factory.get("/")
|
||||
return StageView(FlowExecutorView(current_stage=validate_stage), request=request)
|
||||
|
||||
def _email_device(self, email: str = "throttle@authentik.local") -> EmailDevice:
|
||||
return EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
stage=self.email_stage,
|
||||
confirmed=True,
|
||||
email=email,
|
||||
)
|
||||
|
||||
def _sms_device(self, phone_number: str = "+15551230101") -> SMSDevice:
|
||||
return SMSDevice.objects.create(
|
||||
user=self.user,
|
||||
stage=self.sms_stage,
|
||||
confirmed=True,
|
||||
phone_number=phone_number,
|
||||
)
|
||||
|
||||
def test_stage_factor_applied_to_email_device(self):
|
||||
"""The stage's email_otp_throttling_factor is pushed onto the device before verify."""
|
||||
stage = self._validate_stage(email_otp_throttling_factor=3)
|
||||
device = self._email_device()
|
||||
device.generate_token()
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_code("000000", self._stage_view(stage), self.user)
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.throttling_failure_count, 1)
|
||||
# verify_is_allowed must compute the delay using factor=3 (3 * 2^0 = 3s).
|
||||
device.set_throttle_factor(3)
|
||||
allowed, data = device.verify_is_allowed()
|
||||
self.assertFalse(allowed)
|
||||
required = data["locked_until"] - device.throttling_failure_timestamp
|
||||
self.assertAlmostEqual(required.total_seconds(), 3, places=3)
|
||||
|
||||
def test_factor_zero_disables_throttling_end_to_end(self):
|
||||
"""With email_otp_throttling_factor=0, repeated failures do not lock the device."""
|
||||
stage = self._validate_stage(email_otp_throttling_factor=0)
|
||||
device = self._email_device()
|
||||
device.generate_token()
|
||||
token = device.token
|
||||
for _ in range(10):
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_code("000000", self._stage_view(stage), self.user)
|
||||
matched = validate_challenge_code(token, self._stage_view(stage), self.user)
|
||||
self.assertEqual(matched.pk, device.pk)
|
||||
|
||||
def test_lockout_persists_across_calls(self):
|
||||
"""
|
||||
A correct token on the second call is still blocked and does not increment the counter.
|
||||
"""
|
||||
stage = self._validate_stage(email_otp_throttling_factor=1)
|
||||
device = self._email_device()
|
||||
device.generate_token()
|
||||
token = device.token
|
||||
invalid_token = "000000" if token != "000000" else "111111" # nosec
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_code(invalid_token, self._stage_view(stage), self.user)
|
||||
# Immediately try with the correct token: lockout still active, attempt must be rejected.
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_challenge_code(token, self._stage_view(stage), self.user)
|
||||
device.refresh_from_db()
|
||||
# Token wasn't consumed (verification never ran), and counter didn't get incremented.
|
||||
self.assertEqual(device.token, token)
|
||||
self.assertEqual(device.throttling_failure_count, 1)
|
||||
|
||||
|
||||
class ValidateStageThrottlingFlowTests(FlowTestCase):
|
||||
"""End-to-end lockout behavior through the flow executor HTTP API."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.email_stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-stage-flow-throttle",
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
token_expiry="minutes=30",
|
||||
) # nosec
|
||||
self.ident_stage = IdentificationStage.objects.create(
|
||||
name=generate_id(),
|
||||
user_fields=[UserFields.USERNAME],
|
||||
)
|
||||
self.validate_stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
device_classes=[DeviceClasses.EMAIL],
|
||||
email_otp_throttling_factor=1,
|
||||
)
|
||||
self.flow = create_test_flow()
|
||||
FlowStageBinding.objects.create(target=self.flow, stage=self.ident_stage, order=0)
|
||||
FlowStageBinding.objects.create(target=self.flow, stage=self.validate_stage, order=1)
|
||||
|
||||
def _identify(self):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"uid_field": self.user.username},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def _select_email(self, device: EmailDevice):
|
||||
self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{
|
||||
"component": "ak-stage-authenticator-validate",
|
||||
"selected_challenge": {
|
||||
"device_class": "email",
|
||||
"device_uid": str(device.pk),
|
||||
"challenge": {},
|
||||
"last_used": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_bad_code_then_correct_code_is_still_blocked(self):
|
||||
"""After a bad code over HTTP, a subsequent correct code is still rejected
|
||||
because the lockout persists in the database."""
|
||||
device = EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
confirmed=True,
|
||||
stage=self.email_stage,
|
||||
email="throttle-flow@authentik.local",
|
||||
)
|
||||
self._identify()
|
||||
self._select_email(device)
|
||||
# Server generated and stored the token - grab it from DB.
|
||||
device.refresh_from_db()
|
||||
token = device.token
|
||||
# First attempt: bad code - must increment the DB counter.
|
||||
self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"component": "ak-stage-authenticator-validate", "code": "000000"},
|
||||
)
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.throttling_failure_count, 1)
|
||||
self.assertEqual(device.token, token)
|
||||
# Second attempt with the correct token - still blocked.
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"component": "ak-stage-authenticator-validate", "code": token},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-authenticator-validate",
|
||||
)
|
||||
device.refresh_from_db()
|
||||
# Counter wasn't incremented on a blocked attempt
|
||||
self.assertEqual(device.throttling_failure_count, 1)
|
||||
# Token wasn't consumed.
|
||||
self.assertEqual(device.token, token)
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ class RedirectMode(models.TextChoices):
|
||||
|
||||
|
||||
class RedirectStage(Stage):
|
||||
"""Redirect the user to a static URL or another flow, optionally with all gathered context."""
|
||||
"""Redirect the user to another flow, potentially with all gathered context."""
|
||||
|
||||
keep_context = models.BooleanField(default=True)
|
||||
mode = models.TextField(choices=RedirectMode.choices)
|
||||
|
||||
@@ -7,7 +7,7 @@ from dramatiq.broker import Broker, MessageProxy, get_broker
|
||||
from dramatiq.middleware.middleware import Middleware
|
||||
from dramatiq.middleware.retries import Retries
|
||||
from dramatiq.results.middleware import Results
|
||||
from dramatiq.worker import ConsumerThread, Worker, WorkerThread
|
||||
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
|
||||
|
||||
from authentik.tasks.broker import PostgresBroker
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestWorker(Worker):
|
||||
self.worker_id = 1000
|
||||
self.work_queue = PriorityQueue()
|
||||
self.consumers = {
|
||||
TESTING_QUEUE: ConsumerThread(
|
||||
TESTING_QUEUE: _ConsumerThread(
|
||||
broker=self.broker,
|
||||
queue_name=TESTING_QUEUE,
|
||||
prefetch=2,
|
||||
@@ -33,7 +33,7 @@ class TestWorker(Worker):
|
||||
prefetch=2,
|
||||
timeout=1,
|
||||
)
|
||||
self._worker = WorkerThread(
|
||||
self._worker = _WorkerThread(
|
||||
broker=self.broker,
|
||||
consumers=self.consumers,
|
||||
work_queue=self.work_queue,
|
||||
@@ -78,18 +78,17 @@ def use_test_broker():
|
||||
actor.broker = broker
|
||||
actor.broker.declare_actor(actor)
|
||||
|
||||
for middleware_class_path, middleware_kwargs in Conf().middlewares:
|
||||
middleware_class = import_string(middleware_class_path)
|
||||
if issubclass(middleware_class, Results):
|
||||
middleware_kwargs["backend"] = import_string(Conf().result_backend)(
|
||||
*Conf().result_backend_args,
|
||||
**Conf().result_backend_kwargs,
|
||||
)
|
||||
middleware: Middleware = middleware_class(
|
||||
for middleware_class, middleware_kwargs in Conf().middlewares:
|
||||
middleware: Middleware = import_string(middleware_class)(
|
||||
**middleware_kwargs,
|
||||
)
|
||||
if isinstance(middleware, Retries):
|
||||
middleware.max_retries = 0
|
||||
if isinstance(middleware, Results):
|
||||
middleware.backend = import_string(Conf().result_backend)(
|
||||
*Conf().result_backend_args,
|
||||
**Conf().result_backend_kwargs,
|
||||
)
|
||||
broker.add_middleware(middleware)
|
||||
|
||||
broker.start()
|
||||
|
||||
@@ -19,30 +19,24 @@ from authentik.tenants.models import Tenant
|
||||
|
||||
class FlagJSONField(JSONDictField):
|
||||
|
||||
def to_internal_value(self, data: str):
|
||||
flags = super().to_internal_value(data)
|
||||
for flag in Flag.available(visibility="system", exclude_system=False):
|
||||
flags[flag().key] = flag.get()
|
||||
return flags
|
||||
|
||||
def to_representation(self, value: dict) -> dict:
|
||||
"""Exclude any system flags that aren't modifiable"""
|
||||
new_value = value.copy()
|
||||
for flag in Flag.available(exclude_system=False):
|
||||
_flag = flag()
|
||||
# Exclude any system flags that aren't modifiable
|
||||
if _flag.visibility == "system":
|
||||
new_value.pop(_flag.key, None)
|
||||
# Explicitly present unset flags as if they were set to default
|
||||
if _flag.key not in value:
|
||||
value[_flag.key] = _flag.default
|
||||
return super().to_representation(new_value)
|
||||
|
||||
def run_validators(self, value: dict):
|
||||
super().run_validators(value)
|
||||
for flag in Flag.available():
|
||||
for flag in Flag.available(exclude_system=False):
|
||||
_flag = flag()
|
||||
if _flag.key not in value:
|
||||
continue
|
||||
if _flag.visibility == "system":
|
||||
value.pop(_flag.key, None)
|
||||
continue
|
||||
flag_value = value.get(_flag.key)
|
||||
flag_type = get_args(_flag.__orig_bases__[0])[0]
|
||||
if flag_value and not isinstance(flag_value, flag_type):
|
||||
@@ -65,8 +59,6 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
if _flag.description:
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
return build_object_type(props, required=props.keys())
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ class Flag[T]:
|
||||
Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"]
|
||||
) = "none"
|
||||
description: str | None = None
|
||||
deprecated = False
|
||||
|
||||
def __init_subclass__(cls, key: str, **kwargs):
|
||||
cls.__key = key
|
||||
|
||||
@@ -85,30 +85,10 @@ class TestLocalSettingsAPI(APITestCase):
|
||||
"flags": {"tenants_test_flag_sys": 123},
|
||||
},
|
||||
)
|
||||
print(response.content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertEqual(self.tenant.flags, {"setup": False, "tenants_test_flag_sys": False})
|
||||
|
||||
def test_settings_flags_system_empty_put(self):
|
||||
"""Test settings API"""
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
class _TestFlag(Flag[bool], key="tenants_test_flag_sys"):
|
||||
|
||||
default = False
|
||||
visibility = "system"
|
||||
|
||||
self.client.force_login(self.local_admin)
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:tenant_settings"),
|
||||
data={
|
||||
"flags": {},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertEqual(self.tenant.flags, {"setup": False, "tenants_test_flag_sys": False})
|
||||
self.assertEqual(self.tenant.flags, {})
|
||||
|
||||
def test_command(self):
|
||||
self.tenant.flags = {}
|
||||
|
||||
@@ -36,10 +36,14 @@ entries:
|
||||
attrs:
|
||||
order: 50
|
||||
initial_value: |
|
||||
actor_uuid = str(getattr(http_request.user, "pk", ""))
|
||||
pending_user = user if getattr(user, "is_authenticated", False) else None
|
||||
target_uuid = str(getattr(pending_user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == actor_uuid
|
||||
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
|
||||
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == current_user_uuid
|
||||
pending_user = None
|
||||
if target_uuid and not is_self_service:
|
||||
from authentik.core.models import User
|
||||
|
||||
pending_user = User.objects.filter(pk=target_uuid).first()
|
||||
if is_self_service:
|
||||
return (
|
||||
"<p><strong>You are about to lock down your own account.</strong></p>"
|
||||
@@ -59,15 +63,14 @@ entries:
|
||||
from django.utils.html import escape
|
||||
|
||||
if pending_user:
|
||||
detail = pending_user.email or pending_user.name
|
||||
user_html = f"<code>{escape(pending_user.username)}</code>"
|
||||
if detail and detail != pending_user.username:
|
||||
user_html = f"{user_html} ({escape(detail)})"
|
||||
email = escape(pending_user.email or pending_user.name or "No email")
|
||||
user_html = f"<p><code>{escape(pending_user.username)}</code> ({email})</p>"
|
||||
else:
|
||||
user_html = "the account selected when this one-time lockdown link was created"
|
||||
user_html = "<p>the account selected when this one-time lockdown link was created</p>"
|
||||
|
||||
return (
|
||||
f"<p><strong>You are about to lock down the following account:</strong> {user_html}</p>"
|
||||
"<p><strong>You are about to lock down the following account:</strong></p>"
|
||||
f"{user_html}"
|
||||
"<p>This is an emergency action for cutting off access to the account right away. "
|
||||
"It does not lock the administrator who opened this page.</p>"
|
||||
"<p><strong>This will immediately:</strong></p>"
|
||||
@@ -96,9 +99,9 @@ entries:
|
||||
attrs:
|
||||
order: 100
|
||||
initial_value: |
|
||||
actor_uuid = str(getattr(http_request.user, "pk", ""))
|
||||
target_uuid = str(getattr(user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == actor_uuid
|
||||
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
|
||||
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == current_user_uuid
|
||||
if is_self_service:
|
||||
info = (
|
||||
"Use this if you no longer trust your current password or sessions. "
|
||||
@@ -131,9 +134,9 @@ entries:
|
||||
attrs:
|
||||
order: 200
|
||||
placeholder: |
|
||||
actor_uuid = str(getattr(http_request.user, "pk", ""))
|
||||
target_uuid = str(getattr(user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == actor_uuid
|
||||
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
|
||||
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
|
||||
is_self_service = not target_uuid or target_uuid == current_user_uuid
|
||||
if is_self_service:
|
||||
return "Describe why you are locking your account..."
|
||||
return "Describe why this account is being locked down..."
|
||||
@@ -181,10 +184,14 @@ entries:
|
||||
attrs:
|
||||
order: 300
|
||||
initial_value: |
|
||||
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
|
||||
from django.utils.html import escape
|
||||
from authentik.core.models import User
|
||||
|
||||
if getattr(user, "is_authenticated", False):
|
||||
return f"<p><code>{escape(user.username)}</code> has been locked down.</p>"
|
||||
if target_uuid:
|
||||
target = User.objects.filter(pk=target_uuid).first()
|
||||
if target:
|
||||
return f"<p><code>{escape(target.username)}</code> has been locked down.</p>"
|
||||
|
||||
return "<p>The selected account has been locked down.</p>"
|
||||
initial_value_expression: true
|
||||
@@ -214,9 +221,9 @@ entries:
|
||||
attrs:
|
||||
name: default-account-lockdown-admin-policy
|
||||
expression: |
|
||||
actor_uuid = str(getattr(request.http_request.user, "pk", ""))
|
||||
target_uuid = str(getattr(request.user, "pk", ""))
|
||||
return bool(target_uuid) and target_uuid != actor_uuid
|
||||
target_uuid = (request.http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
|
||||
current_user_uuid = str(getattr(request.user, "pk", "") or getattr(request.http_request.user, "pk", ""))
|
||||
return bool(target_uuid) and target_uuid != current_user_uuid
|
||||
identifiers:
|
||||
name: default-account-lockdown-admin-policy
|
||||
id: admin-policy
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# Minimal Invitation-based Enrollment Blueprint
|
||||
#
|
||||
# Companion to flows-invitation-enrollment.yaml, intended for the "New Invitation"
|
||||
# wizard in the admin UI. Creates a single enrollment flow with an invitation stage
|
||||
# bound to it, plus the supporting prompt/user-write/user-login stages.
|
||||
#
|
||||
# All user-facing fields are parameterized via !Context with fallback defaults, so
|
||||
# this blueprint can be imported directly (without context) or through the wizard
|
||||
# with custom values.
|
||||
#
|
||||
# Context keys (all optional):
|
||||
# flow_name Display name of the enrollment flow.
|
||||
# flow_slug URL slug of the flow and suffix for sub-entity
|
||||
# identifiers (so repeated imports with different
|
||||
# slugs don't overwrite each other).
|
||||
# stage_name Name of the invitation stage.
|
||||
# continue_flow_without_invitation Whether the flow continues when no invitation
|
||||
# is supplied (default: false).
|
||||
# user_type "external" or "internal" (default: "external").
|
||||
# Drives the user-write stage's user_type and
|
||||
# user_path_template.
|
||||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "false"
|
||||
name: Invitation-based Enrollment (minimal)
|
||||
entries:
|
||||
- identifiers:
|
||||
slug: !Context [flow_slug, invitation-enrollment-flow]
|
||||
model: authentik_flows.flow
|
||||
id: flow
|
||||
attrs:
|
||||
name: !Context [flow_name, Invitation Enrollment Flow]
|
||||
title: !Context [flow_name, Invitation Enrollment Flow]
|
||||
designation: enrollment
|
||||
authentication: require_unauthenticated
|
||||
|
||||
- identifiers:
|
||||
name: !Context [stage_name, invitation-stage]
|
||||
id: invitation-stage
|
||||
model: authentik_stages_invitation.invitationstage
|
||||
attrs:
|
||||
continue_flow_without_invitation: !Context [continue_flow_without_invitation, false]
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-field-username-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-field-username
|
||||
model: authentik_stages_prompt.prompt
|
||||
attrs:
|
||||
field_key: username
|
||||
label: Username
|
||||
type: username
|
||||
required: true
|
||||
placeholder: Username
|
||||
placeholder_expression: false
|
||||
order: 0
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-field-password-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-field-password
|
||||
model: authentik_stages_prompt.prompt
|
||||
attrs:
|
||||
field_key: password
|
||||
label: Password
|
||||
type: password
|
||||
required: true
|
||||
placeholder: Password
|
||||
placeholder_expression: false
|
||||
order: 1
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-field-password-repeat-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-field-password-repeat
|
||||
model: authentik_stages_prompt.prompt
|
||||
attrs:
|
||||
field_key: password_repeat
|
||||
label: Password (repeat)
|
||||
type: password
|
||||
required: true
|
||||
placeholder: Password (repeat)
|
||||
placeholder_expression: false
|
||||
order: 2
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-field-name-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-field-name
|
||||
model: authentik_stages_prompt.prompt
|
||||
attrs:
|
||||
field_key: name
|
||||
label: Name
|
||||
type: text
|
||||
required: true
|
||||
placeholder: Name
|
||||
placeholder_expression: false
|
||||
order: 0
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-field-email-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-field-email
|
||||
model: authentik_stages_prompt.prompt
|
||||
attrs:
|
||||
field_key: email
|
||||
label: Email
|
||||
type: email
|
||||
required: true
|
||||
placeholder: Email
|
||||
placeholder_expression: false
|
||||
order: 1
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-prompt-credentials-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-stage-credentials
|
||||
model: authentik_stages_prompt.promptstage
|
||||
attrs:
|
||||
fields:
|
||||
- !KeyOf prompt-field-username
|
||||
- !KeyOf prompt-field-password
|
||||
- !KeyOf prompt-field-password-repeat
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-prompt-details-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: prompt-stage-details
|
||||
model: authentik_stages_prompt.promptstage
|
||||
attrs:
|
||||
fields:
|
||||
- !KeyOf prompt-field-name
|
||||
- !KeyOf prompt-field-email
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-user-write-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: user-write-stage
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
user_creation_mode: always_create
|
||||
user_type: !Context [user_type, external]
|
||||
user_path_template:
|
||||
!Format ["users/%s", !Context [user_type, external]]
|
||||
|
||||
- identifiers:
|
||||
name:
|
||||
!Format [
|
||||
"invitation-enrollment-user-login-%s",
|
||||
!Context [flow_slug, invitation-enrollment-flow],
|
||||
]
|
||||
id: user-login-stage
|
||||
model: authentik_stages_user_login.userloginstage
|
||||
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf invitation-stage
|
||||
order: 5
|
||||
model: authentik_flows.flowstagebinding
|
||||
attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: true
|
||||
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf prompt-stage-credentials
|
||||
order: 10
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf prompt-stage-details
|
||||
order: 15
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf user-write-stage
|
||||
order: 20
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf user-login-stage
|
||||
order: 100
|
||||
model: authentik_flows.flowstagebinding
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2026.5.0-rc2 Blueprint schema",
|
||||
"title": "authentik 2026.5.0-rc1 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -11203,8 +11203,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"token",
|
||||
"oauth",
|
||||
"oauth_interactive"
|
||||
"oauth"
|
||||
],
|
||||
"title": "Auth mode"
|
||||
},
|
||||
@@ -12968,6 +12967,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"apple",
|
||||
"atproto",
|
||||
"openidconnect",
|
||||
"entraid",
|
||||
"azuread",
|
||||
@@ -13039,7 +13039,6 @@
|
||||
},
|
||||
"consumer_secret": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Consumer secret"
|
||||
},
|
||||
"additional_scopes": {
|
||||
@@ -14937,22 +14936,6 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": "Webauthn allowed device types"
|
||||
},
|
||||
"email_otp_throttling_factor": {
|
||||
"type": "number",
|
||||
"title": "Email otp throttling factor"
|
||||
},
|
||||
"sms_otp_throttling_factor": {
|
||||
"type": "number",
|
||||
"title": "Sms otp throttling factor"
|
||||
},
|
||||
"totp_otp_throttling_factor": {
|
||||
"type": "number",
|
||||
"title": "Totp otp throttling factor"
|
||||
},
|
||||
"static_otp_throttling_factor": {
|
||||
"type": "number",
|
||||
"title": "Static otp throttling factor"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026.5.0-rc2
|
||||
2026.5.0-rc1
|
||||
@@ -110,6 +110,17 @@ func (a *Application) getTraefikForwardUrl(r *http.Request) (*url.URL, error) {
|
||||
|
||||
// getNginxForwardUrl See https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl
|
||||
func (a *Application) getNginxForwardUrl(r *http.Request) (*url.URL, error) {
|
||||
ou := r.Header.Get("X-Original-URI")
|
||||
if ou != "" {
|
||||
// Turn this full URL into a relative URL
|
||||
u := &url.URL{
|
||||
Host: "",
|
||||
Scheme: "",
|
||||
Path: ou,
|
||||
}
|
||||
a.log.WithField("url", u.String()).Info("building forward URL from X-Original-URI")
|
||||
return u, nil
|
||||
}
|
||||
h := r.Header.Get("X-Original-URL")
|
||||
if len(h) < 1 {
|
||||
return nil, errors.New("no forward URL found")
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"goauthentik.io/internal/outpost/proxyv2/constants"
|
||||
"goauthentik.io/internal/outpost/proxyv2/types"
|
||||
api "goauthentik.io/packages/client-go"
|
||||
)
|
||||
|
||||
@@ -45,6 +47,67 @@ func TestForwardHandleNginx_Single_Headers(t *testing.T) {
|
||||
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Single_URI(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "https://foo.bar/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URI", "/app")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
|
||||
s, _ := a.sessions.Get(req, a.SessionName())
|
||||
assert.Equal(t, "/app", s.Values[constants.SessionRedirect])
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Single_Claims(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
|
||||
req.Header.Set("X-Original-URI", "/")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
s, _ := a.sessions.Get(req, a.SessionName())
|
||||
s.ID = uuid.New().String()
|
||||
s.Options.MaxAge = 86400
|
||||
s.Values[constants.SessionClaims] = types.Claims{
|
||||
Sub: "foo",
|
||||
Proxy: &types.ProxyClaims{
|
||||
UserAttributes: map[string]any{
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
"additionalHeaders": map[string]any{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := a.sessions.Save(req, rr, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
a.forwardHandleNginx(rr, req)
|
||||
|
||||
h := rr.Result().Header
|
||||
|
||||
assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"])
|
||||
assert.Equal(t, []string{"bar"}, h["Foo"])
|
||||
assert.Equal(t, []string{""}, h["User-Agent"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Email"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Groups"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Jwt"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Name"])
|
||||
assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"])
|
||||
assert.Equal(t, []string{""}, h["X-Authentik-Username"])
|
||||
}
|
||||
|
||||
func TestForwardHandleNginx_Domain_Blank(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()
|
||||
|
||||
@@ -38,10 +38,6 @@ function run_authentik {
|
||||
echo cargo run -- "$@"
|
||||
fi
|
||||
;;
|
||||
manage)
|
||||
shift 1
|
||||
echo python -m manage "$@"
|
||||
;;
|
||||
*)
|
||||
echo "$@"
|
||||
;;
|
||||
@@ -83,7 +79,7 @@ function prepare_debug {
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends krb5-kdc krb5-user krb5-admin-server libkrb5-dev gcc
|
||||
source "${VENV_PATH}/bin/activate"
|
||||
uv sync --active --locked
|
||||
uv sync --active --frozen
|
||||
touch /unittest.xml
|
||||
chown authentik:authentik /unittest.xml
|
||||
}
|
||||
|
||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1120.0",
|
||||
"aws-cdk": "^2.1119.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -25,9 +25,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1120.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1120.0.tgz",
|
||||
"integrity": "sha512-vDVa0IX0FhizARdY/GLSParFglKbdHCIhM8IDmynrAv9w8uLLljzWMeLUOhC1XpMErDZ/npYEihAOjfKxTaMIw==",
|
||||
"version": "2.1119.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1119.0.tgz",
|
||||
"integrity": "sha512-XBxZEKH3BY4M1EX6x0qBkmOAj8viErjpww14iH6Z3z6nI0YzjZeJ05eEl7eJwzUgv7NTGagWBS9m/eDJW5+dAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1120.0",
|
||||
"aws-cdk": "^2.1119.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2026.5.0-rc2
|
||||
Default: 2026.5.0-rc1
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
@@ -200,7 +200,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||
--mount=type=bind,target=packages/django-postgres-cache,src=packages/django-postgres-cache \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Stage: Run
|
||||
FROM python-base AS final-image
|
||||
@@ -228,7 +228,8 @@ RUN apt-get update && \
|
||||
# Required for runtime
|
||||
apt-get install -y --no-install-recommends \
|
||||
libpq5 libmaxminddb0 ca-certificates \
|
||||
libkadm5clnt-mit12 libkadm5clnt7t64-heimdal \
|
||||
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
|
||||
heimdal-multidev libkadm5clnt7t64-heimdal \
|
||||
libltdl7 libxslt1.1 && \
|
||||
# Required for bootstrap & healtcheck
|
||||
apt-get install -y --no-install-recommends runit && \
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc1}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc1}
|
||||
restart: unless-stopped
|
||||
shm_size: 512mb
|
||||
user: root
|
||||
|
||||
@@ -28,7 +28,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
_ = db_conn.cursor()
|
||||
|
||||
def do_GET(self):
|
||||
from django.db import DatabaseError, InterfaceError, OperationalError, connections
|
||||
from django.db import (
|
||||
DatabaseError,
|
||||
InterfaceError,
|
||||
OperationalError,
|
||||
connections,
|
||||
)
|
||||
from psycopg.errors import AdminShutdown
|
||||
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
@@ -37,6 +42,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
||||
AdminShutdown,
|
||||
InterfaceError,
|
||||
DatabaseError,
|
||||
ConnectionError,
|
||||
OperationalError,
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -12,7 +12,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-06 00:27+0000\n"
|
||||
"POT-Creation-Date: 2026-02-10 19:27+0000\n"
|
||||
"PO-Revision-Date: 2025-12-01 19:09+0000\n"
|
||||
"Last-Translator: Václav Nováček <waclaw661@gmail.com>, 2026\n"
|
||||
"Language-Team: Czech (Czech Republic) (https://app.transifex.com/authentik/teams/119923/cs_CZ/)\n"
|
||||
@@ -106,14 +106,6 @@ msgstr "Chyba validace"
|
||||
msgid "Blueprint file does not exist"
|
||||
msgstr "Soubor s konfigurační šablonou neexistuje"
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be valid JSON"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be a JSON object"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Failed to validate blueprint"
|
||||
msgstr "Ověřování konfigurační šablony selhalo"
|
||||
@@ -122,11 +114,6 @@ msgstr "Ověřování konfigurační šablony selhalo"
|
||||
msgid "Either path or content must be set."
|
||||
msgstr "Musí být nastavena buď cesta, nebo obsah."
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
#, python-brace-format
|
||||
msgid "User lacks permission to create {model}"
|
||||
msgstr "Uživatel nemá oprávnění vytvořit {model}"
|
||||
|
||||
#: authentik/blueprints/models.py
|
||||
msgid "Managed by authentik"
|
||||
msgstr "Spravuje authentik"
|
||||
@@ -257,13 +244,10 @@ msgstr ""
|
||||
"pouze poskytovatele backchannel. Pokud je vypnuto, backchannel poskytovatelé"
|
||||
" nejsou zahrnuti."
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "Invalid password hash format. Must be a valid Django password hash."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "Cannot set both password and password_hash. Use only one."
|
||||
msgstr ""
|
||||
#: authentik/core/api/transactional_applications.py
|
||||
#, python-brace-format
|
||||
msgid "User lacks permission to create {model}"
|
||||
msgstr "Uživatel nemá oprávnění vytvořit {model}"
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "No leading or trailing slashes allowed."
|
||||
@@ -325,12 +309,6 @@ msgstr ""
|
||||
msgid "This field is required."
|
||||
msgstr "Toto pole je povinné."
|
||||
|
||||
#: authentik/core/apps.py
|
||||
msgid ""
|
||||
"Configure if applications without any policy/group/user bindings should be "
|
||||
"accessible to any user."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "name"
|
||||
msgstr "Jméno"
|
||||
@@ -437,10 +415,6 @@ msgstr "Interní název aplikace, používaný v URI."
|
||||
msgid "Open launch URL in a new browser tab or window."
|
||||
msgstr "Otevřít úvodní URL v novém okně nebo kartě prohlížeče."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Hide this application from the user's My applications page."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application"
|
||||
msgstr "Aplikace"
|
||||
@@ -632,14 +606,6 @@ msgstr "Odstranit dočasné uživatele vytvořené zdroji SAML."
|
||||
msgid "Go home"
|
||||
msgstr "Přejít domů"
|
||||
|
||||
#: authentik/core/templates/login/base_full.html
|
||||
msgid "Site footer"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/templates/login/base_full.html
|
||||
msgid "Flow links"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/templates/login/base_full.html
|
||||
#: authentik/flows/templates/if/flow-sfe.html
|
||||
msgid "Powered by authentik"
|
||||
@@ -746,10 +712,6 @@ msgstr ""
|
||||
msgid "Discover, import and update certificates from the filesystem."
|
||||
msgstr "Objevit, importovat a aktualizovat certifikáty na souborovém systému."
|
||||
|
||||
#: authentik/endpoints/api/stages.py
|
||||
msgid "Selected connector is not compatible with this stage."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/endpoints/connectors/agent/api/connectors.py
|
||||
msgid "Selected platform not supported"
|
||||
msgstr ""
|
||||
@@ -804,14 +766,6 @@ msgstr ""
|
||||
msgid "Apple Nonces"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/endpoints/connectors/agent/models.py
|
||||
msgid "Apple Independent Secure Enclave"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/endpoints/connectors/agent/models.py
|
||||
msgid "Apple Independent Secure Enclaves"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/endpoints/facts.py
|
||||
msgid "Operating System name, such as 'Server 2022' or 'Ubuntu'"
|
||||
msgstr ""
|
||||
@@ -883,12 +837,6 @@ msgstr ""
|
||||
msgid "Enterprise is required to use this endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/audit/apps.py
|
||||
msgid ""
|
||||
"Include additional information in audit logs, may incur a performance "
|
||||
"penalty."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/endpoints/connectors/fleet/models.py
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
@@ -906,19 +854,6 @@ msgstr ""
|
||||
msgid "Fleet Connectors"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/endpoints/connectors/google_chrome/models.py
|
||||
msgid "Google Device Trust Connector"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/endpoints/connectors/google_chrome/models.py
|
||||
msgid "Google Device Trust Connectors"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/endpoints/connectors/google_chrome/stage.py
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/stage.py
|
||||
msgid "Verifying your browser..."
|
||||
msgstr "Ověřuji Váš prohlížeč..."
|
||||
|
||||
#: authentik/enterprise/lifecycle/api/reviews.py
|
||||
msgid "You are not allowed to submit a review for this object."
|
||||
msgstr ""
|
||||
@@ -935,6 +870,10 @@ msgstr ""
|
||||
msgid "Grace period must be shorter than the interval."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/api/rules.py
|
||||
msgid "Only one type-wide rule for each object type is allowed."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
msgid ""
|
||||
"Select which transports should be used to notify the reviewers. If none are "
|
||||
@@ -962,8 +901,7 @@ msgid "Go to {self._get_model_name()}"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
msgid ""
|
||||
"Access review is due for {self.content_type.name.lower()} {object_label}"
|
||||
msgid "Access review is due for {self.content_type.name} {str(self.object)}"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
@@ -977,7 +915,7 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/tasks.py
|
||||
msgid "Dispatch tasks to apply lifecycle rules."
|
||||
msgid "Dispatch tasks to validate lifecycle rules."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/lifecycle/tasks.py
|
||||
@@ -1220,14 +1158,6 @@ msgstr "Pro použití EAP-TLS je nutná Enterprise licence."
|
||||
msgid "Enterprise is required to use the OAuth mode."
|
||||
msgstr "Pro použití OAuth režimu je vyžadována Enterprise licence."
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF RFC Push"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF RFC Pull"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Signing Key"
|
||||
@@ -1309,78 +1239,6 @@ msgstr ""
|
||||
msgid "Generate data export."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "User to lock. If omitted, locks the current user (self-service)."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "No lockdown flow configured."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Lockdown flow is not applicable."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Choose the target account, then return a flow link."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "No lockdown flow configured or the flow is not applicable"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Permission denied (when targeting another user)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Deactivate the user account (set is_active to False)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Set an unusable password for the user"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Delete all active sessions for the user"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid ""
|
||||
"Revoke all tokens for the user (API, app password, recovery, verification, "
|
||||
"OAuth)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid ""
|
||||
"Flow to redirect users to after self-service lockdown. This flow should not "
|
||||
"require authentication since the user's session is deleted."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Account Lockdown Stage"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Account Lockdown Stages"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "No target user specified for account lockdown"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "You do not have permission to lock down this account."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "Account lockdown failed for this account."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "Self-service account lockdown requires a completion flow."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
msgstr "Fáze konektoru Endpoint Authenticator Google Device Trust"
|
||||
@@ -1397,6 +1255,10 @@ msgstr "Koncové zařízení"
|
||||
msgid "Endpoint Devices"
|
||||
msgstr "Koncová zařízení"
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/stage.py
|
||||
msgid "Verifying your browser..."
|
||||
msgstr "Ověřuji Váš prohlížeč..."
|
||||
|
||||
#: authentik/enterprise/stages/mtls/models.py
|
||||
msgid ""
|
||||
"Configure certificate authorities to validate the certificate against. This "
|
||||
@@ -1479,12 +1341,6 @@ msgstr ""
|
||||
"Odeslat oznámení pouze jednou, například při posílání webhooku do kanálu "
|
||||
"chatu."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
"When set, the selected ceritifcate is used to validate the certificate of "
|
||||
"the webhook server."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
"Customize the body of the request. Mapping should return data that is JSON-"
|
||||
@@ -1655,15 +1511,6 @@ msgstr "Zásady před tokem"
|
||||
msgid "Flow"
|
||||
msgstr "Tok"
|
||||
|
||||
#: authentik/flows/apps.py
|
||||
msgid "Refresh other tabs after successful authentication."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/flows/apps.py
|
||||
msgid ""
|
||||
"Upon successful authentication, re-start authentication in other open tabs."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/flows/exceptions.py
|
||||
msgid "Flow does not apply to current user."
|
||||
msgstr "Tok se nevztahuje na aktuálního uživatele."
|
||||
@@ -1773,8 +1620,8 @@ msgstr "Token Toku"
|
||||
msgid "Flow Tokens"
|
||||
msgstr "Tokeny Toků"
|
||||
|
||||
#: authentik/flows/planner.py
|
||||
msgid "This link is invalid or has expired. Please request a new one."
|
||||
#: authentik/flows/templates/if/flow.html
|
||||
msgid "Site footer"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/flows/views/executor.py
|
||||
@@ -2159,6 +2006,22 @@ msgstr "Reputační skóre"
|
||||
msgid "Reputation Scores"
|
||||
msgstr "Reputační skóre"
|
||||
|
||||
#: authentik/policies/templates/policies/buffer.html
|
||||
msgid "Waiting for authentication..."
|
||||
msgstr "Čeká se na ověření..."
|
||||
|
||||
#: authentik/policies/templates/policies/buffer.html
|
||||
msgid ""
|
||||
"You're already authenticating in another tab. This page will refresh once "
|
||||
"authentication is completed."
|
||||
msgstr ""
|
||||
"Už se přihlašujete na jiné záložce. Stránka se obnoví, jakmile bude ověření "
|
||||
"dokončeno."
|
||||
|
||||
#: authentik/policies/templates/policies/buffer.html
|
||||
msgid "Authenticate in this tab"
|
||||
msgstr "Ověřit na této záložce"
|
||||
|
||||
#: authentik/policies/templates/policies/denied.html
|
||||
msgid "Permission denied"
|
||||
msgstr "Nedostatečná oprávnění"
|
||||
@@ -2284,14 +2147,6 @@ msgstr "Striktní porovnání URL"
|
||||
msgid "Regular Expression URL matching"
|
||||
msgstr "Porovnání URL regulárním výrazem"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Authorization"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Back-channel"
|
||||
msgstr "Back-channel"
|
||||
@@ -2649,6 +2504,10 @@ msgstr "Poskytovatel proxy"
|
||||
msgid "Proxy Providers"
|
||||
msgstr "Poskytovatelé proxy"
|
||||
|
||||
#: authentik/providers/proxy/tasks.py
|
||||
msgid "Terminate session on Proxy outpost."
|
||||
msgstr "Ukončit relaci na outpostu proxy."
|
||||
|
||||
#: authentik/providers/rac/models.py authentik/stages/user_login/models.py
|
||||
msgid ""
|
||||
"Determines how long a session lasts. Default of 0 means that the sessions "
|
||||
@@ -2776,10 +2635,8 @@ msgstr ""
|
||||
"omezení publika nebude přidáno."
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid ""
|
||||
"Also known as EntityID. Providing a value overrides the default issuer "
|
||||
"generated by authentik."
|
||||
msgstr ""
|
||||
msgid "Also known as EntityID"
|
||||
msgstr "Také známé jako EntityID."
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SLS URL"
|
||||
@@ -2997,10 +2854,6 @@ msgstr "Hodnota SAML NameID pro tuto relaci"
|
||||
msgid "SAML NameID format"
|
||||
msgstr "Formát SAML NameID"
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SAML Issuer used for this session"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SAML Session"
|
||||
msgstr "Relace SAML"
|
||||
@@ -3029,14 +2882,6 @@ msgstr "Slack"
|
||||
msgid "Salesforce"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Webex"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "vCenter"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Group filters used to define sync-scope for groups."
|
||||
msgstr ""
|
||||
@@ -3313,7 +3158,7 @@ msgstr ""
|
||||
" Prosím, kontaktujte správce.\n"
|
||||
" "
|
||||
|
||||
#: authentik/sources/ldap/api/sources.py
|
||||
#: authentik/sources/ldap/api.py
|
||||
msgid "Only a single LDAP Source with password synchronization is allowed"
|
||||
msgstr "Je dovolen pouze jeden zdroj LDAP se synchronizací hesel"
|
||||
|
||||
@@ -3843,12 +3688,6 @@ msgstr ""
|
||||
"Povolit autentikační tok iniciovaný Identity Providerem. Může představovat "
|
||||
"bezpečnostní riziko, protože se nekontroluje request ID."
|
||||
|
||||
#: authentik/sources/saml/models.py
|
||||
msgid ""
|
||||
"When enabled, the IdP will re-authenticate the user even if a session "
|
||||
"exists."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/sources/saml/models.py
|
||||
msgid ""
|
||||
"NameID Policy sent to the IdP. Can be unset, in which case no Policy is "
|
||||
@@ -4269,10 +4108,6 @@ msgstr "Kroky validace autentikátoru"
|
||||
msgid "No (allowed) MFA authenticator configured."
|
||||
msgstr "Žádný (povolený) MFA autentikátor nebyl nastaven."
|
||||
|
||||
#: authentik/stages/authenticator_webauthn/models.py
|
||||
msgid "When enabled, a given device can only be registered once."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/authenticator_webauthn/models.py
|
||||
msgid "WebAuthn Authenticator Setup Stage"
|
||||
msgstr "Krok nastavení autentikátoru WebAuthn"
|
||||
@@ -4408,10 +4243,6 @@ msgstr "Email OTP"
|
||||
msgid "Event Notification"
|
||||
msgstr "Oznámení o události"
|
||||
|
||||
#: authentik/stages/email/models.py authentik/stages/invitation/models.py
|
||||
msgid "Invitation"
|
||||
msgstr "Pozvánka"
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid ""
|
||||
"The time window used to count recent account recovery attempts. If the "
|
||||
@@ -4530,62 +4361,6 @@ msgstr ""
|
||||
"\n"
|
||||
"Tento email byl odeslán z transportu oznámení %(name)s.\n"
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.html
|
||||
msgid ""
|
||||
"\n"
|
||||
" You're Invited!\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" You have been invited to join %(host)s. Click the button below to get started.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" This invitation expires %(expires)s.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.html
|
||||
#: authentik/stages/email/templates/email/invitation.txt
|
||||
msgid "Accept Invitation"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.html
|
||||
msgid ""
|
||||
"\n"
|
||||
" If you cannot click the button above, please copy and paste the following URL into your browser:\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.txt
|
||||
msgid "You're Invited!"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.txt
|
||||
#, python-format
|
||||
msgid ""
|
||||
"You have been invited to join %(host)s. Use the link below to get started."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.txt
|
||||
#, python-format
|
||||
msgid "This invitation expires %(expires)s."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/invitation.txt
|
||||
msgid ""
|
||||
"If you cannot click the link above, please copy and paste the following URL "
|
||||
"into your browser:"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/templates/email/password_reset.html
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -4763,6 +4538,10 @@ msgstr "Pokud je povoleno, pozvánka bude po použití smazána."
|
||||
msgid "Optional fixed data to enforce on user enrollment."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/invitation/models.py
|
||||
msgid "Invitation"
|
||||
msgstr "Pozvánka"
|
||||
|
||||
#: authentik/stages/invitation/models.py
|
||||
msgid "Invitations"
|
||||
msgstr "Pozvánky"
|
||||
@@ -4875,18 +4654,6 @@ msgstr ""
|
||||
msgid "Static: Static value, displayed as-is."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Info): Static alert box with info styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Warning): Static alert box with warning styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Danger): Static alert box with danger styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "authentik: Selection of locales authentik supports"
|
||||
msgstr "authentik: Výběr jazyků, které authentik podporuje"
|
||||
|
||||
@@ -14,7 +14,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-06 00:27+0000\n"
|
||||
"POT-Creation-Date: 2026-04-23 00:25+0000\n"
|
||||
"PO-Revision-Date: 2025-12-01 19:09+0000\n"
|
||||
"Last-Translator: Lukas Nielsen, 2026\n"
|
||||
"Language-Team: German (Germany) (https://app.transifex.com/authentik/teams/119923/de_DE/)\n"
|
||||
@@ -111,14 +111,6 @@ msgstr "Validierungsfehler"
|
||||
msgid "Blueprint file does not exist"
|
||||
msgstr "Vorlagendatei existiert nicht"
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be valid JSON"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be a JSON object"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Failed to validate blueprint"
|
||||
msgstr "Fehler bei der Validierung der Vorlage"
|
||||
@@ -265,14 +257,6 @@ msgstr ""
|
||||
"werden nur die backchannel Provider zurück gegeben. Zudem werden bei "
|
||||
"Deaktivierung die backchannel Provider ausgeschlossen."
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "Invalid password hash format. Must be a valid Django password hash."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "Cannot set both password and password_hash. Use only one."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/api/users.py
|
||||
msgid "No leading or trailing slashes allowed."
|
||||
msgstr "Es sind keine führenden oder abschließenden Schrägstriche erlaubt."
|
||||
@@ -451,10 +435,6 @@ msgstr "Interner Anwendungsname, wird in URLs verwendet."
|
||||
msgid "Open launch URL in a new browser tab or window."
|
||||
msgstr "Start-URL in einem neuen Browser-Fenster öffnen."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Hide this application from the user's My applications page."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
@@ -954,6 +934,10 @@ msgstr "Es muss entweder eine Prüfergruppe oder ein Prüfer festgelegt werden."
|
||||
msgid "Grace period must be shorter than the interval."
|
||||
msgstr "Die Nachfrist muss kürzer sein als das Intervall."
|
||||
|
||||
#: authentik/enterprise/lifecycle/api/rules.py
|
||||
msgid "Only one type-wide rule for each object type is allowed."
|
||||
msgstr "Für jeden Objekttyp ist nur eine typweite Regel zulässig."
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
msgid ""
|
||||
"Select which transports should be used to notify the reviewers. If none are "
|
||||
@@ -984,9 +968,10 @@ msgid "Go to {self._get_model_name()}"
|
||||
msgstr "Gehe zu {self._get_model_name()}"
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
msgid ""
|
||||
"Access review is due for {self.content_type.name.lower()} {object_label}"
|
||||
msgid "Access review is due for {self.content_type.name} {str(self.object)}"
|
||||
msgstr ""
|
||||
"Die Zugriffsüberprüfung für {self.content_type.name} {str(self.object)} "
|
||||
"steht an"
|
||||
|
||||
#: authentik/enterprise/lifecycle/models.py
|
||||
msgid ""
|
||||
@@ -1003,8 +988,8 @@ msgstr ""
|
||||
"erledigt"
|
||||
|
||||
#: authentik/enterprise/lifecycle/tasks.py
|
||||
msgid "Dispatch tasks to apply lifecycle rules."
|
||||
msgstr ""
|
||||
msgid "Dispatch tasks to validate lifecycle rules."
|
||||
msgstr "Aufgaben zur Überprüfung von Lebenszyklusregeln zuweisen."
|
||||
|
||||
#: authentik/enterprise/lifecycle/tasks.py
|
||||
msgid "Apply lifecycle rule."
|
||||
@@ -1347,78 +1332,6 @@ msgstr "Download"
|
||||
msgid "Generate data export."
|
||||
msgstr "Datenexport generieren."
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "User to lock. If omitted, locks the current user (self-service)."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "No lockdown flow configured."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Lockdown flow is not applicable."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Choose the target account, then return a flow link."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "No lockdown flow configured or the flow is not applicable"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/api.py
|
||||
msgid "Permission denied (when targeting another user)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Deactivate the user account (set is_active to False)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Set an unusable password for the user"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Delete all active sessions for the user"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid ""
|
||||
"Revoke all tokens for the user (API, app password, recovery, verification, "
|
||||
"OAuth)"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid ""
|
||||
"Flow to redirect users to after self-service lockdown. This flow should not "
|
||||
"require authentication since the user's session is deleted."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Account Lockdown Stage"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/models.py
|
||||
msgid "Account Lockdown Stages"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "No target user specified for account lockdown"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "You do not have permission to lock down this account."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "Account lockdown failed for this account."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/account_lockdown/stage.py
|
||||
msgid "Self-service account lockdown requires a completion flow."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
msgstr "Endpunkt-Authenticator für Google Gerätevertrauen Verbindungs Stage"
|
||||
@@ -2864,10 +2777,8 @@ msgstr ""
|
||||
"Feld leer, wird keine Zielgruppenbeschränkung hinzugefügt."
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid ""
|
||||
"Also known as EntityID. Providing a value overrides the default issuer "
|
||||
"generated by authentik."
|
||||
msgstr ""
|
||||
msgid "Also known as EntityID"
|
||||
msgstr "Auch bekannt als EntityID"
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SLS URL"
|
||||
@@ -3089,10 +3000,6 @@ msgstr "SAML-NameID-Wert für diese Sitzung"
|
||||
msgid "SAML NameID format"
|
||||
msgstr "SAML-NameID-Format"
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SAML Issuer used for this session"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "SAML Session"
|
||||
msgstr "SAML Sitzung"
|
||||
@@ -3125,10 +3032,6 @@ msgstr "Salesforce"
|
||||
msgid "Webex"
|
||||
msgstr "Webex"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "vCenter"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Group filters used to define sync-scope for groups."
|
||||
msgstr ""
|
||||
@@ -5043,18 +4946,6 @@ msgstr ""
|
||||
msgid "Static: Static value, displayed as-is."
|
||||
msgstr "Statisch: Statischer Wert, wird so angezeigt, wie er ist."
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Info): Static alert box with info styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Warning): Static alert box with warning styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "Alert (Danger): Static alert box with danger styling"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/prompt/models.py
|
||||
msgid "authentik: Selection of locales authentik supports"
|
||||
msgstr "Authentik: Auswahl der von Authentik unterstützten Gebietsschemata"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-06 00:27+0000\n"
|
||||
"POT-Creation-Date: 2026-05-01 03:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -101,14 +101,6 @@ msgstr ""
|
||||
msgid "Blueprint file does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be valid JSON"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Context must be a JSON object"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/blueprints/api.py
|
||||
msgid "Failed to validate blueprint"
|
||||
msgstr ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user