mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 07:32:23 +02:00
Compare commits
27 Commits
web/csp
...
core/objec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a555570418 | ||
|
|
aa8463a6a8 | ||
|
|
e616cb8bac | ||
|
|
ddadbba685 | ||
|
|
b70dfe1cf0 | ||
|
|
aba6932a2d | ||
|
|
4b66289798 | ||
|
|
ba6060be77 | ||
|
|
e835418e76 | ||
|
|
e67c78ea85 | ||
|
|
5bbe099528 | ||
|
|
949b5d671a | ||
|
|
4eef34e223 | ||
|
|
e58cfd3b70 | ||
|
|
4ea9451e5f | ||
|
|
d6867895aa | ||
|
|
4727a0a69a | ||
|
|
1c226196b4 | ||
|
|
74f0def068 | ||
|
|
59afc1c7d9 | ||
|
|
6fda71763a | ||
|
|
059acf477e | ||
|
|
d5b9071fa7 | ||
|
|
607b4d6a7c | ||
|
|
09cb76bf7c | ||
|
|
a4e18ba849 | ||
|
|
d70bdc68ec |
@@ -1,5 +1,5 @@
|
||||
[alias]
|
||||
t = ["nextest", "run", "--workspace"]
|
||||
t = ["nextest", "run"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[licenses]
|
||||
allow = [
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
|
||||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -37,7 +37,7 @@ runs:
|
||||
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v5
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
@@ -64,12 +64,12 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@787505cde8a44ea468a00478fe52baf23b15bccd # v2
|
||||
uses: taiki-e/install-action@5939f3337e40968c39aa70f5ecb1417a92fb25a0 # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
||||
with:
|
||||
node-version-file: "${{ inputs.working-directory }}web/package.json"
|
||||
cache: "npm"
|
||||
@@ -77,7 +77,7 @@ runs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Setup node (root)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
||||
with:
|
||||
node-version-file: "${{ inputs.working-directory }}package.json"
|
||||
cache: "npm"
|
||||
|
||||
2
.github/actions/setup/compose.yml
vendored
2
.github/actions/setup/compose.yml
vendored
@@ -2,7 +2,7 @@ services:
|
||||
postgresql:
|
||||
image: docker.io/library/postgres:${PSQL_TAG:-16}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
- db-data:/var/lib/postgresql/data
|
||||
command: "-c log_statement=all"
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -66,14 +66,6 @@ updates:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
semver-patch-days: 3
|
||||
exclude:
|
||||
- aws-lc-fips-sys
|
||||
- aws-lc-rs
|
||||
- aws-lc-sys
|
||||
- rustls
|
||||
- rustls-pki-types
|
||||
- rustls-platform-verifier
|
||||
- rustls-webpki
|
||||
|
||||
- package-ecosystem: rust-toolchain
|
||||
directory: "/"
|
||||
|
||||
2
.github/workflows/_reusable-docker-build.yml
vendored
2
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # v2
|
||||
- uses: int128/docker-manifest-create-action@44422a4b046d55dc036df622039ed3aec43c613c # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
|
||||
4
.github/workflows/ci-api-docs.yml
vendored
4
.github/workflows/ci-api-docs.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/ci-aws-cfn.yml
vendored
2
.github/workflows/ci-aws-cfn.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: lifecycle/aws/package.json
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
|
||||
5
.github/workflows/ci-main.yml
vendored
5
.github/workflows/ci-main.yml
vendored
@@ -127,10 +127,7 @@ jobs:
|
||||
with:
|
||||
postgresql_version: ${{ matrix.psql }}
|
||||
- name: run migrations to stable
|
||||
run: |
|
||||
docker ps
|
||||
docker logs setup-postgresql-1
|
||||
uv run python -m lifecycle.migrate
|
||||
run: uv run python -m lifecycle.migrate
|
||||
- name: checkout current code
|
||||
run: |
|
||||
set -x
|
||||
|
||||
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@@ -145,7 +145,7 @@ jobs:
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
6
.github/workflows/ci-web.yml
vendored
6
.github/workflows/ci-web.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
project: web
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.project }}/package.json
|
||||
cache: "npm"
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/gen-image-compress.yml
vendored
2
.github/workflows/gen-image-compress.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
id: compress
|
||||
uses: calibreapp/image-actions@e2cc8db5d49c849e00844dfebf01438318e96fa2 # main
|
||||
uses: calibreapp/image-actions@4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 # main
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
4
.github/workflows/packages-npm-publish.yml
vendored
4
.github/workflows/packages-npm-publish.yml
vendored
@@ -35,13 +35,13 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.package }}/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
|
||||
4
.github/workflows/release-publish.yml
vendored
4
.github/workflows/release-publish.yml
vendored
@@ -87,7 +87,7 @@ jobs:
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
359
Cargo.lock
generated
359
Cargo.lock
generated
@@ -17,18 +17,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -118,37 +106,6 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
|
||||
dependencies = [
|
||||
"argh_derive",
|
||||
"argh_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh_derive"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
|
||||
dependencies = [
|
||||
"argh_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh_shared"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
@@ -181,29 +138,6 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"argh",
|
||||
"authentik-axum",
|
||||
"authentik-common",
|
||||
"axum",
|
||||
"color-eyre",
|
||||
"eyre",
|
||||
"hyper-unix-socket",
|
||||
"hyper-util",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"nix 0.31.2",
|
||||
"pyo3",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authentik-axum"
|
||||
version = "2026.5.0-rc1"
|
||||
@@ -216,7 +150,6 @@ dependencies = [
|
||||
"eyre",
|
||||
"forwarded-header-value",
|
||||
"futures",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
@@ -298,9 +231,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.3"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-fips-sys",
|
||||
"aws-lc-sys",
|
||||
@@ -310,9 +243,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.40.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -322,9 +255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
@@ -378,9 +311,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.1"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -577,9 +510,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -599,9 +532,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -634,33 +567,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"color-spantrace",
|
||||
"eyre",
|
||||
"indenter",
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-spantrace"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-core",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -822,15 +728,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -1080,12 +977,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1318,7 +1209,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1326,9 +1217,6 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1455,9 +1343,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1470,6 +1358,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -1504,20 +1393,6 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-unix-socket"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88978f1d73da0eb87d86555fcc40cbdd87bc86eb6525710b89db8c9278ec6a59"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1975,46 +1850,6 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.24.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"quanta",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-util"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.16.1",
|
||||
"metrics",
|
||||
"quanta",
|
||||
"rand 0.9.2",
|
||||
"rand_xoshiro",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2146,7 +1981,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2398,12 +2233,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -2480,6 +2309,12 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
@@ -2513,12 +2348,6 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2594,79 +2423,6 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
|
||||
dependencies = [
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2746,9 +2502,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -2803,24 +2559,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2999,9 +2737,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.39"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3064,9 +2802,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -3413,12 +3151,6 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3583,7 +3315,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.8.6",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -3624,7 +3356,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.6",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3744,12 +3476,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3872,9 +3598,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3932,9 +3658,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -4143,9 +3869,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
@@ -4155,6 +3881,7 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4264,6 +3991,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -4284,9 +4017,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
|
||||
80
Cargo.toml
80
Cargo.toml
@@ -20,13 +20,11 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
arc-swap = "= 1.9.1"
|
||||
argh = "= 0.1.19"
|
||||
axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] }
|
||||
aws-lc-rs = { version = "= 1.16.3", features = ["fips"] }
|
||||
axum = { version = "= 0.8.9", features = ["http2", "macros", "ws"] }
|
||||
clap = { version = "= 4.6.1", features = ["derive", "env"] }
|
||||
aws-lc-rs = { version = "= 1.16.2", features = ["fips"] }
|
||||
axum = { version = "= 0.8.8", features = ["http2", "macros", "ws"] }
|
||||
clap = { version = "= 4.6.0", features = ["derive", "env"] }
|
||||
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
|
||||
color-eyre = "= 0.6.5"
|
||||
colored = "= 3.1.1"
|
||||
config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [
|
||||
"json",
|
||||
@@ -39,16 +37,11 @@ eyre = "= 0.6.12"
|
||||
forwarded-header-value = "= 0.1.1"
|
||||
futures = "= 0.3.32"
|
||||
glob = "= 0.3.3"
|
||||
hyper-unix-socket = "= 0.6.1"
|
||||
hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
metrics = "= 0.24.3"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
|
||||
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
nix = { version = "= 0.31.2", features = ["signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.2", features = [
|
||||
"form",
|
||||
@@ -65,7 +58,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.39", features = ["fips"] }
|
||||
rustls = { version = "= 0.23.37", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
@@ -96,7 +89,7 @@ sqlx = { version = "= 0.8.6", default-features = false, features = [
|
||||
tempfile = "= 3.27.0"
|
||||
thiserror = "= 2.0.18"
|
||||
time = { version = "= 0.3.47", features = ["macros"] }
|
||||
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
|
||||
tokio = { version = "= 1.51.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
@@ -111,12 +104,18 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
"tracing-log",
|
||||
] }
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
|
||||
uuid = { version = "= 1.23.0", features = ["serde", "v4"] }
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
|
||||
ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false }
|
||||
|
||||
[profile.dev.package.backtrace]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
debug = 2
|
||||
|
||||
[workspace.lints.rust]
|
||||
ambiguous_negative_literals = "warn"
|
||||
closure_returning_async_block = "warn"
|
||||
@@ -230,54 +229,3 @@ unused_trait_names = "warn"
|
||||
unwrap_in_result = "warn"
|
||||
unwrap_used = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
|
||||
[profile.dev.package.backtrace]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
|
||||
[profile.release]
|
||||
debug = 2
|
||||
lto = "fat"
|
||||
# Because of the async runtime, we want to die straightaway if we panic.
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[package]
|
||||
name = "authentik"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
readme.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
license-file.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["core", "proxy"]
|
||||
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = ["ak-common/proxy"]
|
||||
|
||||
[dependencies]
|
||||
ak-axum.workspace = true
|
||||
ak-common.workspace = true
|
||||
arc-swap.workspace = true
|
||||
argh.workspace = true
|
||||
axum.workspace = true
|
||||
color-eyre.workspace = true
|
||||
eyre.workspace = true
|
||||
hyper-unix-socket.workspace = true
|
||||
hyper-util.workspace = true
|
||||
metrics.workspace = true
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
nix.workspace = true
|
||||
pyo3 = { workspace = true, optional = true }
|
||||
sqlx = { workspace = true, optional = true }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
3
Makefile
3
Makefile
@@ -115,9 +115,6 @@ run-server: ## Run the main authentik server process
|
||||
run-worker: ## Run the main authentik worker process
|
||||
$(UV) run ak worker
|
||||
|
||||
run-worker-watch: ## Run the authentik worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
|
||||
|
||||
core-i18n-extract:
|
||||
$(UV) run ak makemessages \
|
||||
--add-location file \
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Meta API"""
|
||||
|
||||
from django.apps import apps
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import AttributesMixin
|
||||
from authentik.lib.api import Models
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
|
||||
@@ -36,12 +39,19 @@ class AppsViewSet(ViewSet):
|
||||
class ModelViewSet(ViewSet):
|
||||
"""Read-only view list all installed models"""
|
||||
|
||||
class ModelFilterSerializer(PassiveSerializer):
|
||||
filter_has_attributes = BooleanField(allow_null=True, default=None)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses={200: AppSerializer(many=True)})
|
||||
def list(self, request: Request) -> Response:
|
||||
@extend_schema(responses={200: AppSerializer(many=True)}, parameters=[ModelFilterSerializer])
|
||||
@validate(ModelFilterSerializer, "query")
|
||||
def list(self, request: Request, query: ModelFilterSerializer) -> Response:
|
||||
"""Read-only view list all installed models"""
|
||||
data = []
|
||||
for name, label in Models.choices:
|
||||
if query.validated_data["filter_has_attributes"]:
|
||||
if not issubclass(apps.get_model(name), AttributesMixin):
|
||||
continue
|
||||
data.append({"name": name, "label": label})
|
||||
return Response(AppSerializer(data, many=True).data)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
@@ -164,19 +164,16 @@ class S3Backend(ManageableBackend):
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
client = self.client
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
|
||||
operation_name = "GetObject"
|
||||
operation_model = client.meta.service_model.operation_model(operation_name)
|
||||
request_dict = client._convert_to_request_dict(
|
||||
params,
|
||||
operation_model,
|
||||
endpoint_url=client.meta.endpoint_url,
|
||||
context={"is_presign_request": True},
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
@@ -186,8 +183,9 @@ class S3Backend(ManageableBackend):
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
path = request_dict["url_path"]
|
||||
path = parsed.path
|
||||
|
||||
# When using path-style addressing, the presigned URL contains the bucket
|
||||
# name in the path (e.g., /bucket-name/key). Since custom_domain must
|
||||
@@ -202,22 +200,9 @@ class S3Backend(ManageableBackend):
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
|
||||
custom_base = urlsplit(f"{scheme}://{custom_domain}")
|
||||
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
|
||||
|
||||
# Sign the final public URL instead of signing the internal S3 endpoint and
|
||||
# rewriting it afterwards. Presigned SigV4 URLs include the host header in the
|
||||
# canonical request, so post-sign host changes break strict backends like RustFS.
|
||||
public_path = f"{custom_base.path.rstrip('/')}{path}" if custom_base.path else path
|
||||
request_dict["url_path"] = public_path
|
||||
request_dict["url"] = urlunsplit(
|
||||
(custom_base.scheme, custom_base.netloc, public_path, "", "")
|
||||
)
|
||||
|
||||
return client._request_signer.generate_presigned_url(
|
||||
request_dict,
|
||||
operation_name,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
return url
|
||||
|
||||
if use_cache:
|
||||
return self._cache_get_or_set(name, request, _file_url, expires_in)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from unittest import skipUnless
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from botocore.exceptions import UnsupportedSignatureVersionError
|
||||
from django.test import TestCase
|
||||
@@ -169,44 +168,6 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
f"URL: {url}",
|
||||
)
|
||||
|
||||
@CONFIG.patch("storage.s3.secure_urls", False)
|
||||
@CONFIG.patch("storage.s3.addressing_style", "path")
|
||||
def test_file_url_custom_domain_resigns_for_custom_host(self):
|
||||
"""Test presigned URLs are signed for the custom domain host.
|
||||
|
||||
Host-changing custom domains must produce a signature query string for
|
||||
the public host, not reuse the internal endpoint signature.
|
||||
"""
|
||||
bucket_name = self.media_s3_bucket_name
|
||||
key_name = "application-icons/test.svg"
|
||||
custom_domain = f"files.example.test:8020/{bucket_name}"
|
||||
|
||||
endpoint_signed_url = self.media_s3_backend.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={
|
||||
"Bucket": bucket_name,
|
||||
"Key": f"{self.media_s3_backend.base_path}/{key_name}",
|
||||
},
|
||||
ExpiresIn=900,
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
|
||||
custom_url = self.media_s3_backend.file_url(key_name, use_cache=False)
|
||||
|
||||
endpoint_parts = urlsplit(endpoint_signed_url)
|
||||
custom_parts = urlsplit(custom_url)
|
||||
|
||||
self.assertEqual(custom_parts.scheme, "http")
|
||||
self.assertEqual(custom_parts.netloc, "files.example.test:8020")
|
||||
self.assertEqual(parse_qs(custom_parts.query)["X-Amz-SignedHeaders"], ["host"])
|
||||
self.assertNotEqual(
|
||||
custom_parts.query,
|
||||
endpoint_parts.query,
|
||||
"Custom-domain URLs must be signed for the public host, not reuse the endpoint "
|
||||
"signature query string.",
|
||||
)
|
||||
|
||||
def test_themed_urls_without_theme_variable(self):
|
||||
"""Test themed_urls returns None when filename has no %(theme)s"""
|
||||
result = self.media_s3_backend.themed_urls("logo.png")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Apply blueprint from commandline"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from sys import exit as sys_exit
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
@@ -32,5 +31,5 @@ class Command(BaseCommand):
|
||||
sys_exit(1)
|
||||
importer.apply()
|
||||
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("blueprints", nargs="+", type=str)
|
||||
|
||||
@@ -66,5 +66,4 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"footer_links": tenant.footer_links,
|
||||
"html_meta": {**get_http_meta()},
|
||||
"version": authentik_full_version(),
|
||||
"csp_nonce": request.request_id,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_IMPLICIT = "implicit"
|
||||
GRANT_TYPE_HYBRID = "hybrid"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
GRANT_TYPE_PASSWORD = "password" # nosec
|
||||
|
||||
@@ -6,6 +6,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import (
|
||||
@@ -14,7 +15,7 @@ from authentik.core.models import (
|
||||
)
|
||||
|
||||
|
||||
class ApplicationEntitlementSerializer(ModelSerializer):
|
||||
class ApplicationEntitlementSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""ApplicationEntitlement Serializer"""
|
||||
|
||||
def validate_app(self, app: Application) -> Application:
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Case, Q, QuerySet
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -36,13 +36,9 @@ from authentik.rbac.filters import ObjectFilter
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def user_app_cache_key(
|
||||
user_pk: str, page_number: int | None = None, only_with_launch_url: bool = False
|
||||
) -> str:
|
||||
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
||||
"""Cache key where application list for user is saved"""
|
||||
key = f"{CACHE_PREFIX}app_access/{user_pk}"
|
||||
if only_with_launch_url:
|
||||
key += "/launch"
|
||||
if page_number:
|
||||
key += f"/{page_number}"
|
||||
return key
|
||||
@@ -278,19 +274,11 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
if superuser_full_list and request.user.is_superuser:
|
||||
return super().list(request)
|
||||
|
||||
only_with_launch_url = (
|
||||
str(request.query_params.get("only_with_launch_url", "false")).lower()
|
||||
) == "true"
|
||||
only_with_launch_url = str(
|
||||
request.query_params.get("only_with_launch_url", "false")
|
||||
).lower()
|
||||
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
if only_with_launch_url:
|
||||
# Pre-filter at DB level to skip expensive per-app policy evaluation
|
||||
# for apps that can never appear in the launcher:
|
||||
# - No meta_launch_url AND no provider: no possible launch URL
|
||||
# - meta_launch_url="blank://blank": documented convention to hide from launcher
|
||||
queryset = queryset.exclude(
|
||||
Q(meta_launch_url="", provider__isnull=True) | Q(meta_launch_url="blank://blank")
|
||||
)
|
||||
paginator: Pagination = self.paginator
|
||||
paginated_apps = paginator.paginate_queryset(queryset, request)
|
||||
|
||||
@@ -307,6 +295,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
@@ -316,26 +305,19 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps)
|
||||
if should_cache:
|
||||
allowed_applications = cache.get(
|
||||
user_app_cache_key(
|
||||
self.request.user.pk, paginator.page.number, only_with_launch_url
|
||||
)
|
||||
user_app_cache_key(self.request.user.pk, paginator.page.number)
|
||||
)
|
||||
if allowed_applications:
|
||||
# Re-fetch cached applications since pickled instances lose prefetched
|
||||
# relationships, causing N+1 queries during serialization
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
else:
|
||||
if not allowed_applications:
|
||||
LOGGER.debug("Caching allowed application list", page=paginator.page.number)
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps)
|
||||
cache.set(
|
||||
user_app_cache_key(
|
||||
self.request.user.pk, paginator.page.number, only_with_launch_url
|
||||
),
|
||||
user_app_cache_key(self.request.user.pk, paginator.page.number),
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url:
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
|
||||
@@ -30,6 +30,7 @@ from authentik.api.search.fields import (
|
||||
JSONSearchField,
|
||||
)
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import Group, User
|
||||
@@ -75,7 +76,7 @@ class RelatedGroupSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class GroupSerializer(ModelSerializer):
|
||||
class GroupSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
|
||||
94
authentik/core/api/object_attributes.py
Normal file
94
authentik/core/api/object_attributes.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import AttributesMixin, ObjectAttribute
|
||||
from authentik.lib.utils.dict import get_path_from_dict
|
||||
|
||||
|
||||
class AttributesMixinSerializer(ModelSerializer):
|
||||
|
||||
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
model = self.Meta.model
|
||||
attrs = data.get("attributes", {})
|
||||
attributes = ObjectAttribute.objects.filter(
|
||||
object_type=ContentType.objects.get_for_model(model),
|
||||
enabled=True,
|
||||
)
|
||||
for attr in attributes:
|
||||
value = get_path_from_dict(attrs, attr.key)
|
||||
attr.run_validation(value)
|
||||
return data
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
app_label = CharField(read_only=True)
|
||||
model = CharField(read_only=True)
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
fully_qualified_model = SerializerMethodField()
|
||||
|
||||
def get_fully_qualified_model(self, ct: ContentType) -> str:
|
||||
return f"{ct.app_label}.{ct.model}"
|
||||
|
||||
def get_verbose_name_plural(self, ct: ContentType) -> str:
|
||||
return ct.model_class()._meta.verbose_name_plural
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model", "verbose_name_plural", "fully_qualified_model")
|
||||
|
||||
|
||||
class ObjectAttributeSerializer(ModelSerializer):
|
||||
|
||||
object_type = CharField()
|
||||
object_type_obj = ContentTypeSerializer(read_only=True, source="object_type")
|
||||
|
||||
def validate_object_type(self, fqm: str) -> ContentType:
|
||||
app_label, _, model = fqm.partition(".")
|
||||
ct = ContentType.objects.filter(app_label=app_label, model=model).first()
|
||||
if not ct or not issubclass(ct.model_class(), AttributesMixin):
|
||||
raise ValidationError("Invalid object type")
|
||||
return ct
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
if attrs.get("is_unique") and attrs.get("is_array"):
|
||||
raise ValidationError(_("Unique cannot be enabled for arrays."))
|
||||
return super().validate(attrs)
|
||||
|
||||
class Meta:
|
||||
model = ObjectAttribute
|
||||
fields = [
|
||||
"pk",
|
||||
"object_type",
|
||||
"object_type_obj",
|
||||
"enabled",
|
||||
"created",
|
||||
"key",
|
||||
"label",
|
||||
"last_updated",
|
||||
"regex",
|
||||
"type",
|
||||
"group",
|
||||
"managed",
|
||||
"is_unique",
|
||||
"is_required",
|
||||
"is_array",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"last_updated": {"read_only": True},
|
||||
"created": {"read_only": True},
|
||||
"pk": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class ObjectAttributeViewSet(ModelViewSet):
|
||||
serializer_class = ObjectAttributeSerializer
|
||||
queryset = ObjectAttribute.objects.all()
|
||||
filterset_fields = ["object_type__model", "object_type__app_label", "enabled"]
|
||||
search_fields = ["key", "label", "group", "object_type__model", "object_type__app_label"]
|
||||
ordering = ["key"]
|
||||
@@ -63,6 +63,7 @@ from authentik.api.search.fields import (
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import (
|
||||
JSONDictField,
|
||||
@@ -128,7 +129,7 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class UserSerializer(ModelSerializer):
|
||||
class UserSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""User Serializer"""
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
|
||||
@@ -7,12 +7,6 @@ from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
|
||||
class Setup(Flag[bool], key="setup"):
|
||||
|
||||
default = False
|
||||
visibility = "system"
|
||||
|
||||
|
||||
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
|
||||
|
||||
default = True
|
||||
@@ -32,10 +26,6 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
||||
mountpoint = ""
|
||||
default = True
|
||||
|
||||
def import_related(self):
|
||||
super().import_related()
|
||||
self.import_module("authentik.core.setup.signals")
|
||||
|
||||
@ManagedAppConfig.reconcile_tenant
|
||||
def source_inbuilt(self):
|
||||
"""Reconcile inbuilt source"""
|
||||
|
||||
62
authentik/core/migrations/0058_objectattribute.py
Normal file
62
authentik/core/migrations/0058_objectattribute.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-11 18:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ObjectAttribute",
|
||||
fields=[
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"managed",
|
||||
models.TextField(
|
||||
default=None,
|
||||
help_text="Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Managed by authentik",
|
||||
),
|
||||
),
|
||||
(
|
||||
"attribute_id",
|
||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("label", models.TextField()),
|
||||
("group", models.TextField(blank=True)),
|
||||
("key", models.TextField()),
|
||||
(
|
||||
"type",
|
||||
models.TextField(
|
||||
choices=[("text", "Text"), ("number", "Number"), ("boolean", "Boolean")]
|
||||
),
|
||||
),
|
||||
("is_unique", models.BooleanField(default=False)),
|
||||
("is_required", models.BooleanField(default=False)),
|
||||
("regex", models.TextField(blank=True)),
|
||||
("is_array", models.BooleanField(default=False)),
|
||||
(
|
||||
"object_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Object Attribute",
|
||||
"verbose_name_plural": "Object Attributes",
|
||||
"unique_together": {("object_type", "key", "enabled")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,61 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-21 18:49
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from django.conf import settings
|
||||
from authentik.flows.models import FlowAuthenticationRequirement
|
||||
|
||||
VersionHistory = apps.get_model("authentik_admin", "VersionHistory")
|
||||
Flow = apps.get_model("authentik_flows", "Flow")
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Upgrading from a previous version
|
||||
if not settings.TEST and VersionHistory.objects.using(db_alias).count() > 1:
|
||||
return True
|
||||
# OOBE flow sets itself to this authentication requirement once finished
|
||||
if (
|
||||
Flow.objects.using(db_alias)
|
||||
.filter(
|
||||
slug="initial-setup", authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
|
||||
)
|
||||
.exists()
|
||||
):
|
||||
return True
|
||||
# non-akadmin and non-guardian anonymous user exist
|
||||
if (
|
||||
User.objects.using(db_alias)
|
||||
.exclude(username="akadmin")
|
||||
.exclude(username="AnonymousUser")
|
||||
.exists()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
is_already_setup = check_is_already_setup(apps, schema_editor)
|
||||
if is_already_setup:
|
||||
tenant = get_current_tenant()
|
||||
tenant.flags[Setup().key] = True
|
||||
tenant.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
# 0024_flow_authentication adds the `authentication` field.
|
||||
("authentik_flows", "0024_flow_authentication"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)]
|
||||
@@ -13,6 +13,7 @@ from deepmerge import always_merger
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.models import AbstractUser, Permission
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.core.validators import validate_slug
|
||||
from django.db import models
|
||||
@@ -26,6 +27,7 @@ from guardian.models import RoleModelPermission, RoleObjectPermission
|
||||
from model_utils.managers import InheritanceManager
|
||||
from psqlextra.indexes import UniqueIndex
|
||||
from psqlextra.models import PostgresMaterializedViewModel
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@@ -790,13 +792,9 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
def get_provider(self) -> Provider | None:
|
||||
"""Get casted provider instance. Needs Application queryset with_provider"""
|
||||
if hasattr(self, "_cached_provider"):
|
||||
return self._cached_provider
|
||||
if not self.provider:
|
||||
self._cached_provider = None
|
||||
return None
|
||||
self._cached_provider = get_deepest_child(self.provider)
|
||||
return self._cached_provider
|
||||
return get_deepest_child(self.provider)
|
||||
|
||||
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
|
||||
"""Get Backchannel provider for a specific type"""
|
||||
@@ -1362,3 +1360,61 @@ class AuthenticatedSession(SerializerModel):
|
||||
session=Session.objects.filter(session_key=request.session.session_key).first(),
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
class ObjectAttribute(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
"""User-defined schema for models' `attributes` JSON field."""
|
||||
|
||||
class AttributeType(models.TextChoices):
|
||||
TEXT = "text"
|
||||
NUMBER = "number"
|
||||
BOOLEAN = "boolean"
|
||||
|
||||
attribute_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
label = models.TextField()
|
||||
group = models.TextField(blank=True)
|
||||
key = models.TextField()
|
||||
|
||||
type = models.TextField(choices=AttributeType.choices)
|
||||
is_unique = models.BooleanField(default=False)
|
||||
is_required = models.BooleanField(default=False)
|
||||
regex = models.TextField(blank=True)
|
||||
is_array = models.BooleanField(default=False)
|
||||
|
||||
def run_validation(self, value: Any) -> None:
|
||||
err_key = f"attributes_{self.key.replace(".", "_")}"
|
||||
if self.is_required and value is None:
|
||||
raise ValidationError({err_key: _("This field is required")})
|
||||
if self.is_array:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValidationError({err_key: _("Value must be an array.")})
|
||||
if self.regex != "":
|
||||
if not all(re.fullmatch(self.regex, v) for v in value):
|
||||
raise ValidationError({err_key: _("Value does not match configured pattern.")})
|
||||
else:
|
||||
if self.is_unique:
|
||||
model: type[models.Model] = self.object_type.model_class()
|
||||
lookup_key = f"attributes__{self.key.replace(".", "__")}"
|
||||
if model.objects.filter(**{lookup_key: value}).exists():
|
||||
raise ValidationError({err_key: _("Value is not unique.")})
|
||||
if self.regex != "":
|
||||
if not re.fullmatch(self.regex, value):
|
||||
raise ValidationError({err_key: _("Value does not match configured pattern.")})
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.core.api.object_attributes import ObjectAttributeSerializer
|
||||
|
||||
return ObjectAttributeSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"Object attribute '{self.key}' for content type {self.object_type_id}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Object Attribute")
|
||||
verbose_name_plural = _("Object Attributes")
|
||||
unique_together = (("object_type", "key", "enabled"),)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from os import getenv
|
||||
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.root.signals import post_startup
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
BOOTSTRAP_BLUEPRINT = "system/bootstrap.yaml"
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_startup)
|
||||
def post_startup_setup_bootstrap(sender, **_):
|
||||
if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"):
|
||||
return
|
||||
LOGGER.info("Configuring authentik through bootstrap environment variables")
|
||||
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()
|
||||
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
|
||||
# sync, so that we can sure the first start actually has working bootstrap
|
||||
# credentials
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
if Setup.get(tenant=tenant):
|
||||
LOGGER.info("Tenant is already setup, skipping", tenant=tenant.schema_name)
|
||||
continue
|
||||
with tenant:
|
||||
importer = Importer.from_string(content)
|
||||
valid, logs = importer.validate()
|
||||
if not valid:
|
||||
LOGGER.warning("Blueprint invalid", tenant=tenant.schema_name)
|
||||
for log in logs:
|
||||
log.log()
|
||||
importer.apply()
|
||||
Setup.set(True, tenant=tenant)
|
||||
@@ -1,80 +0,0 @@
|
||||
from functools import lru_cache
|
||||
from http import HTTPMethod, HTTPStatus
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.flows.models import Flow, FlowAuthenticationRequirement, in_memory_stage
|
||||
from authentik.flows.planner import FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
|
||||
LOGGER = get_logger()
|
||||
FLOW_CONTEXT_START_BY = "goauthentik.io/core/setup/started-by"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def read_static(path: str) -> str | None:
|
||||
result = finders.find(path)
|
||||
if not result:
|
||||
return None
|
||||
with open(result, encoding="utf8") as _file:
|
||||
return _file.read()
|
||||
|
||||
|
||||
class SetupView(View):
|
||||
|
||||
setup_flow_slug = "initial-setup"
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||
if request.method != HTTPMethod.HEAD and Setup.get():
|
||||
return redirect(reverse("authentik_core:root-redirect"))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def head(self, request: HttpRequest, *args, **kwargs):
|
||||
if Setup.get():
|
||||
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
if not Flow.objects.filter(slug=self.setup_flow_slug).exists():
|
||||
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
return HttpResponse(status=HTTPStatus.OK)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
flow = Flow.objects.filter(slug=self.setup_flow_slug).first()
|
||||
if not flow:
|
||||
LOGGER.info("Setup flow does not exist yet, waiting for worker to finish")
|
||||
return HttpResponse(
|
||||
read_static("dist/standalone/loading/startup.html"),
|
||||
status=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
planner = FlowPlanner(flow)
|
||||
plan = planner.plan(request, {FLOW_CONTEXT_START_BY: "setup"})
|
||||
plan.append_stage(in_memory_stage(PostSetupStageView))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
|
||||
class PostSetupStageView(StageView):
|
||||
"""Run post-setup tasks"""
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Wrapper when this stage gets hit with a post request"""
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get(self, requeset: HttpRequest, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
# Remember we're setup
|
||||
Setup.set(True)
|
||||
# Disable OOBE Blueprints
|
||||
BlueprintInstance.objects.filter(
|
||||
**{"metadata__labels__blueprints.goauthentik.io/system-oobe": "true"}
|
||||
).update(enabled=False)
|
||||
# Make flow inaccessible
|
||||
Flow.objects.filter(slug="initial-setup").update(
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
|
||||
)
|
||||
return self.executor.stage_ok()
|
||||
@@ -1,7 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<script data-id="authentik-config" nonce="{{ csp_nonce }}">
|
||||
<script data-id="authentik-config">
|
||||
"use strict";
|
||||
|
||||
window.authentik = {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
|
||||
<meta name="darkreader-lock">
|
||||
<script nonce="{{ csp_nonce }}">window.litNonce = "{{ csp_nonce }}";</script>
|
||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ brand.branding_favicon_url }}">
|
||||
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
||||
@@ -28,7 +27,7 @@
|
||||
|
||||
{% include "base/theme.html" %}
|
||||
|
||||
<style data-id="brand-css" nonce="{{ csp_nonce }}">{{ brand_css }}</style>
|
||||
<style data-id="brand-css">{{ brand_css }}</style>
|
||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% else %}
|
||||
<script data-id="theme-script" nonce="{{ csp_nonce }}">
|
||||
<script data-id="theme-script">
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
@@ -22,7 +22,7 @@
|
||||
if (!(["auto", "light", "dark"].includes(locallyStoredTheme))) {
|
||||
locallyStoredTheme = null;
|
||||
}
|
||||
|
||||
|
||||
const initialThemeChoice =
|
||||
new URLSearchParams(window.location.search).get("theme") || locallyStoredTheme;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style data-id="static-styles" nonce="{{ csp_nonce }}">
|
||||
<style data-id="static-styles">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import Application, UserTypes
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_user
|
||||
|
||||
@@ -13,7 +12,6 @@ class TestInterfaceRedirects(TestCase):
|
||||
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
|
||||
|
||||
def setUp(self):
|
||||
Setup.set(True)
|
||||
self.app = Application.objects.create(name="test-app", slug="test-app")
|
||||
self.brand: Brand = create_test_brand(default_application=self.app)
|
||||
|
||||
|
||||
198
authentik/core/tests/test_object_attributes_api.py
Normal file
198
authentik/core/tests/test_object_attributes_api.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Test object attributes API"""
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.api.object_attributes import ContentType
|
||||
from authentik.core.models import ObjectAttribute, User
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestObjectAttributesAPI(APITestCase):
|
||||
"""Test object attributes API"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_create(self):
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:objectattribute-list"),
|
||||
data={
|
||||
"object_type": "authentik_core.user",
|
||||
"enabled": False,
|
||||
"key": "employeeNumber",
|
||||
"label": "Employee Number",
|
||||
"type": "text",
|
||||
"group": "Employee",
|
||||
"is_unique": False,
|
||||
"is_required": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
attr = ObjectAttribute.objects.filter(key="employeeNumber").first()
|
||||
self.assertIsNotNone(attr)
|
||||
|
||||
def test_create_invalid(self):
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:objectattribute-list"),
|
||||
data={
|
||||
"object_type": "authentik_core.objectattribute",
|
||||
"enabled": False,
|
||||
"key": "employeeNumber",
|
||||
"label": "Employee Number",
|
||||
"type": "text",
|
||||
"group": "Employee",
|
||||
"is_unique": False,
|
||||
"is_required": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(res.content, {"object_type": ["Invalid object type"]})
|
||||
|
||||
def test_create_invalid_array_unique(self):
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:objectattribute-list"),
|
||||
data={
|
||||
"object_type": "authentik_core.user",
|
||||
"enabled": False,
|
||||
"key": "employeeNumber",
|
||||
"label": "Employee Number",
|
||||
"type": "text",
|
||||
"group": "Employee",
|
||||
"is_unique": True,
|
||||
"is_required": False,
|
||||
"is_array": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"non_field_errors": ["Unique cannot be enabled for arrays."]}
|
||||
)
|
||||
|
||||
def test_update(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
)
|
||||
res = self.client.put(
|
||||
reverse("authentik_api:objectattribute-detail", kwargs={"pk": attr.pk}),
|
||||
data={
|
||||
"object_type": "authentik_core.user",
|
||||
"enabled": False,
|
||||
"key": attr.key,
|
||||
"label": "Employee Number",
|
||||
"type": "text",
|
||||
"group": "Employee",
|
||||
"is_unique": False,
|
||||
"is_required": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
attr.refresh_from_db()
|
||||
self.assertEqual(attr.label, "Employee Number")
|
||||
|
||||
def test_user_attrib_validation_required(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
is_required=True,
|
||||
)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(res.content, {f"attributes_{attr.key}": ["This field is required"]})
|
||||
|
||||
def test_user_attrib_validation_unique(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
is_unique=True,
|
||||
)
|
||||
other_user = create_test_user()
|
||||
other_user.attributes[attr.key] = "foo"
|
||||
other_user.save()
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {attr.key: "foo"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(res.content, {f"attributes_{attr.key}": ["Value is not unique."]})
|
||||
|
||||
def test_user_attrib_validation_regex(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
regex="bar",
|
||||
)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {attr.key: "foo"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {f"attributes_{attr.key}": ["Value does not match configured pattern."]}
|
||||
)
|
||||
|
||||
def test_user_attrib_validation_array(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
is_array=True,
|
||||
)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {attr.key: "foo"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(res.content, {f"attributes_{attr.key}": ["Value must be an array."]})
|
||||
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {attr.key: ["foo"]},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_user_attrib_validation_array_regex(self):
|
||||
attr = ObjectAttribute.objects.create(
|
||||
object_type=ContentType.objects.get_for_model(User),
|
||||
label="foo",
|
||||
key=generate_id(),
|
||||
type=ObjectAttribute.AttributeType.TEXT,
|
||||
is_array=True,
|
||||
regex="bar",
|
||||
)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data={
|
||||
"attributes": {attr.key: ["foo"]},
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {f"attributes_{attr.key}": ["Value does not match configured pattern."]}
|
||||
)
|
||||
@@ -1,156 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
from os import environ
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root.signals import post_startup, pre_startup
|
||||
from authentik.tenants.flags import patch_flag
|
||||
|
||||
|
||||
class TestSetup(FlowTestCase):
|
||||
def tearDown(self):
|
||||
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
|
||||
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
|
||||
|
||||
@patch_flag(Setup, True)
|
||||
def test_setup(self):
|
||||
"""Test existing instance"""
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_flows:default-authentication") + "?next=/",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:root-redirect"),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
def test_not_setup_no_flow(self):
|
||||
"""Test case on initial startup; setup flag is not set and oobe flow does
|
||||
not exist yet"""
|
||||
Flow.objects.filter(slug="initial-setup").delete()
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
|
||||
# Flow does not exist, hence 503
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
def test_not_setup(self):
|
||||
"""Test case for when worker comes up, and has created flow"""
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
|
||||
# Flow does not exist, hence 503
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
@apply_blueprint("system/bootstrap.yaml")
|
||||
def test_setup_flow_full(self):
|
||||
"""Test full setup flow"""
|
||||
Setup.set(False)
|
||||
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
self.assertStageResponse(res, component="ak-stage-prompt")
|
||||
|
||||
pw = generate_id()
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
{
|
||||
"email": f"{generate_id()}@t.goauthentik.io",
|
||||
"password": pw,
|
||||
"password_repeat": pw,
|
||||
"component": "ak-stage-prompt",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
|
||||
self.assertTrue(Setup.get())
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.assertTrue(user.check_password(pw))
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
@apply_blueprint("system/bootstrap.yaml")
|
||||
def test_setup_flow_direct(self):
|
||||
"""Test setup flow, directly accessing the flow"""
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"})
|
||||
)
|
||||
self.assertStageResponse(
|
||||
res,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="Access the authentik setup by navigating to http://testserver/",
|
||||
)
|
||||
|
||||
def test_setup_bootstrap_env(self):
|
||||
"""Test setup with env vars"""
|
||||
User.objects.filter(username="akadmin").delete()
|
||||
Setup.set(False)
|
||||
|
||||
environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] = generate_id()
|
||||
environ["AUTHENTIK_BOOTSTRAP_TOKEN"] = generate_id()
|
||||
pre_startup.send(sender=self)
|
||||
post_startup.send(sender=self)
|
||||
|
||||
self.assertTrue(Setup.get())
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.assertTrue(user.check_password(environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]))
|
||||
|
||||
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])
|
||||
@@ -1,6 +1,7 @@
|
||||
"""authentik URL Configuration"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
|
||||
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
|
||||
@@ -8,6 +9,7 @@ from authentik.core.api.applications import ApplicationViewSet
|
||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
|
||||
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
|
||||
from authentik.core.api.groups import GroupViewSet
|
||||
from authentik.core.api.object_attributes import ObjectAttributeViewSet
|
||||
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
||||
from authentik.core.api.providers import ProviderViewSet
|
||||
from authentik.core.api.sources import (
|
||||
@@ -18,7 +20,6 @@ from authentik.core.api.sources import (
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.core.setup.views import SetupView
|
||||
from authentik.core.views.apps import RedirectToAppLaunch
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import (
|
||||
@@ -35,7 +36,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
RootRedirectView.as_view(),
|
||||
login_required(RootRedirectView.as_view()),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
@@ -62,11 +63,6 @@ urlpatterns = [
|
||||
FlowInterfaceView.as_view(),
|
||||
name="if-flow",
|
||||
),
|
||||
path(
|
||||
"setup",
|
||||
SetupView.as_view(),
|
||||
name="setup",
|
||||
),
|
||||
# Fallback for WS
|
||||
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
|
||||
path(
|
||||
@@ -87,6 +83,7 @@ api_urlpatterns = [
|
||||
("core/groups", GroupViewSet),
|
||||
("core/users", UserViewSet),
|
||||
("core/tokens", TokenViewSet),
|
||||
("core/object_attributes", ObjectAttributeViewSet),
|
||||
("sources/all", SourceViewSet),
|
||||
("sources/user_connections/all", UserSourceConnectionViewSet),
|
||||
("sources/group_connections/all", GroupSourceConnectionViewSet),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
@@ -15,13 +14,12 @@ from authentik.admin.tasks import LOCAL_VERSION
|
||||
from authentik.api.v3.config import ConfigView
|
||||
from authentik.brands.api import CurrentBrandSerializer
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import UserTypes
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
|
||||
|
||||
class RootRedirectView(AccessMixin, RedirectView):
|
||||
class RootRedirectView(RedirectView):
|
||||
"""Root redirect view, redirect to brand's default application if set"""
|
||||
|
||||
pattern_name = "authentik_core:if-user"
|
||||
@@ -42,10 +40,6 @@ class RootRedirectView(AccessMixin, RedirectView):
|
||||
return None
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if not Setup.get():
|
||||
return redirect("authentik_core:setup")
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
if redirect_response := RootRedirectView().redirect_to_app(request):
|
||||
return redirect_response
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.endpoints.models import DeviceAccessGroup
|
||||
|
||||
|
||||
class DeviceAccessGroupSerializer(ModelSerializer):
|
||||
class DeviceAccessGroupSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceAccessGroup
|
||||
|
||||
@@ -138,7 +138,13 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
"AllowDeviceIdentifiersInAttestation": True,
|
||||
"AuthenticationMethod": "UserSecureEnclaveKey",
|
||||
"EnableAuthorization": True,
|
||||
"EnableCreateUserAtLogin": True,
|
||||
"FileVaultPolicy": ["RequireAuthentication"],
|
||||
"LoginPolicy": ["RequireAuthentication"],
|
||||
"NewUserAuthorizationMode": "Standard",
|
||||
"UnlockPolicy": ["RequireAuthentication"],
|
||||
"UseSharedDeviceKeys": True,
|
||||
"UserAuthorizationMode": "Standard",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-06 14:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_endpoints_connectors_agent",
|
||||
"0004_agentconnector_challenge_idle_timeout_and_more",
|
||||
),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AppleIndependentSecureEnclave",
|
||||
fields=[
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="The human-readable name of this device.", max_length=64
|
||||
),
|
||||
),
|
||||
(
|
||||
"confirmed",
|
||||
models.BooleanField(default=True, help_text="Is this device ready for use?"),
|
||||
),
|
||||
("last_used", models.DateTimeField(null=True)),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
("apple_secure_enclave_key", models.TextField()),
|
||||
("apple_enclave_key_id", models.TextField()),
|
||||
("device_type", models.TextField()),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
help_text="The user that this device belongs to.",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Apple Independent Secure Enclave",
|
||||
"verbose_name_plural": "Apple Independent Secure Enclaves",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -19,7 +19,6 @@ from authentik.flows.stage import StageView
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import Device as Authenticator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
|
||||
@@ -173,17 +172,3 @@ class AppleNonce(InternallyManagedMixin, ExpiringModel):
|
||||
class Meta(ExpiringModel.Meta):
|
||||
verbose_name = _("Apple Nonce")
|
||||
verbose_name_plural = _("Apple Nonces")
|
||||
|
||||
|
||||
class AppleIndependentSecureEnclave(Authenticator):
|
||||
"""A device-independent secure enclave key, used by Tap-to-login"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid4)
|
||||
|
||||
apple_secure_enclave_key = models.TextField()
|
||||
apple_enclave_key_id = models.TextField()
|
||||
device_type = models.TextField()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Apple Independent Secure Enclave")
|
||||
verbose_name_plural = _("Apple Independent Secure Enclaves")
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.controller import BaseController
|
||||
from authentik.endpoints.models import StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@@ -27,22 +25,16 @@ class TestAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_endpoint_stage_agent_no_stage(self):
|
||||
connector = AgentConnector.objects.create(name=generate_id())
|
||||
|
||||
class controller(BaseController):
|
||||
def capabilities(self):
|
||||
return []
|
||||
|
||||
with patch.object(AgentConnector, "controller", PropertyMock(return_value=controller)):
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:stages-endpoint-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"connector": str(connector.pk),
|
||||
"mode": StageMode.REQUIRED,
|
||||
},
|
||||
)
|
||||
def test_endpoint_stage_fleet(self):
|
||||
connector = FleetConnector.objects.create(name=generate_id())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:stages-endpoint-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"connector": str(connector.pk),
|
||||
"mode": StageMode.REQUIRED,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"connector": ["Selected connector is not compatible with this stage."]}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.endpoints.connectors.agent.models import AppleIndependentSecureEnclave
|
||||
|
||||
|
||||
class AppleIndependentSecureEnclaveSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = AppleIndependentSecureEnclave
|
||||
fields = [
|
||||
"uuid",
|
||||
"user",
|
||||
"apple_secure_enclave_key",
|
||||
"apple_enclave_key_id",
|
||||
"device_type",
|
||||
]
|
||||
|
||||
|
||||
class AppleIndependentSecureEnclaveViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = AppleIndependentSecureEnclave.objects.all()
|
||||
serializer_class = AppleIndependentSecureEnclaveSerializer
|
||||
search_fields = [
|
||||
"name",
|
||||
"user__name",
|
||||
]
|
||||
ordering = ["uuid"]
|
||||
filterset_fields = ["user", "apple_enclave_key_id"]
|
||||
@@ -11,7 +11,6 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
AgentDeviceUserBinding,
|
||||
AppleIndependentSecureEnclave,
|
||||
AppleNonce,
|
||||
DeviceToken,
|
||||
EnrollmentToken,
|
||||
@@ -26,7 +25,7 @@ class TestAppleToken(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.apple_sign_key = create_test_cert(PrivateKeyAlg.ECDSA)
|
||||
self.sign_key_pem = self.apple_sign_key.public_key.public_bytes(
|
||||
sign_key_pem = self.apple_sign_key.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
@@ -51,7 +50,7 @@ class TestAppleToken(TestCase):
|
||||
device=self.device,
|
||||
connector=self.connector,
|
||||
apple_sign_key_id=self.apple_sign_key.kid,
|
||||
apple_signing_key=self.sign_key_pem,
|
||||
apple_signing_key=sign_key_pem,
|
||||
apple_encryption_key=self.enc_pub,
|
||||
)
|
||||
self.user = create_test_user()
|
||||
@@ -60,7 +59,7 @@ class TestAppleToken(TestCase):
|
||||
user=self.user,
|
||||
order=0,
|
||||
apple_enclave_key_id=self.apple_sign_key.kid,
|
||||
apple_secure_enclave_key=self.sign_key_pem,
|
||||
apple_secure_enclave_key=sign_key_pem,
|
||||
)
|
||||
self.device_token = DeviceToken.objects.create(device=self.connection)
|
||||
|
||||
@@ -114,62 +113,3 @@ class TestAppleToken(TestCase):
|
||||
).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context["device"]["name"], self.device.name)
|
||||
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_token_independent(self):
|
||||
nonce = generate_id()
|
||||
|
||||
AgentDeviceUserBinding.objects.all().delete()
|
||||
AppleIndependentSecureEnclave.objects.create(
|
||||
user=self.user,
|
||||
apple_enclave_key_id=self.apple_sign_key.kid,
|
||||
apple_secure_enclave_key=self.sign_key_pem,
|
||||
)
|
||||
|
||||
AppleNonce.objects.create(
|
||||
device_token=self.device_token,
|
||||
nonce=nonce,
|
||||
)
|
||||
embedded = encode(
|
||||
{"iss": str(self.connector.pk), "aud": str(self.device.pk), "request_nonce": nonce},
|
||||
self.apple_sign_key.private_key,
|
||||
headers={
|
||||
"kid": self.apple_sign_key.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(self.apple_sign_key.private_key),
|
||||
)
|
||||
assertion = encode(
|
||||
{
|
||||
"iss": str(self.connector.pk),
|
||||
"aud": "http://testserver/endpoints/agent/psso/token/",
|
||||
"request_nonce": nonce,
|
||||
"assertion": embedded,
|
||||
"jwe_crypto": {
|
||||
"apv": (
|
||||
"AAAABUFwcGxlAAAAQQTFgZOospN6KbkhXhx1lfa-AKYxjEfJhTJrkpdEY_srMmkPzS7VN0Bzt2AtNBEXE"
|
||||
"aphDONiP2Mq6Oxytv5JKOxHAAAAJDgyOThERkY5LTVFMUUtNEUwMS04OEUwLUI3QkQzOUM4QjA3Qw"
|
||||
)
|
||||
},
|
||||
},
|
||||
self.apple_sign_key.private_key,
|
||||
headers={
|
||||
"kid": self.apple_sign_key.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(self.apple_sign_key.private_key),
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_enterprise_endpoints_connectors_agent:psso-token"),
|
||||
data={
|
||||
"assertion": assertion,
|
||||
"platform_sso_version": "1.0",
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
event = Event.objects.filter(
|
||||
action=EventAction.LOGIN,
|
||||
app="authentik.endpoints.connectors.agent",
|
||||
).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context["device"]["name"], self.device.name)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.enterprise.endpoints.connectors.agent.api.secure_enclave import (
|
||||
AppleIndependentSecureEnclaveViewSet,
|
||||
)
|
||||
from authentik.enterprise.endpoints.connectors.agent.views.apple_jwks import AppleJWKSView
|
||||
from authentik.enterprise.endpoints.connectors.agent.views.apple_nonce import NonceView
|
||||
from authentik.enterprise.endpoints.connectors.agent.views.apple_register import (
|
||||
@@ -26,7 +23,6 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
api_urlpatterns = [
|
||||
("endpoints/agents/psso/ise", AppleIndependentSecureEnclaveViewSet),
|
||||
path(
|
||||
"endpoints/agents/psso/register/device/",
|
||||
RegisterDeviceView.as_view(),
|
||||
|
||||
@@ -19,7 +19,6 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
AgentDeviceUserBinding,
|
||||
AppleIndependentSecureEnclave,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
@@ -104,9 +103,7 @@ class TokenView(View):
|
||||
nonce.delete()
|
||||
return decoded
|
||||
|
||||
def validate_embedded_assertion(
|
||||
self, assertion: str
|
||||
) -> tuple[AgentDeviceUserBinding | AppleIndependentSecureEnclave, dict]:
|
||||
def validate_embedded_assertion(self, assertion: str) -> tuple[AgentDeviceUserBinding, dict]:
|
||||
"""Decode an embedded assertion and validate it by looking up the matching device user"""
|
||||
decode_unvalidated = get_unverified_header(assertion)
|
||||
expected_kid = decode_unvalidated["kid"]
|
||||
@@ -115,13 +112,8 @@ class TokenView(View):
|
||||
target=self.device_connection.device, apple_enclave_key_id=expected_kid
|
||||
).first()
|
||||
if not device_user:
|
||||
independent_user = AppleIndependentSecureEnclave.objects.filter(
|
||||
apple_enclave_key_id=expected_kid
|
||||
).first()
|
||||
if not independent_user:
|
||||
LOGGER.warning("Could not find device user binding or independent enclave for user")
|
||||
raise ValidationError("Invalid request")
|
||||
device_user = independent_user
|
||||
LOGGER.warning("Could not find device user binding for user")
|
||||
raise ValidationError("Invalid request")
|
||||
decoded: dict[str, Any] = decode(
|
||||
assertion,
|
||||
device_user.apple_secure_enclave_key,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import re
|
||||
from plistlib import loads
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from django.db import transaction
|
||||
from requests import RequestException
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
|
||||
from authentik.endpoints.facts import (
|
||||
DeviceFacts,
|
||||
@@ -48,7 +44,7 @@ class FleetController(BaseController[DBC]):
|
||||
return "fleetdm.com"
|
||||
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
|
||||
return [Capabilities.ENROLL_AUTOMATIC_API]
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.connector.url}{path}"
|
||||
@@ -80,44 +76,8 @@ class FleetController(BaseController[DBC]):
|
||||
except RequestException as exc:
|
||||
raise ConnectorSyncException(exc) from exc
|
||||
|
||||
@property
|
||||
def mtls_ca_managed(self) -> str:
|
||||
return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
|
||||
|
||||
def _sync_mtls_ca(self):
|
||||
"""Sync conditional access Root CA for mTLS"""
|
||||
try:
|
||||
# Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
|
||||
# hence we fetch the apple config profile and extract it
|
||||
res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
|
||||
res.raise_for_status()
|
||||
profile = loads(res.text).get("PayloadContent", [])
|
||||
raw_cert = None
|
||||
for payload in profile:
|
||||
if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
|
||||
continue
|
||||
raw_cert = payload.get("PayloadContent")
|
||||
if not raw_cert:
|
||||
raise ConnectorSyncException("Failed to get conditional acccess CA")
|
||||
except RequestException as exc:
|
||||
raise ConnectorSyncException(exc) from exc
|
||||
cert = load_der_x509_certificate(raw_cert)
|
||||
CertificateKeyPair.objects.update_or_create(
|
||||
managed=self.mtls_ca_managed,
|
||||
defaults={
|
||||
"name": f"Fleet Endpoint connector {self.connector.name}",
|
||||
"certificate_data": cert.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
).decode("utf-8"),
|
||||
},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def sync_endpoints(self) -> None:
|
||||
try:
|
||||
self._sync_mtls_ca()
|
||||
except ConnectorSyncException as exc:
|
||||
self.logger.warning("Failed to sync conditional access CA", exc=exc)
|
||||
for host in self._paginate_hosts():
|
||||
serial = host["hardware_serial"]
|
||||
device, _ = Device.objects.get_or_create(
|
||||
@@ -238,8 +198,6 @@ class FleetController(BaseController[DBC]):
|
||||
for policy in host.get("policies", [])
|
||||
],
|
||||
"agent_version": fleet_version,
|
||||
# Host UUID is required for conditional access matching
|
||||
"uuid": host.get("uuid", "").lower(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,12 +51,6 @@ class FleetConnector(Connector):
|
||||
def component(self) -> str:
|
||||
return "ak-endpoints-connector-fleet-form"
|
||||
|
||||
@property
|
||||
def stage(self):
|
||||
from authentik.enterprise.endpoints.connectors.fleet.stage import FleetStageView
|
||||
|
||||
return FleetStageView
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Fleet Connector")
|
||||
verbose_name_plural = _("Fleet Connectors")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
from cryptography.x509 import (
|
||||
Certificate,
|
||||
Extension,
|
||||
SubjectAlternativeName,
|
||||
UniformResourceIdentifier,
|
||||
)
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE, MTLSStageView
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
|
||||
FLEET_CONDITIONAL_ACCESS_URI_PREFIX = "urn:device:apple:uuid:"
|
||||
|
||||
|
||||
class FleetStageView(MTLSStageView):
|
||||
def get_authorities(self):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
connector = FleetConnector.objects.filter(pk=stage.connector_id).first()
|
||||
controller = connector.controller(connector)
|
||||
kp = CertificateKeyPair.objects.filter(managed=controller.mtls_ca_managed).first()
|
||||
return [kp] if kp else None
|
||||
|
||||
def lookup_device(self, cert: Certificate, mode: StageMode):
|
||||
san_ext: Extension[SubjectAlternativeName] = cert.extensions.get_extension_for_oid(
|
||||
SubjectAlternativeName.oid
|
||||
)
|
||||
raw_values = san_ext.value.get_values_for_type(UniformResourceIdentifier)
|
||||
values = [x.removeprefix(FLEET_CONDITIONAL_ACCESS_URI_PREFIX).lower() for x in raw_values]
|
||||
self.logger.debug("Looking for devices with uuid", fleet_device_uuid=values)
|
||||
device = Device.objects.filter(
|
||||
**{"deviceconnection__devicefactsnapshot__data__vendor__fleetdm.com__uuid__in": values}
|
||||
).first()
|
||||
if not device and mode == StageMode.REQUIRED:
|
||||
raise PermissionDenied("Failed to find device")
|
||||
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
|
||||
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
try:
|
||||
cert = self.get_cert(stage.mode)
|
||||
if not cert:
|
||||
return self.executor.stage_ok()
|
||||
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
|
||||
return self.lookup_device(cert, stage.mode)
|
||||
except PermissionDenied as exc:
|
||||
return self.executor.stage_invalid(error_message=exc.detail)
|
||||
@@ -1,23 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDwDCCAqigAwIBAgIBBDANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi
|
||||
BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF
|
||||
UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI2
|
||||
MDMxODExMTc1NFoXDTI3MDQyMDExMjc1NFowLDEqMCgGA1UEAxMhRmxlZXQgY29u
|
||||
ZGl0aW9uYWwgYWNjZXNzIGZvciBPa3RhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEA3xuKxQQ8JSA4qCJ6RfOB7tbQurhwXiaJSLUDG7R5ncdRcd9LH/9y
|
||||
5ZyI5kQACOwfICHmv02zR4/CrurfzXabo3CCpvcMdS7JI/FzP1GIIZ5RsR7oPFC6
|
||||
JJg3m5BHuoHsUtCD7w0D52WiE7XVfbw47h2ChKmGMhkSrBvQnp3dHFEt8ntbl1/q
|
||||
zCSuQaLeR2sQFurBDVBdinEgsvb1YHaYHi4tdFx5joG64Q/nJXyA2OM4hO9uBF+G
|
||||
c4UVTzubx5sxwONcPhC9H+eLMpF1VHeU9gAGBlruVusUEYDmlqYQuA+bW5fTr4Zd
|
||||
ZmJ5e+CzzUBYHduAML9a5S+1jbxSPZFBSwIDAQABo4GvMIGsMA4GA1UdDwEB/wQE
|
||||
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUPrc1+LvbR9WoJIWZ
|
||||
7YQa/3IX2w8wHwYDVR0jBBgwFoAUfl92kU2qcH4e+hypez4kEnqMbk4wRQYDVR0R
|
||||
BD4wPIY6dXJuOmRldmljZTphcHBsZTp1dWlkOjVCRjQyMkQ2LTZFQUItNTE1Ni1B
|
||||
QzVBLTlFQURDOTUyNDcxMzANBgkqhkiG9w0BAQsFAAOCAQEAGfxJ/u4271tnUUTB
|
||||
J39YU6z2Ciav+9G3BtbvxBXI57Po7zCE6Z1sVkvYq6Xd0CcItPWRjbSPEy78ZzS0
|
||||
By+gPy5fkKc8HHJ5I1wK890xbLBUS1P4EbdVBzI9ggouEa3B2asE10asnzLoKE4C
|
||||
0FYWQwrzCsso8yxsJj1S8RKtd6MMbCis/9OQSC8om2tu6cLO+OftVn5DHtNWFidw
|
||||
tAl/oHn2cZPUfZGpJGrHNZlp5w1c1dYfQeiPayoQIbsF+8eMV424G76z/8UPhMBs
|
||||
R23LByv4TlUOPAGn2TRa2WtLIXs7FgqXRIFW4CjsPsEpXSVNlkYcn/VHY7Jl13zz
|
||||
CRQ1Pg==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<!-- Trusted CA certificate -->
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>conditional_access_ca.der</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAiBgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NFUCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1MTIwOTEyMjI1MVoXDTM1MTIwOTEyMjI1MVowaTEJMAcGA1UEBhMAMSQwIgYDVQQKExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0ExJDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrgCcpzQci2UhH+Dn0eHopnnbx3HbMabMCHXm6xteMVFLrdQJDTFrZCQzcexUgbpPJ0az6mn4szo+E3stn0y2PPWsiAiVhFwp5M9HwNg18rPgDmITv2pM3l/hlEsfggjq6TEVO2gRcq4NujEGagcYX6kp6nWxh6bbRngQ/hlK6mXItWV3x0G9eTcbFObwZhbuC2dNbccytdqbVEIpBjp6fftQnQwAaUVjoyZBFlf1C1cDV4+1jpaVsIj11U1olA33GJCHcZQ4CJEsgh8yiSsvkH5RNf94CGINB5ixsMfppjSXV/vNkWDKEfmUXW2q4ft7KK/L/SRq8QSB4VqTAp2GsCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH5fdpFNqnB+HvocqXs+JBJ6jG5OMA0GCSqGSIb3DQEBCwUAA4IBAQAJr4bTGlrANoHStu4Y+OXjGbEQjZOe546Bcln4eWrEB16eaVzfKuZgjJYdcOmp36/v34QY/OCXEIsixrBU5aW/Sr53IK6UQSZV3O3xbBc4Aert7AbeJ4NVGZyelfVQo/5G0qM6k9p0+zpIZqNAzFbhcSPIzuE7ig2OGsFoQU+bXhzk09bsZ+u4BXibzVNfMuMG+DHNv0PRjll272nEPI3bGwHF5tdrnfJG6e9t+qK9j9UqmSlBknHQJNeU5o8IDcmWYjWtOuBzecYsg8pZzXabJqlHTBIz/h7waRe7jtrK+XopK3jghRf9JTL+i0Y8NbVjoNkIoS3xMeRhnNbR9lw1</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Fleet conditional access CA certificate</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Fleet conditional access CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.conditional-access-ca</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>ef1b2231-ad80-5511-9893-1f9838295147</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures SCEP enrollment for Okta conditional access</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Fleet conditional access for Okta</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.conditional-access-okta</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Fleet Device Management</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadScope</key>
|
||||
<string>User</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>6fa509a3-feca-56f7-a283-d6a81c733ed2</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"created_at": "2026-02-18T16:31:34Z",
|
||||
"updated_at": "2026-03-18T11:29:18Z",
|
||||
"created_at": "2025-06-25T22:21:35Z",
|
||||
"updated_at": "2025-12-20T11:42:09Z",
|
||||
"software": null,
|
||||
"software_updated_at": "2026-03-18T11:29:17Z",
|
||||
"id": 19,
|
||||
"detail_updated_at": "2026-03-18T11:29:18Z",
|
||||
"label_updated_at": "2026-03-18T11:29:18Z",
|
||||
"policy_updated_at": "2026-03-18T11:29:18Z",
|
||||
"last_enrolled_at": "2026-02-18T16:31:45Z",
|
||||
"seen_time": "2026-03-18T11:31:34Z",
|
||||
"software_updated_at": "2025-10-22T02:24:25Z",
|
||||
"id": 1,
|
||||
"detail_updated_at": "2025-10-23T23:30:31Z",
|
||||
"label_updated_at": "2025-10-23T23:30:31Z",
|
||||
"policy_updated_at": "2025-10-23T23:02:11Z",
|
||||
"last_enrolled_at": "2025-06-25T22:21:37Z",
|
||||
"seen_time": "2025-10-23T23:59:08Z",
|
||||
"refetch_requested": false,
|
||||
"hostname": "jens-mac-vm.local",
|
||||
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
|
||||
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
|
||||
"platform": "darwin",
|
||||
"osquery_version": "5.21.0",
|
||||
"osquery_version": "5.19.0",
|
||||
"orbit_version": null,
|
||||
"fleet_desktop_version": null,
|
||||
"scripts_enabled": null,
|
||||
"os_version": "macOS 26.3",
|
||||
"build": "25D125",
|
||||
"os_version": "macOS 26.0.1",
|
||||
"build": "25A362",
|
||||
"platform_like": "darwin",
|
||||
"code_name": "",
|
||||
"uptime": 653014000000000,
|
||||
"uptime": 256356000000000,
|
||||
"memory": 4294967296,
|
||||
"cpu_type": "arm64e",
|
||||
"cpu_subtype": "ARM64E",
|
||||
@@ -31,41 +31,38 @@
|
||||
"hardware_vendor": "Apple Inc.",
|
||||
"hardware_model": "VirtualMac2,1",
|
||||
"hardware_version": "",
|
||||
"hardware_serial": "ZV35VFDD50",
|
||||
"hardware_serial": "Z5DDF07GK6",
|
||||
"computer_name": "jens-mac-vm",
|
||||
"timezone": null,
|
||||
"public_ip": "92.116.179.252",
|
||||
"primary_ip": "192.168.64.7",
|
||||
"primary_mac": "5e:72:1c:89:98:29",
|
||||
"primary_ip": "192.168.85.3",
|
||||
"primary_mac": "e6:9d:21:c2:2f:19",
|
||||
"distributed_interval": 10,
|
||||
"config_tls_refresh": 60,
|
||||
"logger_tls_period": 10,
|
||||
"team_id": 5,
|
||||
"team_id": 2,
|
||||
"pack_stats": null,
|
||||
"team_name": "dev",
|
||||
"gigs_disk_space_available": 16.52,
|
||||
"percent_disk_space_available": 26,
|
||||
"team_name": "prod",
|
||||
"gigs_disk_space_available": 23.82,
|
||||
"percent_disk_space_available": 37,
|
||||
"gigs_total_disk_space": 62.83,
|
||||
"gigs_all_disk_space": null,
|
||||
"issues": {
|
||||
"failing_policies_count": 1,
|
||||
"critical_vulnerabilities_count": 0,
|
||||
"total_issues_count": 1
|
||||
"critical_vulnerabilities_count": 2,
|
||||
"total_issues_count": 3
|
||||
},
|
||||
"device_mapping": null,
|
||||
"mdm": {
|
||||
"enrollment_status": "On (manual)",
|
||||
"dep_profile_error": false,
|
||||
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
|
||||
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
|
||||
"name": "Fleet",
|
||||
"encryption_key_available": false,
|
||||
"connected_to_fleet": true
|
||||
},
|
||||
"refetch_critical_queries_until": null,
|
||||
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
|
||||
"status": "online",
|
||||
"last_restarted_at": "2025-10-21T00:17:55Z",
|
||||
"status": "offline",
|
||||
"display_text": "jens-mac-vm.local",
|
||||
"display_name": "jens-mac-vm",
|
||||
"fleet_id": 5,
|
||||
"fleet_name": "dev"
|
||||
"display_name": "jens-mac-vm"
|
||||
}
|
||||
|
||||
@@ -21,19 +21,12 @@ TEST_HOST = {"hosts": [TEST_HOST_UBUNTU, TEST_HOST_MACOS, TEST_HOST_WINDOWS, TES
|
||||
class TestFleetConnector(APITestCase):
|
||||
def setUp(self):
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(),
|
||||
url="http://localhost",
|
||||
token=generate_id(),
|
||||
map_teams_access_group=True,
|
||||
name=generate_id(), url="http://localhost", token=generate_id()
|
||||
)
|
||||
|
||||
def test_sync(self):
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json=TEST_HOST,
|
||||
@@ -47,9 +40,6 @@ class TestFleetConnector(APITestCase):
|
||||
identifier="VMware-56 4d 4a 5a b0 22 7b d7-9b a5 0b dc 8f f2 3b 60"
|
||||
).first()
|
||||
self.assertIsNotNone(device)
|
||||
group = device.access_group
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.name, "prod")
|
||||
self.assertEqual(
|
||||
device.cached_facts.data,
|
||||
{
|
||||
@@ -60,13 +50,7 @@ class TestFleetConnector(APITestCase):
|
||||
"version": "24.04.3 LTS",
|
||||
},
|
||||
"disks": [],
|
||||
"vendor": {
|
||||
"fleetdm.com": {
|
||||
"policies": [],
|
||||
"agent_version": "",
|
||||
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
|
||||
}
|
||||
},
|
||||
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
|
||||
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
|
||||
"hardware": {
|
||||
"model": "VMware20,1",
|
||||
@@ -88,10 +72,6 @@ class TestFleetConnector(APITestCase):
|
||||
self.connector.save()
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json=TEST_HOST,
|
||||
@@ -101,13 +81,11 @@ class TestFleetConnector(APITestCase):
|
||||
json={"hosts": []},
|
||||
)
|
||||
controller.sync_endpoints()
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[0].headers["foo"], "bar")
|
||||
self.assertEqual(mock.request_history[1].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].headers["foo"], "bar")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].headers["foo"], "bar")
|
||||
|
||||
def test_map_host_linux(self):
|
||||
controller = self.connector.controller(self.connector)
|
||||
@@ -150,6 +128,6 @@ class TestFleetConnector(APITestCase):
|
||||
"arch": "arm64e",
|
||||
"family": OSFamily.macOS,
|
||||
"name": "macOS",
|
||||
"version": "26.3",
|
||||
"version": "26.0.1",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
from json import loads
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.core.tests.utils import (
|
||||
create_test_flow,
|
||||
)
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
|
||||
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
|
||||
|
||||
class FleetConnectorStageTests(FlowTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.connector = FleetConnector.objects.create(
|
||||
name=generate_id(), url="http://localhost", token=generate_id()
|
||||
)
|
||||
|
||||
controller = self.connector.controller(self.connector)
|
||||
with Mocker() as mock:
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
|
||||
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json={"hosts": [loads(load_fixture("fixtures/host_macos.json"))]},
|
||||
)
|
||||
mock.get(
|
||||
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=1&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
|
||||
json={"hosts": []},
|
||||
)
|
||||
controller.sync_endpoints()
|
||||
|
||||
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
self.stage = EndpointStage.objects.create(
|
||||
name=generate_id(),
|
||||
mode=StageMode.REQUIRED,
|
||||
connector=self.connector,
|
||||
)
|
||||
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
|
||||
|
||||
self.host_cert = load_fixture("fixtures/cond_acc_host.pem")
|
||||
|
||||
def _format_traefik(self, cert: str | None = None):
|
||||
cert = cert if cert else self.host_cert
|
||||
return cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", "")
|
||||
|
||||
def test_assoc(self):
|
||||
dev = Device.objects.get(identifier="ZV35VFDD50")
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
plan = plan()
|
||||
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], dev)
|
||||
self.assertEqual(
|
||||
plan.context[PLAN_CONTEXT_CERTIFICATE]["subject"],
|
||||
"CN=Fleet conditional access for Okta",
|
||||
)
|
||||
|
||||
def test_assoc_not_found(self):
|
||||
dev = Device.objects.get(identifier="ZV35VFDD50")
|
||||
dev.delete()
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
plan = plan()
|
||||
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
|
||||
@@ -4,13 +4,13 @@ from django.urls import reverse
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, SerializerMethodField
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.core.api.groups import PartialUserSerializer
|
||||
from authentik.core.api.object_attributes import ContentTypeSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
@@ -18,19 +18,6 @@ from authentik.enterprise.reports.tasks import generate_export
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
app_label = CharField(read_only=True)
|
||||
model = CharField(read_only=True)
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
|
||||
def get_verbose_name_plural(self, ct: ContentType) -> str:
|
||||
return ct.model_class()._meta.verbose_name_plural
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model", "verbose_name_plural")
|
||||
|
||||
|
||||
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
requested_by = PartialUserSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
@@ -15,7 +15,6 @@ from cryptography.x509 import (
|
||||
)
|
||||
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import User
|
||||
@@ -26,6 +25,7 @@ from authentik.enterprise.stages.mtls.models import (
|
||||
MutualTLSStage,
|
||||
UserAttributes,
|
||||
)
|
||||
from authentik.flows.challenge import AccessDeniedChallenge
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
@@ -217,7 +217,8 @@ class MTLSStageView(ChallengeStageView):
|
||||
return None
|
||||
return str(_cert_attr[0])
|
||||
|
||||
def get_cert(self, mode: StageMode):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: MutualTLSStage = self.executor.current_stage
|
||||
certs = [
|
||||
*self._parse_cert_xfcc(),
|
||||
*self._parse_cert_nginx(),
|
||||
@@ -227,26 +228,21 @@ class MTLSStageView(ChallengeStageView):
|
||||
authorities = self.get_authorities()
|
||||
if not authorities:
|
||||
self.logger.warning("No Certificate authority found")
|
||||
if mode == StageMode.OPTIONAL:
|
||||
return None
|
||||
if mode == StageMode.REQUIRED:
|
||||
raise PermissionDenied("Unknown error")
|
||||
if stage.mode == StageMode.OPTIONAL:
|
||||
return self.executor.stage_ok()
|
||||
if stage.mode == StageMode.REQUIRED:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
cert = self.validate_cert(authorities, certs)
|
||||
if not cert and mode == StageMode.REQUIRED:
|
||||
if not cert and stage.mode == StageMode.REQUIRED:
|
||||
self.logger.warning("Client certificate required but no certificates given")
|
||||
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
|
||||
if not cert and mode == StageMode.OPTIONAL:
|
||||
return super().dispatch(
|
||||
request,
|
||||
*args,
|
||||
error_message=_("Certificate required but no certificate was given."),
|
||||
**kwargs,
|
||||
)
|
||||
if not cert and stage.mode == StageMode.OPTIONAL:
|
||||
self.logger.info("No certificate given, continuing")
|
||||
return None
|
||||
return cert
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: MutualTLSStage = self.executor.current_stage
|
||||
try:
|
||||
cert = self.get_cert(stage.mode)
|
||||
except PermissionDenied as exc:
|
||||
return self.executor.stage_invalid(error_message=exc.detail)
|
||||
if not cert:
|
||||
return self.executor.stage_ok()
|
||||
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
|
||||
existing_user = self.check_if_user(cert)
|
||||
@@ -255,5 +251,15 @@ class MTLSStageView(ChallengeStageView):
|
||||
elif existing_user:
|
||||
self.auth_user(existing_user, cert)
|
||||
else:
|
||||
return self.executor.stage_invalid(_("No user found for certificate."))
|
||||
return super().dispatch(
|
||||
request, *args, error_message=_("No user found for certificate."), **kwargs
|
||||
)
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
|
||||
return AccessDeniedChallenge(
|
||||
data={
|
||||
"component": "ak-stage-access-denied",
|
||||
"error_message": str(error_message or "Unknown error"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,10 +11,6 @@ class FlowNonApplicableException(SentryIgnoredException):
|
||||
|
||||
policy_result: PolicyResult | None = None
|
||||
|
||||
def __init__(self, policy_result: PolicyResult | None = None, *args):
|
||||
super().__init__(*args)
|
||||
self.policy_result = policy_result
|
||||
|
||||
@property
|
||||
def messages(self) -> str:
|
||||
"""Get messages from policy result, fallback to generic reason"""
|
||||
|
||||
@@ -42,7 +42,6 @@ class Migration(migrations.Migration):
|
||||
("require_superuser", "Require Superuser"),
|
||||
("require_redirect", "Require Redirect"),
|
||||
("require_outpost", "Require Outpost"),
|
||||
("require_token", "Require Token"),
|
||||
],
|
||||
default="none",
|
||||
help_text="Required level of authentication and authorization to access a flow.",
|
||||
|
||||
@@ -40,7 +40,6 @@ class FlowAuthenticationRequirement(models.TextChoices):
|
||||
REQUIRE_SUPERUSER = "require_superuser"
|
||||
REQUIRE_REDIRECT = "require_redirect"
|
||||
REQUIRE_OUTPOST = "require_outpost"
|
||||
REQUIRE_TOKEN = "require_token"
|
||||
|
||||
|
||||
class NotConfiguredAction(models.TextChoices):
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from sentry_sdk import start_span
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -27,7 +26,6 @@ from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.types import PolicyResult
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -228,15 +226,6 @@ class FlowPlanner:
|
||||
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
|
||||
):
|
||||
raise FlowNonApplicableException()
|
||||
if (
|
||||
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_TOKEN
|
||||
and context.get(PLAN_CONTEXT_IS_RESTORED) is None
|
||||
):
|
||||
raise FlowNonApplicableException(
|
||||
PolicyResult(
|
||||
False, _("This link is invalid or has expired. Please request a new one.")
|
||||
)
|
||||
)
|
||||
outpost_user = ClientIPMiddleware.get_outpost_user(request)
|
||||
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
|
||||
if not outpost_user:
|
||||
@@ -284,7 +273,9 @@ class FlowPlanner:
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
raise FlowNonApplicableException(result)
|
||||
exc = FlowNonApplicableException()
|
||||
exc.policy_result = result
|
||||
raise exc
|
||||
# User is passing so far, check if we have a cached plan
|
||||
cached_plan_key = cache_key(self.flow, user)
|
||||
cached_plan = cache.get(cached_plan_key, None)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<html>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
<script>
|
||||
window.parent.postMessage({
|
||||
message: "submit",
|
||||
source: "goauthentik.io",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
<link rel="prefetch" href="{{ flow_background_url }}" />
|
||||
{% include "base/header_js.html" %}
|
||||
<style data-id="flow-sfe" nonce="{{ csp_nonce }}">
|
||||
<style data-id="flow-sfe">
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -43,13 +43,13 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
||||
</head>
|
||||
<body class="d-flex align-items-center py-4 bg-body-tertiary">
|
||||
<div class="card m-auto">
|
||||
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
|
||||
</main>
|
||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||
</div>
|
||||
<div class="card m-auto">
|
||||
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
|
||||
</main>
|
||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||
</div>
|
||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% comment %}
|
||||
@see {@link web/types/webcomponents.d.ts} for type definitions.
|
||||
{% endcomment %}
|
||||
<script data-id="shady-dom" nonce="{{ csp_nonce }}">
|
||||
<script data-id="shady-dom">
|
||||
"use strict";
|
||||
|
||||
window.ShadyDOM = window.ShadyDOM || {}
|
||||
@@ -20,7 +20,7 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
{% include "base/header_js.html" %}
|
||||
<script data-id="flow-config" nonce="{{ csp_nonce }}">
|
||||
<script data-id="flow-config">
|
||||
"use strict";
|
||||
|
||||
window.authentik.flow = {
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
|
||||
<style data-id="flow-css" nonce="{{ csp_nonce }}">
|
||||
<style data-id="flow-css">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ flow_background_url }}");
|
||||
}
|
||||
@@ -55,10 +55,10 @@
|
||||
loading
|
||||
>
|
||||
{% include "base/placeholder.html" %}
|
||||
|
||||
|
||||
<ak-brand-links name="flow-links" slot="footer"></ak-brand-links>
|
||||
</ak-flow-executor>
|
||||
|
||||
|
||||
<ak-flow-inspector
|
||||
slot="panel"
|
||||
id="flow-inspector"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""flow views tests"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -8,7 +7,6 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
@@ -19,7 +17,6 @@ from authentik.flows.models import (
|
||||
FlowDeniedAction,
|
||||
FlowDesignation,
|
||||
FlowStageBinding,
|
||||
FlowToken,
|
||||
InvalidResponseAction,
|
||||
)
|
||||
from authentik.flows.planner import FlowPlan, FlowPlanner
|
||||
@@ -27,7 +24,6 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import (
|
||||
NEXT_ARG_NAME,
|
||||
QS_KEY_TOKEN,
|
||||
QS_QUERY,
|
||||
SESSION_KEY_PLAN,
|
||||
FlowExecutorView,
|
||||
@@ -744,77 +740,3 @@ class TestFlowExecutor(FlowTestCase):
|
||||
"title": flow.title,
|
||||
},
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_expired_flow_token(self):
|
||||
"""Test that an expired flow token shows an appropriate error message"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.RECOVERY,
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
|
||||
)
|
||||
user = create_test_user()
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[], markers=[])
|
||||
|
||||
token = FlowToken.objects.create(
|
||||
user=user,
|
||||
identifier=generate_id(),
|
||||
flow=flow,
|
||||
_plan=FlowToken.pickle(plan),
|
||||
expires=now() - timedelta(hours=1),
|
||||
)
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
response = self.client.get(
|
||||
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: token.key})})}"
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="This link is invalid or has expired. Please request a new one.",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_flow_token_require_token(self):
|
||||
"""Test that an invalid/nonexistent token on a REQUIRE_TOKEN flow shows error"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.RECOVERY,
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
|
||||
)
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
response = self.client.get(
|
||||
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: 'invalid-token'})})}"
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="This link is invalid or has expired. Please request a new one.",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_no_token_require_token(self):
|
||||
"""Test that accessing a REQUIRE_TOKEN flow without any token shows error"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.RECOVERY,
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
|
||||
)
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
response = self.client.get(url)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="This link is invalid or has expired. Please request a new one.",
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ from authentik.flows.models import (
|
||||
)
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_IS_REDIRECTED,
|
||||
PLAN_CONTEXT_IS_RESTORED,
|
||||
PLAN_CONTEXT_PENDING_USER,
|
||||
FlowPlanner,
|
||||
cache_key,
|
||||
@@ -130,22 +129,6 @@ class TestFlowPlanner(TestCase):
|
||||
planner.allow_empty_flows = True
|
||||
planner.plan(request)
|
||||
|
||||
def test_authentication_require_token(self):
|
||||
"""Test flow authentication (require_token)"""
|
||||
flow = create_test_flow()
|
||||
flow.authentication = FlowAuthenticationRequirement.REQUIRE_TOKEN
|
||||
request = self.request_factory.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
|
||||
with self.assertRaises(FlowNonApplicableException):
|
||||
planner.plan(request)
|
||||
|
||||
context = {PLAN_CONTEXT_IS_RESTORED: True}
|
||||
planner.plan(request, context)
|
||||
|
||||
@patch(
|
||||
"authentik.policies.engine.PolicyEngine.result",
|
||||
POLICY_RETURN_FALSE,
|
||||
|
||||
@@ -62,7 +62,6 @@ from authentik.policies.engine import PolicyEngine
|
||||
LOGGER = get_logger()
|
||||
# Argument used to redirect user after login
|
||||
NEXT_ARG_NAME = "next"
|
||||
|
||||
SESSION_KEY_PLAN = "authentik/flows/plan"
|
||||
SESSION_KEY_GET = "authentik/flows/get"
|
||||
SESSION_KEY_POST = "authentik/flows/post"
|
||||
|
||||
@@ -71,11 +71,7 @@ class FlowInspectorView(APIView):
|
||||
|
||||
flow: Flow
|
||||
_logger: BoundLogger
|
||||
|
||||
def get_permissions(self):
|
||||
if settings.DEBUG:
|
||||
return []
|
||||
return [IsAuthenticated()]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def setup(self, request: HttpRequest, flow_slug: str):
|
||||
super().setup(request, flow_slug=flow_slug)
|
||||
|
||||
@@ -165,8 +165,6 @@ web:
|
||||
timeout_http_read: 30s
|
||||
timeout_http_write: 60s
|
||||
timeout_http_idle: 120s
|
||||
csp:
|
||||
report_only: false
|
||||
|
||||
worker:
|
||||
processes: 1
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cachetools import TLRUCache, cached
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
@@ -22,6 +23,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.utils.dict import get_path_from_dict
|
||||
from authentik.lib.utils.email import normalize_addresses
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
@@ -70,6 +72,7 @@ class BaseEvaluator:
|
||||
"ak_send_email": self.expr_send_email,
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
|
||||
"ak_obj_attr": BaseEvaluator.expr_obj_attr,
|
||||
"ip_address": ip_address,
|
||||
"ip_network": ip_network,
|
||||
"list_flatten": BaseEvaluator.expr_flatten,
|
||||
@@ -162,6 +165,16 @@ class BaseEvaluator:
|
||||
return False
|
||||
return len(list(user_devices)) > 0
|
||||
|
||||
@staticmethod
|
||||
def expr_obj_attr(obj: Model, attr_key: str, fallback: str) -> Any:
|
||||
"""Get an attribute of the given object if set by its dotted path, otherwise
|
||||
return fallback value."""
|
||||
attrs = getattr(obj, "attributes", {})
|
||||
value = get_path_from_dict(attrs, attr_key)
|
||||
if value is None and fallback:
|
||||
return getattr(obj, fallback)
|
||||
return value
|
||||
|
||||
def expr_event_create(self, action: str, **kwargs):
|
||||
"""Create event with supplied data and try to extract as much relevant data
|
||||
from the context"""
|
||||
|
||||
@@ -14,16 +14,7 @@ def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -
|
||||
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
|
||||
qs = qs.order_by("pk")
|
||||
pks = qs.values_list("pk", flat=True)
|
||||
# The outer queryset.exists() guard can race with a concurrent
|
||||
# transaction that deletes the last matching row (or with a
|
||||
# different isolation-level snapshot), so by the time this
|
||||
# generator starts iterating the queryset may be empty and
|
||||
# pks[0] would raise IndexError and crash the caller. Using
|
||||
# .first() returns None on an empty queryset, which we bail
|
||||
# out on cleanly. See goauthentik/authentik#21643.
|
||||
start_pk = pks.first()
|
||||
if start_pk is None:
|
||||
return
|
||||
start_pk = pks[0]
|
||||
while True:
|
||||
try:
|
||||
end_pk = pks.filter(pk__gte=start_pk)[chunk_size]
|
||||
|
||||
@@ -65,7 +65,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"grant_types",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"access_code_validity",
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.oauth2.models import GrantType, RedirectURI
|
||||
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
|
||||
|
||||
|
||||
class OAuth2Error(SentryIgnoredException):
|
||||
@@ -182,7 +182,7 @@ class AuthorizeError(OAuth2Error):
|
||||
# See:
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
|
||||
fragment_or_query = (
|
||||
"#" if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID] else "?"
|
||||
"#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
|
||||
)
|
||||
|
||||
uri = (
|
||||
@@ -225,7 +225,7 @@ class TokenError(OAuth2Error):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, error: str):
|
||||
def __init__(self, error):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self.errors[error]
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-17 11:04
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_default_grant_types():
|
||||
from authentik.providers.oauth2.models import GrantType
|
||||
|
||||
return [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.HYBRID,
|
||||
GrantType.IMPLICIT,
|
||||
GrantType.CLIENT_CREDENTIALS,
|
||||
GrantType.PASSWORD,
|
||||
GrantType.DEVICE_CODE,
|
||||
GrantType.REFRESH_TOKEN,
|
||||
]
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_providers_oauth2",
|
||||
"0031_remove_oauth2provider_backchannel_logout_uri_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="grant_types",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("authorization_code", "Authorization Code"),
|
||||
("implicit", "Implicit"),
|
||||
("hybrid", "Hybrid"),
|
||||
("refresh_token", "Refresh Token"),
|
||||
("client_credentials", "Client Credentials"),
|
||||
("password", "Password"),
|
||||
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
|
||||
]
|
||||
),
|
||||
default=migrate_default_grant_types,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="grant_types",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("authorization_code", "Authorization Code"),
|
||||
("implicit", "Implicit"),
|
||||
("hybrid", "Hybrid"),
|
||||
("refresh_token", "Refresh Token"),
|
||||
("client_credentials", "Client Credentials"),
|
||||
("password", "Password"),
|
||||
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
|
||||
]
|
||||
),
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -19,7 +19,6 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||
from dacite import Config
|
||||
from dacite.core import from_dict
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import HashIndex
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -34,16 +33,7 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import WebfingerProvider
|
||||
from authentik.common.oauth.constants import (
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
GRANT_TYPE_HYBRID,
|
||||
GRANT_TYPE_IMPLICIT,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
SubModes,
|
||||
)
|
||||
from authentik.common.oauth.constants import SubModes
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
@@ -68,7 +58,7 @@ def generate_client_secret() -> str:
|
||||
return generate_id(128)
|
||||
|
||||
|
||||
class ClientType(models.TextChoices):
|
||||
class ClientTypes(models.TextChoices):
|
||||
"""Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable."""
|
||||
|
||||
@@ -76,16 +66,12 @@ class ClientType(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
|
||||
|
||||
class GrantType(models.TextChoices):
|
||||
class GrantTypes(models.TextChoices):
|
||||
"""OAuth2 Grant types we support"""
|
||||
|
||||
AUTHORIZATION_CODE = GRANT_TYPE_AUTHORIZATION_CODE
|
||||
IMPLICIT = GRANT_TYPE_IMPLICIT
|
||||
HYBRID = GRANT_TYPE_HYBRID
|
||||
REFRESH_TOKEN = GRANT_TYPE_REFRESH_TOKEN
|
||||
CLIENT_CREDENTIALS = GRANT_TYPE_CLIENT_CREDENTIALS
|
||||
PASSWORD = GRANT_TYPE_PASSWORD
|
||||
DEVICE_CODE = GRANT_TYPE_DEVICE_CODE
|
||||
AUTHORIZATION_CODE = "authorization_code"
|
||||
IMPLICIT = "implicit"
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class ResponseMode(models.TextChoices):
|
||||
@@ -202,15 +188,14 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
|
||||
client_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=ClientType.choices,
|
||||
default=ClientType.CONFIDENTIAL,
|
||||
choices=ClientTypes.choices,
|
||||
default=ClientTypes.CONFIDENTIAL,
|
||||
verbose_name=_("Client Type"),
|
||||
help_text=_(
|
||||
"Confidential clients are capable of maintaining the confidentiality "
|
||||
"of their credentials. Public clients are incapable"
|
||||
),
|
||||
)
|
||||
grant_types = ArrayField(models.TextField(choices=GrantType.choices), default=list)
|
||||
client_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
|
||||
@@ -22,7 +22,7 @@ from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, Red
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -41,34 +41,12 @@ class TestAuthorize(OAuthTestCase):
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_disallowed_grant_type(self):
|
||||
"""Test with disallowed grant type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
grant_types=[],
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
self.assertEqual(cm.exception.error, "invalid_request")
|
||||
|
||||
def test_invalid_grant_type(self):
|
||||
"""Test with invalid grant type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
@@ -96,7 +74,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
@@ -164,6 +141,26 @@ class TestAuthorize(OAuthTestCase):
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
self.assertEqual(cm.exception.cause, "redirect_uri_forbidden_scheme")
|
||||
|
||||
def test_invalid_redirect_uri_empty(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[],
|
||||
)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "+",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
provider.refresh_from_db()
|
||||
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
@@ -211,7 +208,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, ".+")],
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
@@ -230,7 +226,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@@ -252,14 +247,12 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).redirect_uri,
|
||||
"http://local.invalid/Foo",
|
||||
)
|
||||
provider.grant_types = [GrantType.IMPLICIT]
|
||||
provider.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
@@ -273,7 +266,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantType.IMPLICIT,
|
||||
GrantTypes.IMPLICIT,
|
||||
)
|
||||
# Implicit without openid scope
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
@@ -288,10 +281,8 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantType.IMPLICIT,
|
||||
GrantTypes.IMPLICIT,
|
||||
)
|
||||
provider.grant_types = [GrantType.HYBRID]
|
||||
provider.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
@@ -303,7 +294,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantType.HYBRID
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantTypes.HYBRID
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
@@ -326,7 +317,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -363,7 +353,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -435,7 +424,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -498,7 +486,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -548,7 +535,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -606,7 +592,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
state = generate_id()
|
||||
@@ -647,7 +632,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
)
|
||||
request = self.factory.get(
|
||||
@@ -671,7 +655,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -704,7 +687,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -735,7 +717,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -775,7 +756,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authentication_flow=auth_flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -802,7 +782,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
|
||||
@@ -6,11 +6,10 @@ from urllib.parse import quote
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@@ -22,7 +21,6 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
@@ -43,21 +41,6 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_backchannel_invalid_no_grant(self):
|
||||
"""Test backchannel"""
|
||||
self.provider.grant_types = []
|
||||
self.provider.save()
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
data={
|
||||
"client_id": "test",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_backchannel_invalid_no_app(self):
|
||||
"""Test backchannel"""
|
||||
# test without application
|
||||
self.application.provider = None
|
||||
self.application.save()
|
||||
@@ -127,57 +110,3 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["expires_in"], 60)
|
||||
|
||||
@apply_blueprint("system/providers-oauth2.yaml")
|
||||
def test_backchannel_scopes(self):
|
||||
"""Test backchannel"""
|
||||
self.provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
managed__in=[
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
]
|
||||
)
|
||||
)
|
||||
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
HTTP_AUTHORIZATION=f"Basic {creds}",
|
||||
data={"scope": "openid email"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["expires_in"], 60)
|
||||
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(len(token.scope), 2)
|
||||
self.assertIn("openid", token.scope)
|
||||
self.assertIn("email", token.scope)
|
||||
|
||||
@apply_blueprint("system/providers-oauth2.yaml")
|
||||
def test_backchannel_scopes_extra(self):
|
||||
"""Test backchannel"""
|
||||
self.provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
managed__in=[
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
]
|
||||
)
|
||||
)
|
||||
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
HTTP_AUTHORIZATION=f"Basic {creds}",
|
||||
data={"scope": "openid email foo"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["expires_in"], 60)
|
||||
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(len(token.scope), 2)
|
||||
self.assertIn("openid", token.scope)
|
||||
self.assertIn("email", token.scope)
|
||||
|
||||
@@ -9,7 +9,7 @@ from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
@@ -22,7 +22,6 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
|
||||
@@ -14,7 +14,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
ClientType,
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -173,7 +173,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
|
||||
def test_introspect_provider_public(self):
|
||||
"""Test introspect"""
|
||||
self.provider.client_type = ClientType.PUBLIC
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.save()
|
||||
token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
@@ -208,7 +208,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
client_type=ClientType.PUBLIC,
|
||||
client_type=ClientTypes.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
ClientType,
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
@@ -126,7 +126,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
|
||||
def test_revoke_public(self):
|
||||
"""Test revoke public client"""
|
||||
self.provider.client_type = ClientType.PUBLIC
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.save()
|
||||
token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
@@ -241,7 +241,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
client_type=ClientType.PUBLIC,
|
||||
client_type=ClientTypes.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
@@ -270,14 +270,14 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
def test_revoke_provider_fed_public(self):
|
||||
"""Test revoke with federation. self.provider is a public
|
||||
client and other_provider is a public client."""
|
||||
self.provider.client_type = ClientType.PUBLIC
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.save()
|
||||
other_provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
client_type=ClientType.PUBLIC,
|
||||
client_type=ClientTypes.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -45,39 +44,11 @@ class TestToken(OAuthTestCase):
|
||||
self.factory = RequestFactory()
|
||||
self.app = Application.objects.create(name=generate_id(), slug="test")
|
||||
|
||||
def test_invalid_grant_type(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
"code": code.code,
|
||||
"redirect_uri": "http://TestServer",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
TokenParams.parse(request, provider, provider.client_id, provider.client_secret)
|
||||
self.assertEqual(cm.exception.cause, "grant_type_not_configured")
|
||||
|
||||
def test_request_auth_code(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -105,7 +76,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -127,7 +97,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -170,7 +139,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -211,7 +179,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
@@ -243,7 +210,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -305,7 +271,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -363,7 +328,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -436,7 +400,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
refresh_token_threshold="hours=1", # nosec
|
||||
@@ -534,7 +497,6 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
include_claims_in_id_token=True,
|
||||
|
||||
@@ -22,7 +22,6 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -56,7 +55,6 @@ class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS],
|
||||
)
|
||||
self.provider.jwt_federation_providers.add(self.other_provider)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
||||
@@ -20,7 +20,6 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -69,7 +68,6 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS],
|
||||
)
|
||||
self.provider.jwt_federation_sources.add(self.source)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
||||
@@ -21,7 +21,6 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -42,7 +41,6 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -22,7 +22,6 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -43,7 +42,6 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -25,7 +25,6 @@ from authentik.core.tests.utils import (
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -46,7 +45,6 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -17,7 +17,6 @@ from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
DeviceToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -38,7 +37,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
@@ -50,7 +48,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
},
|
||||
)
|
||||
@@ -69,7 +66,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -78,26 +74,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["error"], "authorization_pending")
|
||||
|
||||
def test_code_no_auth(self):
|
||||
"""Test code with user"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
provider=self.provider,
|
||||
user_code=generate_code_fixed_length(),
|
||||
device_code=generate_id(),
|
||||
user=self.user,
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
body = loads(res.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_client")
|
||||
|
||||
def test_code(self):
|
||||
"""Test code with user"""
|
||||
device_token = DeviceToken.objects.create(
|
||||
@@ -110,7 +86,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
},
|
||||
@@ -130,7 +105,6 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"grant_type": GRANT_TYPE_DEVICE_CODE,
|
||||
"device_code": device_token.device_code,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
|
||||
|
||||
@@ -11,7 +11,6 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -38,7 +37,6 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -97,7 +95,6 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -154,7 +151,6 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -200,7 +196,6 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
|
||||
@@ -57,9 +57,11 @@ from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RedirectURIType,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
@@ -164,31 +166,28 @@ class OAuthAuthorizationParams:
|
||||
"""Check grant"""
|
||||
# Determine which flow to use.
|
||||
if self.response_type in [ResponseTypes.CODE]:
|
||||
self.grant_type = GrantType.AUTHORIZATION_CODE
|
||||
self.grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif self.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantType.IMPLICIT
|
||||
self.grant_type = GrantTypes.IMPLICIT
|
||||
elif self.response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantType.HYBRID
|
||||
self.grant_type = GrantTypes.HYBRID
|
||||
|
||||
# Grant type validation.
|
||||
if not self.grant_type:
|
||||
LOGGER.warning("Invalid response type", type=self.response_type)
|
||||
raise AuthorizeError(self.redirect_uri, "unsupported_response_type", "", self.state)
|
||||
|
||||
if self.grant_type not in self.provider.grant_types:
|
||||
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
|
||||
|
||||
if self.response_mode not in ResponseMode.values:
|
||||
self.response_mode = ResponseMode.QUERY
|
||||
|
||||
if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID]:
|
||||
if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
self.response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
def check_redirect_uri(self):
|
||||
@@ -198,6 +197,18 @@ class OAuthAuthorizationParams:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", allowed_redirect_urls).with_cause("redirect_uri_missing")
|
||||
|
||||
if len(allowed_redirect_urls) < 1:
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT,
|
||||
self.redirect_uri,
|
||||
RedirectURIType.AUTHORIZATION,
|
||||
)
|
||||
]
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.authorization_redirect_uris
|
||||
|
||||
match_found = False
|
||||
for allowed in allowed_redirect_urls:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
@@ -249,7 +260,7 @@ class OAuthAuthorizationParams:
|
||||
)
|
||||
self.scope = self.scope.intersection(default_scope_names)
|
||||
if SCOPE_OPENID not in self.scope and (
|
||||
self.grant_type == GrantType.HYBRID
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
@@ -600,8 +611,8 @@ class OAuthFulfillmentStage(StageView):
|
||||
code = None
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.HYBRID,
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save()
|
||||
@@ -616,7 +627,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = {}
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
query_fragment["code"] = code.code
|
||||
query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -630,7 +641,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
post_params = {}
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
post_params["code"] = code.code
|
||||
post_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -699,7 +710,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantType.HYBRID:
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = TOKEN_TYPE
|
||||
|
||||
@@ -15,7 +15,7 @@ from authentik.core.models import Application
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.errors import DeviceCodeError
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
@@ -28,7 +28,7 @@ class DeviceView(View):
|
||||
|
||||
client_id: str
|
||||
provider: OAuth2Provider
|
||||
scopes: set[str] = []
|
||||
scopes: list[str] = []
|
||||
|
||||
def parse_request(self):
|
||||
"""Parse incoming request"""
|
||||
@@ -42,25 +42,9 @@ class DeviceView(View):
|
||||
_ = provider.application
|
||||
except Application.DoesNotExist:
|
||||
raise DeviceCodeError("invalid_client") from None
|
||||
if GrantType.DEVICE_CODE not in provider.grant_types:
|
||||
raise DeviceCodeError("invalid_client")
|
||||
self.provider = provider
|
||||
self.client_id = client_id
|
||||
|
||||
scopes_to_check = set(self.request.POST.get("scope", "").split())
|
||||
default_scope_names = set(
|
||||
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
)
|
||||
self.scopes = scopes_to_check
|
||||
if not scopes_to_check.issubset(default_scope_names):
|
||||
LOGGER.info(
|
||||
"Application requested scopes not configured, setting to overlap",
|
||||
scope_allowed=default_scope_names,
|
||||
scope_given=self.scopes,
|
||||
)
|
||||
self.scopes = self.scopes.intersection(default_scope_names)
|
||||
self.scopes = self.request.POST.get("scope", "").split(" ")
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
throttle = AnonRateThrottle()
|
||||
|
||||
@@ -11,7 +11,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenIntrospectionError
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -45,7 +45,7 @@ class TokenIntrospectionParams:
|
||||
if not provider:
|
||||
LOGGER.info("Failed to authenticate introspection request")
|
||||
raise TokenIntrospectionError
|
||||
if provider.client_type != ClientType.CONFIDENTIAL:
|
||||
if provider.client_type != ClientTypes.CONFIDENTIAL:
|
||||
LOGGER.info("Introspection request from public provider, denying.")
|
||||
raise TokenIntrospectionError
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
ClientType,
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -165,20 +165,8 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type not in self.provider.grant_types:
|
||||
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
|
||||
raise TokenError("invalid_grant").with_cause("grant_type_not_configured")
|
||||
|
||||
# Confidential clients MUST authenticate to the token endpoint per
|
||||
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
|
||||
# that requirement - the device_code alone is not a substitute for
|
||||
# client credentials.
|
||||
if self.grant_type in [
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
]:
|
||||
if self.provider.client_type == ClientType.CONFIDENTIAL and not compare_digest(
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
LOGGER.warning(
|
||||
@@ -610,10 +598,10 @@ class TokenView(View):
|
||||
if not self.provider:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
self.params = self.params_class.parse(
|
||||
request, self.provider, client_id, client_secret
|
||||
)
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.post.response",
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenRevocationError
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import (
|
||||
TokenResponse,
|
||||
authenticate_provider,
|
||||
@@ -33,13 +33,11 @@ class TokenRevocationParams:
|
||||
raw_token = request.POST.get("token")
|
||||
|
||||
provider, _, _ = provider_from_request(request)
|
||||
if provider and provider.client_type == ClientType.CONFIDENTIAL:
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
# By default clients can only revoke their own tokens
|
||||
query = Q(provider=provider, token=raw_token)
|
||||
if provider.client_type == ClientType.CONFIDENTIAL:
|
||||
if provider.client_type == ClientTypes.CONFIDENTIAL:
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
|
||||
@@ -16,8 +16,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientType,
|
||||
GrantType,
|
||||
ClientTypes,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -162,12 +161,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.grant_types = [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.CLIENT_CREDENTIALS,
|
||||
GrantType.PASSWORD,
|
||||
]
|
||||
self.client_type = ClientType.CONFIDENTIAL
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.signing_key = None
|
||||
self.include_claims_in_id_token = True
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user