mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
151 Commits
feature/ag
...
web/csp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e7fa6bc0 | ||
|
|
4b4968c66b | ||
|
|
4e5b938ebe | ||
|
|
2a027264b3 | ||
|
|
fe4a7d2c5f | ||
|
|
71af5e40a3 | ||
|
|
3e75278052 | ||
|
|
620387f294 | ||
|
|
9dfc4e76ee | ||
|
|
85f0ab899e | ||
|
|
72c76bb95b | ||
|
|
9ea465441b | ||
|
|
3c9d682eb3 | ||
|
|
32de314485 | ||
|
|
3a9211f248 | ||
|
|
26cfdf59c9 | ||
|
|
b6f9013977 | ||
|
|
3133f8cbda | ||
|
|
b66024f26f | ||
|
|
8f1bdc01b6 | ||
|
|
5c3cd2c6ed | ||
|
|
97c9626bd4 | ||
|
|
3d7ff2cfef | ||
|
|
311954f920 | ||
|
|
ea4848c7c6 | ||
|
|
2fd9a09055 | ||
|
|
b07b71f528 | ||
|
|
c058363180 | ||
|
|
b5a92b783f | ||
|
|
a4c60ece8b | ||
|
|
d1d38edb50 | ||
|
|
c6ee7b6881 | ||
|
|
0459568a96 | ||
|
|
aa746e7585 | ||
|
|
a4dcf097b3 | ||
|
|
c2ecff559c | ||
|
|
c20ecb48f8 | ||
|
|
34a50ad46e | ||
|
|
99410f3775 | ||
|
|
86de4955aa | ||
|
|
bea9b23555 | ||
|
|
9820ee1d67 | ||
|
|
1379637389 | ||
|
|
39e6c41566 | ||
|
|
92a2d26c86 | ||
|
|
0f8d8c81d7 | ||
|
|
cce646b132 | ||
|
|
6d274d1e3d | ||
|
|
8d5489e441 | ||
|
|
8ea9a48017 | ||
|
|
c6b5869b48 | ||
|
|
e4971f9aa5 | ||
|
|
028ec05a8b | ||
|
|
b4c9ac57e0 | ||
|
|
80b93e1fbc | ||
|
|
dff6b48f53 | ||
|
|
79473341d6 | ||
|
|
99f9682d61 | ||
|
|
987f367d7b | ||
|
|
805ff9f1ab | ||
|
|
42fc9d537e | ||
|
|
3f4c0fb35d | ||
|
|
42d87072cf | ||
|
|
075a1f5875 | ||
|
|
24edee3e78 | ||
|
|
9d55b9a9b0 | ||
|
|
349be68d52 | ||
|
|
7dfb8d6129 | ||
|
|
7f7965e42c | ||
|
|
2e2b471b94 | ||
|
|
4d53cd0790 | ||
|
|
7b913eaaa9 | ||
|
|
880c1ec89a | ||
|
|
d7724a52f2 | ||
|
|
508b45b6e3 | ||
|
|
2d52756761 | ||
|
|
6e84b74797 | ||
|
|
aff93d35ef | ||
|
|
d995613212 | ||
|
|
194f04bb6f | ||
|
|
ba14cac535 | ||
|
|
953c70f5fc | ||
|
|
4c775b2258 | ||
|
|
2c851f7cd0 | ||
|
|
520f81966c | ||
|
|
7f27ee3267 | ||
|
|
6d57854bff | ||
|
|
f7871d726e | ||
|
|
189056e19a | ||
|
|
24362625a9 | ||
|
|
5266166d64 | ||
|
|
44d13e3ea5 | ||
|
|
c7e8037ef7 | ||
|
|
a10769e60e | ||
|
|
1a1f752f28 | ||
|
|
081fe60ad7 | ||
|
|
8be14a6de4 | ||
|
|
57c97d5318 | ||
|
|
d44cd63a52 | ||
|
|
33e4f8beb2 | ||
|
|
1b6da073c8 | ||
|
|
c481a5c2f0 | ||
|
|
c300a5338e | ||
|
|
742bbcc51f | ||
|
|
018c81178f | ||
|
|
8bd601f91c | ||
|
|
6f1db505b5 | ||
|
|
1e6d8aa5c4 | ||
|
|
4893d3ef61 | ||
|
|
e99200c1a9 | ||
|
|
132417c3f0 | ||
|
|
9f1318f583 | ||
|
|
d7ca75024a | ||
|
|
addbf5a2f6 | ||
|
|
3dd05a4407 | ||
|
|
058af4504f | ||
|
|
5e88751516 | ||
|
|
87639eced4 | ||
|
|
546f204c15 | ||
|
|
0c6c5661a9 | ||
|
|
fc9211220a | ||
|
|
7ad5c87e84 | ||
|
|
05c2ec315a | ||
|
|
16731116ab | ||
|
|
8147b605e7 | ||
|
|
915b5a73fc | ||
|
|
08832b8520 | ||
|
|
d158fdb792 | ||
|
|
f86ca53309 | ||
|
|
19e406700e | ||
|
|
c74350145f | ||
|
|
514ff57953 | ||
|
|
680220f977 | ||
|
|
27a3dc93e3 | ||
|
|
bd6102b59b | ||
|
|
b41cb4817a | ||
|
|
81bfcbb4e8 | ||
|
|
909d1335ad | ||
|
|
8df67091d9 | ||
|
|
e4c7a8aded | ||
|
|
75d14586d2 | ||
|
|
6ed5cb5249 | ||
|
|
b6496950bf | ||
|
|
05bb1d1fdd | ||
|
|
d51296cbb9 | ||
|
|
abb65d2682 | ||
|
|
4667deaefc | ||
|
|
1b53426e2c | ||
|
|
b3e7a01f10 | ||
|
|
404570a4d2 | ||
|
|
d2cbd82d7a |
@@ -1,5 +1,5 @@
|
||||
[alias]
|
||||
t = ["nextest", "run"]
|
||||
t = ["nextest", "run", "--workspace"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[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@cec208311dfd045dd5311c1add060b2062131d57 # v5
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # 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@5939f3337e40968c39aa70f5ecb1417a92fb25a0 # v2
|
||||
uses: taiki-e/install-action@787505cde8a44ea468a00478fe52baf23b15bccd # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v4
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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/data
|
||||
- db-data:/var/lib/postgresql
|
||||
command: "-c log_statement=all"
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -66,6 +66,14 @@ 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@44422a4b046d55dc036df622039ed3aec43c613c # v2
|
||||
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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,7 +127,10 @@ jobs:
|
||||
with:
|
||||
postgresql_version: ${{ matrix.psql }}
|
||||
- name: run migrations to stable
|
||||
run: uv run python -m lifecycle.migrate
|
||||
run: |
|
||||
docker ps
|
||||
docker logs setup-postgresql-1
|
||||
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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 # main
|
||||
uses: calibreapp/image-actions@e2cc8db5d49c849e00844dfebf01438318e96fa2 # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@22103cc46bda19c2b464ffe86db46df6922fd323 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@53b83947a5a98c8d113130e565377fae1a50d02f # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
|
||||
373
Cargo.lock
generated
373
Cargo.lock
generated
@@ -17,6 +17,18 @@ 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"
|
||||
@@ -106,6 +118,37 @@ 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"
|
||||
@@ -138,6 +181,29 @@ 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"
|
||||
@@ -150,6 +216,7 @@ dependencies = [
|
||||
"eyre",
|
||||
"forwarded-header-value",
|
||||
"futures",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
@@ -178,6 +245,7 @@ name = "authentik-common"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"authentik-client",
|
||||
"aws-lc-rs",
|
||||
"axum-server",
|
||||
"config",
|
||||
@@ -189,6 +257,8 @@ dependencies = [
|
||||
"nix 0.31.2",
|
||||
"notify",
|
||||
"pin-project-lite",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"rustls",
|
||||
"sentry",
|
||||
"serde",
|
||||
@@ -198,6 +268,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-retry2",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-error",
|
||||
@@ -227,9 +298,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-fips-sys",
|
||||
"aws-lc-sys",
|
||||
@@ -239,9 +310,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -251,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
@@ -307,9 +378,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -506,9 +577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -528,9 +599,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -563,6 +634,33 @@ 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"
|
||||
@@ -724,6 +822,15 @@ 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"
|
||||
@@ -973,6 +1080,12 @@ 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"
|
||||
@@ -1205,7 +1318,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1213,6 +1326,9 @@ 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"
|
||||
@@ -1339,9 +1455,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1354,7 +1470,6 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -1389,6 +1504,20 @@ 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"
|
||||
@@ -1846,6 +1975,46 @@ 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"
|
||||
@@ -1977,7 +2146,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2229,6 +2398,12 @@ 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"
|
||||
@@ -2305,12 +2480,6 @@ 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"
|
||||
@@ -2344,6 +2513,12 @@ 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"
|
||||
@@ -2419,6 +2594,79 @@ 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"
|
||||
@@ -2498,9 +2746,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -2555,6 +2803,24 @@ 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"
|
||||
@@ -2733,9 +2999,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -2798,9 +3064,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -3147,6 +3413,12 @@ 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"
|
||||
@@ -3311,7 +3583,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -3352,7 +3624,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3472,6 +3744,12 @@ 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"
|
||||
@@ -3594,9 +3872,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.1"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3621,6 +3899,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-retry2"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0266d56e57e6b29becbfce5daa6add8730941ca8192ddd7c24d25bf90c32a743"
|
||||
dependencies = [
|
||||
"pin-project",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3644,9 +3932,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -3855,9 +4143,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
@@ -3867,7 +4155,6 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3977,12 +4264,6 @@ 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"
|
||||
@@ -4003,9 +4284,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
|
||||
82
Cargo.toml
82
Cargo.toml
@@ -20,11 +20,13 @@ 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.2", features = ["fips"] }
|
||||
axum = { version = "= 0.8.8", features = ["http2", "macros", "ws"] }
|
||||
clap = { version = "= 4.6.0", features = ["derive", "env"] }
|
||||
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"] }
|
||||
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",
|
||||
@@ -37,11 +39,16 @@ 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"
|
||||
nix = { version = "= 0.31.2", features = ["signal"] }
|
||||
metrics = "= 0.24.3"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
|
||||
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.2", features = [
|
||||
"form",
|
||||
@@ -58,7 +65,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.37", features = ["fips"] }
|
||||
rustls = { version = "= 0.23.39", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
@@ -89,7 +96,8 @@ 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.51.1", features = ["full", "tracing"] }
|
||||
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
tower = "= 0.5.3"
|
||||
@@ -103,17 +111,12 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
"tracing-log",
|
||||
] }
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.0", features = ["serde", "v4"] }
|
||||
uuid = { version = "= 1.23.1", 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"
|
||||
@@ -227,3 +230,54 @@ 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,6 +115,9 @@ 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,7 +1,7 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
@@ -164,16 +164,19 @@ 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}",
|
||||
}
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
HttpMethod="GET",
|
||||
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},
|
||||
)
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
@@ -183,9 +186,8 @@ 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 = parsed.path
|
||||
path = request_dict["url_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
|
||||
@@ -200,9 +202,22 @@ class S3Backend(ManageableBackend):
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
|
||||
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
|
||||
custom_base = urlsplit(f"{scheme}://{custom_domain}")
|
||||
|
||||
return url
|
||||
# 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,
|
||||
)
|
||||
|
||||
if use_cache:
|
||||
return self._cache_get_or_set(name, request, _file_url, expires_in)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from unittest import skipUnless
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from botocore.exceptions import UnsupportedSignatureVersionError
|
||||
from django.test import TestCase
|
||||
@@ -168,6 +169,44 @@ 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,5 +1,6 @@
|
||||
"""Apply blueprint from commandline"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from sys import exit as sys_exit
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
@@ -31,5 +32,5 @@ class Command(BaseCommand):
|
||||
sys_exit(1)
|
||||
importer.apply()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("blueprints", nargs="+", type=str)
|
||||
|
||||
@@ -66,4 +66,5 @@ 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,6 +5,7 @@ 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
|
||||
|
||||
@@ -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, QuerySet
|
||||
from django.db.models import Case, Q, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -36,9 +36,13 @@ from authentik.rbac.filters import ObjectFilter
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
||||
def user_app_cache_key(
|
||||
user_pk: str, page_number: int | None = None, only_with_launch_url: bool = False
|
||||
) -> 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
|
||||
@@ -274,11 +278,19 @@ 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()
|
||||
only_with_launch_url = (
|
||||
str(request.query_params.get("only_with_launch_url", "false")).lower()
|
||||
) == "true"
|
||||
|
||||
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)
|
||||
|
||||
@@ -295,7 +307,6 @@ 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)
|
||||
@@ -305,19 +316,26 @@ 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)
|
||||
user_app_cache_key(
|
||||
self.request.user.pk, paginator.page.number, only_with_launch_url
|
||||
)
|
||||
)
|
||||
if not allowed_applications:
|
||||
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:
|
||||
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),
|
||||
user_app_cache_key(
|
||||
self.request.user.pk, paginator.page.number, only_with_launch_url
|
||||
),
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
if only_with_launch_url:
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
|
||||
@@ -7,6 +7,12 @@ 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
|
||||
@@ -26,6 +32,10 @@ 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"""
|
||||
|
||||
61
authentik/core/migrations/0058_setup.py
Normal file
61
authentik/core/migrations/0058_setup.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# 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)]
|
||||
@@ -790,9 +790,13 @@ 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
|
||||
return get_deepest_child(self.provider)
|
||||
self._cached_provider = get_deepest_child(self.provider)
|
||||
return self._cached_provider
|
||||
|
||||
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
|
||||
"""Get Backchannel provider for a specific type"""
|
||||
|
||||
38
authentik/core/setup/signals.py
Normal file
38
authentik/core/setup/signals.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
80
authentik/core/setup/views.py
Normal file
80
authentik/core/setup/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
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">
|
||||
<script data-id="authentik-config" nonce="{{ csp_nonce }}">
|
||||
"use strict";
|
||||
|
||||
window.authentik = {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<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 }}">
|
||||
@@ -27,7 +28,7 @@
|
||||
|
||||
{% include "base/theme.html" %}
|
||||
|
||||
<style data-id="brand-css">{{ brand_css }}</style>
|
||||
<style data-id="brand-css" nonce="{{ csp_nonce }}">{{ 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">
|
||||
<script data-id="theme-script" nonce="{{ csp_nonce }}">
|
||||
"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">
|
||||
<style data-id="static-styles" nonce="{{ csp_nonce }}">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -12,6 +13,7 @@ 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)
|
||||
|
||||
|
||||
156
authentik/core/tests/test_setup.py
Normal file
156
authentik/core/tests/test_setup.py
Normal file
@@ -0,0 +1,156 @@
|
||||
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,7 +1,6 @@
|
||||
"""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
|
||||
@@ -19,6 +18,7 @@ from authentik.core.api.sources import (
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.core.setup.views import SetupView
|
||||
from authentik.core.views.apps import RedirectToAppLaunch
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import (
|
||||
@@ -35,7 +35,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
login_required(RootRedirectView.as_view()),
|
||||
RootRedirectView.as_view(),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
@@ -62,6 +62,11 @@ 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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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
|
||||
@@ -14,12 +15,13 @@ 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(RedirectView):
|
||||
class RootRedirectView(AccessMixin, RedirectView):
|
||||
"""Root redirect view, redirect to brand's default application if set"""
|
||||
|
||||
pattern_name = "authentik_core:if-user"
|
||||
@@ -40,6 +42,10 @@ class RootRedirectView(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)
|
||||
|
||||
@@ -138,13 +138,7 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
"AllowDeviceIdentifiersInAttestation": True,
|
||||
"AuthenticationMethod": "UserSecureEnclaveKey",
|
||||
"EnableAuthorization": True,
|
||||
"EnableCreateUserAtLogin": True,
|
||||
"FileVaultPolicy": ["RequireAuthentication"],
|
||||
"LoginPolicy": ["RequireAuthentication"],
|
||||
"NewUserAuthorizationMode": "Standard",
|
||||
"UnlockPolicy": ["RequireAuthentication"],
|
||||
"UseSharedDeviceKeys": True,
|
||||
"UserAuthorizationMode": "Standard",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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,6 +19,7 @@ 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
|
||||
@@ -172,3 +173,17 @@ 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,10 +1,12 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -25,16 +27,22 @@ class TestAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_endpoint_stage_fleet(self):
|
||||
connector = FleetConnector.objects.create(name=generate_id())
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:stages-endpoint-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"connector": str(connector.pk),
|
||||
"mode": StageMode.REQUIRED,
|
||||
},
|
||||
)
|
||||
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,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content, {"connector": ["Selected connector is not compatible with this stage."]}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,6 +11,7 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
AgentDeviceUserBinding,
|
||||
AppleIndependentSecureEnclave,
|
||||
AppleNonce,
|
||||
DeviceToken,
|
||||
EnrollmentToken,
|
||||
@@ -25,7 +26,7 @@ class TestAppleToken(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.apple_sign_key = create_test_cert(PrivateKeyAlg.ECDSA)
|
||||
sign_key_pem = self.apple_sign_key.public_key.public_bytes(
|
||||
self.sign_key_pem = self.apple_sign_key.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
@@ -50,7 +51,7 @@ class TestAppleToken(TestCase):
|
||||
device=self.device,
|
||||
connector=self.connector,
|
||||
apple_sign_key_id=self.apple_sign_key.kid,
|
||||
apple_signing_key=sign_key_pem,
|
||||
apple_signing_key=self.sign_key_pem,
|
||||
apple_encryption_key=self.enc_pub,
|
||||
)
|
||||
self.user = create_test_user()
|
||||
@@ -59,7 +60,7 @@ class TestAppleToken(TestCase):
|
||||
user=self.user,
|
||||
order=0,
|
||||
apple_enclave_key_id=self.apple_sign_key.kid,
|
||||
apple_secure_enclave_key=sign_key_pem,
|
||||
apple_secure_enclave_key=self.sign_key_pem,
|
||||
)
|
||||
self.device_token = DeviceToken.objects.create(device=self.connection)
|
||||
|
||||
@@ -113,3 +114,62 @@ 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,5 +1,8 @@
|
||||
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 (
|
||||
@@ -23,6 +26,7 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
api_urlpatterns = [
|
||||
("endpoints/agents/psso/ise", AppleIndependentSecureEnclaveViewSet),
|
||||
path(
|
||||
"endpoints/agents/psso/register/device/",
|
||||
RegisterDeviceView.as_view(),
|
||||
|
||||
@@ -19,6 +19,7 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
AgentDeviceConnection,
|
||||
AgentDeviceUserBinding,
|
||||
AppleIndependentSecureEnclave,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
@@ -103,7 +104,9 @@ class TokenView(View):
|
||||
nonce.delete()
|
||||
return decoded
|
||||
|
||||
def validate_embedded_assertion(self, assertion: str) -> tuple[AgentDeviceUserBinding, dict]:
|
||||
def validate_embedded_assertion(
|
||||
self, assertion: str
|
||||
) -> tuple[AgentDeviceUserBinding | AppleIndependentSecureEnclave, 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"]
|
||||
@@ -112,8 +115,13 @@ class TokenView(View):
|
||||
target=self.device_connection.device, apple_enclave_key_id=expected_kid
|
||||
).first()
|
||||
if not device_user:
|
||||
LOGGER.warning("Could not find device user binding for user")
|
||||
raise ValidationError("Invalid request")
|
||||
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
|
||||
decoded: dict[str, Any] = decode(
|
||||
assertion,
|
||||
device_user.apple_secure_enclave_key,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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,
|
||||
@@ -44,7 +48,7 @@ class FleetController(BaseController[DBC]):
|
||||
return "fleetdm.com"
|
||||
|
||||
def capabilities(self) -> list[Capabilities]:
|
||||
return [Capabilities.ENROLL_AUTOMATIC_API]
|
||||
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.connector.url}{path}"
|
||||
@@ -76,8 +80,44 @@ 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(
|
||||
@@ -198,6 +238,8 @@ 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,6 +51,12 @@ 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")
|
||||
|
||||
51
authentik/enterprise/endpoints/connectors/fleet/stage.py
Normal file
51
authentik/enterprise/endpoints/connectors/fleet/stage.py
Normal file
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
23
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_host.pem
vendored
Normal file
23
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_host.pem
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
-----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-----
|
||||
46
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_profile.mobileconfig
vendored
Normal file
46
authentik/enterprise/endpoints/connectors/fleet/tests/fixtures/cond_acc_profile.mobileconfig
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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": "2025-06-25T22:21:35Z",
|
||||
"updated_at": "2025-12-20T11:42:09Z",
|
||||
"created_at": "2026-02-18T16:31:34Z",
|
||||
"updated_at": "2026-03-18T11:29:18Z",
|
||||
"software": null,
|
||||
"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",
|
||||
"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",
|
||||
"refetch_requested": false,
|
||||
"hostname": "jens-mac-vm.local",
|
||||
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
|
||||
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
|
||||
"platform": "darwin",
|
||||
"osquery_version": "5.19.0",
|
||||
"osquery_version": "5.21.0",
|
||||
"orbit_version": null,
|
||||
"fleet_desktop_version": null,
|
||||
"scripts_enabled": null,
|
||||
"os_version": "macOS 26.0.1",
|
||||
"build": "25A362",
|
||||
"os_version": "macOS 26.3",
|
||||
"build": "25D125",
|
||||
"platform_like": "darwin",
|
||||
"code_name": "",
|
||||
"uptime": 256356000000000,
|
||||
"uptime": 653014000000000,
|
||||
"memory": 4294967296,
|
||||
"cpu_type": "arm64e",
|
||||
"cpu_subtype": "ARM64E",
|
||||
@@ -31,38 +31,41 @@
|
||||
"hardware_vendor": "Apple Inc.",
|
||||
"hardware_model": "VirtualMac2,1",
|
||||
"hardware_version": "",
|
||||
"hardware_serial": "Z5DDF07GK6",
|
||||
"hardware_serial": "ZV35VFDD50",
|
||||
"computer_name": "jens-mac-vm",
|
||||
"timezone": null,
|
||||
"public_ip": "92.116.179.252",
|
||||
"primary_ip": "192.168.85.3",
|
||||
"primary_mac": "e6:9d:21:c2:2f:19",
|
||||
"primary_ip": "192.168.64.7",
|
||||
"primary_mac": "5e:72:1c:89:98:29",
|
||||
"distributed_interval": 10,
|
||||
"config_tls_refresh": 60,
|
||||
"logger_tls_period": 10,
|
||||
"team_id": 2,
|
||||
"team_id": 5,
|
||||
"pack_stats": null,
|
||||
"team_name": "prod",
|
||||
"gigs_disk_space_available": 23.82,
|
||||
"percent_disk_space_available": 37,
|
||||
"team_name": "dev",
|
||||
"gigs_disk_space_available": 16.52,
|
||||
"percent_disk_space_available": 26,
|
||||
"gigs_total_disk_space": 62.83,
|
||||
"gigs_all_disk_space": null,
|
||||
"issues": {
|
||||
"failing_policies_count": 1,
|
||||
"critical_vulnerabilities_count": 2,
|
||||
"total_issues_count": 3
|
||||
"critical_vulnerabilities_count": 0,
|
||||
"total_issues_count": 1
|
||||
},
|
||||
"device_mapping": null,
|
||||
"mdm": {
|
||||
"enrollment_status": "On (manual)",
|
||||
"dep_profile_error": false,
|
||||
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
|
||||
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
|
||||
"name": "Fleet",
|
||||
"encryption_key_available": false,
|
||||
"connected_to_fleet": true
|
||||
},
|
||||
"refetch_critical_queries_until": null,
|
||||
"last_restarted_at": "2025-10-21T00:17:55Z",
|
||||
"status": "offline",
|
||||
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
|
||||
"status": "online",
|
||||
"display_text": "jens-mac-vm.local",
|
||||
"display_name": "jens-mac-vm"
|
||||
"display_name": "jens-mac-vm",
|
||||
"fleet_id": 5,
|
||||
"fleet_name": "dev"
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ 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()
|
||||
name=generate_id(),
|
||||
url="http://localhost",
|
||||
token=generate_id(),
|
||||
map_teams_access_group=True,
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -40,6 +47,9 @@ 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,
|
||||
{
|
||||
@@ -50,7 +60,13 @@ class TestFleetConnector(APITestCase):
|
||||
"version": "24.04.3 LTS",
|
||||
},
|
||||
"disks": [],
|
||||
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
|
||||
"vendor": {
|
||||
"fleetdm.com": {
|
||||
"policies": [],
|
||||
"agent_version": "",
|
||||
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
|
||||
}
|
||||
},
|
||||
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
|
||||
"hardware": {
|
||||
"model": "VMware20,1",
|
||||
@@ -72,6 +88,10 @@ 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,
|
||||
@@ -81,11 +101,13 @@ class TestFleetConnector(APITestCase):
|
||||
json={"hosts": []},
|
||||
)
|
||||
controller.sync_endpoints()
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
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)
|
||||
@@ -128,6 +150,6 @@ class TestFleetConnector(APITestCase):
|
||||
"arch": "arm64e",
|
||||
"family": OSFamily.macOS,
|
||||
"name": "macOS",
|
||||
"version": "26.0.1",
|
||||
"version": "26.3",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -25,7 +26,6 @@ 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,8 +217,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
return None
|
||||
return str(_cert_attr[0])
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: MutualTLSStage = self.executor.current_stage
|
||||
def get_cert(self, mode: StageMode):
|
||||
certs = [
|
||||
*self._parse_cert_xfcc(),
|
||||
*self._parse_cert_nginx(),
|
||||
@@ -228,21 +227,26 @@ class MTLSStageView(ChallengeStageView):
|
||||
authorities = self.get_authorities()
|
||||
if not authorities:
|
||||
self.logger.warning("No Certificate authority found")
|
||||
if stage.mode == StageMode.OPTIONAL:
|
||||
return self.executor.stage_ok()
|
||||
if stage.mode == StageMode.REQUIRED:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
if mode == StageMode.OPTIONAL:
|
||||
return None
|
||||
if mode == StageMode.REQUIRED:
|
||||
raise PermissionDenied("Unknown error")
|
||||
cert = self.validate_cert(authorities, certs)
|
||||
if not cert and stage.mode == StageMode.REQUIRED:
|
||||
if not cert and mode == StageMode.REQUIRED:
|
||||
self.logger.warning("Client certificate required but no certificates given")
|
||||
return super().dispatch(
|
||||
request,
|
||||
*args,
|
||||
error_message=_("Certificate required but no certificate was given."),
|
||||
**kwargs,
|
||||
)
|
||||
if not cert and stage.mode == StageMode.OPTIONAL:
|
||||
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
|
||||
if not cert and 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)
|
||||
@@ -251,15 +255,5 @@ class MTLSStageView(ChallengeStageView):
|
||||
elif existing_user:
|
||||
self.auth_user(existing_user, cert)
|
||||
else:
|
||||
return super().dispatch(
|
||||
request, *args, error_message=_("No user found for certificate."), **kwargs
|
||||
)
|
||||
return self.executor.stage_invalid(_("No user found for certificate."))
|
||||
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,6 +11,10 @@ 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,6 +42,7 @@ 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,6 +40,7 @@ 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,6 +5,7 @@ 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
|
||||
@@ -26,6 +27,7 @@ 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:
|
||||
@@ -226,6 +228,15 @@ 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:
|
||||
@@ -273,9 +284,7 @@ class FlowPlanner:
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
exc = FlowNonApplicableException()
|
||||
exc.policy_result = result
|
||||
raise exc
|
||||
raise FlowNonApplicableException(result)
|
||||
# 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>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
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">
|
||||
<style data-id="flow-sfe" nonce="{{ csp_nonce }}">
|
||||
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>
|
||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% comment %}
|
||||
@see {@link web/types/webcomponents.d.ts} for type definitions.
|
||||
{% endcomment %}
|
||||
<script data-id="shady-dom">
|
||||
<script data-id="shady-dom" nonce="{{ csp_nonce }}">
|
||||
"use strict";
|
||||
|
||||
window.ShadyDOM = window.ShadyDOM || {}
|
||||
@@ -20,7 +20,7 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
{% include "base/header_js.html" %}
|
||||
<script data-id="flow-config">
|
||||
<script data-id="flow-config" nonce="{{ csp_nonce }}">
|
||||
"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">
|
||||
<style data-id="flow-css" nonce="{{ csp_nonce }}">
|
||||
: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,5 +1,6 @@
|
||||
"""flow views tests"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -7,6 +8,7 @@ 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
|
||||
@@ -17,6 +19,7 @@ from authentik.flows.models import (
|
||||
FlowDeniedAction,
|
||||
FlowDesignation,
|
||||
FlowStageBinding,
|
||||
FlowToken,
|
||||
InvalidResponseAction,
|
||||
)
|
||||
from authentik.flows.planner import FlowPlan, FlowPlanner
|
||||
@@ -24,6 +27,7 @@ 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,
|
||||
@@ -740,3 +744,77 @@ 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,6 +26,7 @@ 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,
|
||||
@@ -129,6 +130,22 @@ 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,6 +62,7 @@ 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,7 +71,11 @@ class FlowInspectorView(APIView):
|
||||
|
||||
flow: Flow
|
||||
_logger: BoundLogger
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if settings.DEBUG:
|
||||
return []
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def setup(self, request: HttpRequest, flow_slug: str):
|
||||
super().setup(request, flow_slug=flow_slug)
|
||||
|
||||
@@ -165,6 +165,8 @@ web:
|
||||
timeout_http_read: 30s
|
||||
timeout_http_write: 60s
|
||||
timeout_http_idle: 120s
|
||||
csp:
|
||||
report_only: false
|
||||
|
||||
worker:
|
||||
processes: 1
|
||||
|
||||
@@ -14,7 +14,16 @@ 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)
|
||||
start_pk = pks[0]
|
||||
# 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
|
||||
while True:
|
||||
try:
|
||||
end_pk = pks.filter(pk__gte=start_pk)[chunk_size]
|
||||
|
||||
@@ -65,6 +65,7 @@ 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 GrantTypes, RedirectURI
|
||||
from authentik.providers.oauth2.models import GrantType, 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 [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
|
||||
"#" if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID] else "?"
|
||||
)
|
||||
|
||||
uri = (
|
||||
@@ -225,7 +225,7 @@ class TokenError(OAuth2Error):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, error):
|
||||
def __init__(self, error: str):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self.errors[error]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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,6 +19,7 @@ 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
|
||||
@@ -33,7 +34,16 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import WebfingerProvider
|
||||
from authentik.common.oauth.constants import SubModes
|
||||
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.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
@@ -58,7 +68,7 @@ def generate_client_secret() -> str:
|
||||
return generate_id(128)
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
class ClientType(models.TextChoices):
|
||||
"""Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable."""
|
||||
|
||||
@@ -66,12 +76,16 @@ class ClientTypes(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
|
||||
|
||||
class GrantTypes(models.TextChoices):
|
||||
class GrantType(models.TextChoices):
|
||||
"""OAuth2 Grant types we support"""
|
||||
|
||||
AUTHORIZATION_CODE = "authorization_code"
|
||||
IMPLICIT = "implicit"
|
||||
HYBRID = "hybrid"
|
||||
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
|
||||
|
||||
|
||||
class ResponseMode(models.TextChoices):
|
||||
@@ -188,14 +202,15 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
|
||||
client_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=ClientTypes.choices,
|
||||
default=ClientTypes.CONFIDENTIAL,
|
||||
choices=ClientType.choices,
|
||||
default=ClientType.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,
|
||||
GrantTypes,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -41,12 +41,34 @@ 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:
|
||||
@@ -74,6 +96,7 @@ 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(
|
||||
@@ -141,26 +164,6 @@ 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(
|
||||
@@ -208,6 +211,7 @@ 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(
|
||||
"/",
|
||||
@@ -226,6 +230,7 @@ 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(
|
||||
@@ -247,12 +252,14 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantType.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={
|
||||
@@ -266,7 +273,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.IMPLICIT,
|
||||
GrantType.IMPLICIT,
|
||||
)
|
||||
# Implicit without openid scope
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
@@ -281,8 +288,10 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.IMPLICIT,
|
||||
GrantType.IMPLICIT,
|
||||
)
|
||||
provider.grant_types = [GrantType.HYBRID]
|
||||
provider.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
@@ -294,7 +303,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantTypes.HYBRID
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantType.HYBRID
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
@@ -317,6 +326,7 @@ 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()
|
||||
@@ -353,6 +363,7 @@ 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(
|
||||
@@ -424,6 +435,7 @@ 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(
|
||||
@@ -486,6 +498,7 @@ 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()
|
||||
@@ -535,6 +548,7 @@ 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(
|
||||
@@ -592,6 +606,7 @@ 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()
|
||||
@@ -632,6 +647,7 @@ 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(
|
||||
@@ -655,6 +671,7 @@ 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(
|
||||
@@ -687,6 +704,7 @@ 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()
|
||||
@@ -717,6 +735,7 @@ 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()
|
||||
@@ -756,6 +775,7 @@ 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()
|
||||
@@ -782,6 +802,7 @@ 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,10 +6,11 @@ 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 OAuth2Provider
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@@ -21,6 +22,7 @@ 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(),
|
||||
@@ -41,6 +43,21 @@ 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()
|
||||
@@ -110,3 +127,57 @@ 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, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -173,7 +173,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
|
||||
def test_introspect_provider_public(self):
|
||||
"""Test introspect"""
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.client_type = ClientType.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=ClientTypes.PUBLIC,
|
||||
client_type=ClientType.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,
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
@@ -126,7 +126,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
|
||||
def test_revoke_public(self):
|
||||
"""Test revoke public client"""
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.client_type = ClientType.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=ClientTypes.PUBLIC,
|
||||
client_type=ClientType.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 = ClientTypes.PUBLIC
|
||||
self.provider.client_type = ClientType.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=ClientTypes.PUBLIC,
|
||||
client_type=ClientType.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -44,11 +45,39 @@ 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,
|
||||
)
|
||||
@@ -76,6 +105,7 @@ 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,
|
||||
)
|
||||
@@ -97,6 +127,7 @@ 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,
|
||||
)
|
||||
@@ -139,6 +170,7 @@ 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,
|
||||
)
|
||||
@@ -179,6 +211,7 @@ 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,
|
||||
@@ -210,6 +243,7 @@ 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,
|
||||
)
|
||||
@@ -271,6 +305,7 @@ 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,
|
||||
)
|
||||
@@ -328,6 +363,7 @@ 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,
|
||||
)
|
||||
@@ -400,6 +436,7 @@ 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
|
||||
@@ -497,6 +534,7 @@ 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,6 +22,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -55,6 +56,7 @@ 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,6 +20,7 @@ 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,
|
||||
@@ -68,6 +69,7 @@ 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,6 +21,7 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -41,6 +42,7 @@ 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,6 +22,7 @@ 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,
|
||||
@@ -42,6 +43,7 @@ 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,6 +25,7 @@ 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,
|
||||
@@ -45,6 +46,7 @@ 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,6 +17,7 @@ from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
DeviceToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -37,6 +38,7 @@ 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)
|
||||
@@ -48,6 +50,7 @@ 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,
|
||||
},
|
||||
)
|
||||
@@ -66,6 +69,7 @@ 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,
|
||||
},
|
||||
@@ -74,6 +78,26 @@ 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(
|
||||
@@ -86,6 +110,7 @@ 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,
|
||||
},
|
||||
@@ -105,6 +130,7 @@ 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,6 +11,7 @@ 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,
|
||||
@@ -37,6 +38,7 @@ 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()
|
||||
@@ -95,6 +97,7 @@ 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()
|
||||
@@ -151,6 +154,7 @@ 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()
|
||||
@@ -196,6 +200,7 @@ 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,11 +57,9 @@ from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RedirectURIType,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
@@ -166,28 +164,31 @@ class OAuthAuthorizationParams:
|
||||
"""Check grant"""
|
||||
# Determine which flow to use.
|
||||
if self.response_type in [ResponseTypes.CODE]:
|
||||
self.grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
self.grant_type = GrantType.AUTHORIZATION_CODE
|
||||
elif self.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.IMPLICIT
|
||||
self.grant_type = GrantType.IMPLICIT
|
||||
elif self.response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.HYBRID
|
||||
|
||||
self.grant_type = GrantType.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 [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID]:
|
||||
self.response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
def check_redirect_uri(self):
|
||||
@@ -197,18 +198,6 @@ 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:
|
||||
@@ -260,7 +249,7 @@ class OAuthAuthorizationParams:
|
||||
)
|
||||
self.scope = self.scope.intersection(default_scope_names)
|
||||
if SCOPE_OPENID not in self.scope and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
self.grant_type == GrantType.HYBRID
|
||||
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
@@ -611,8 +600,8 @@ class OAuthFulfillmentStage(StageView):
|
||||
code = None
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save()
|
||||
@@ -627,7 +616,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = {}
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
query_fragment["code"] = code.code
|
||||
query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -641,7 +630,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
post_params = {}
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
post_params["code"] = code.code
|
||||
post_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -710,7 +699,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
if self.params.grant_type == GrantType.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, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
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: list[str] = []
|
||||
scopes: set[str] = []
|
||||
|
||||
def parse_request(self):
|
||||
"""Parse incoming request"""
|
||||
@@ -42,9 +42,25 @@ 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
|
||||
self.scopes = self.request.POST.get("scope", "").split(" ")
|
||||
|
||||
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)
|
||||
|
||||
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, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, 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 != ClientTypes.CONFIDENTIAL:
|
||||
if provider.client_type != ClientType.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,
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -165,8 +165,20 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
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(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
LOGGER.warning(
|
||||
@@ -598,10 +610,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, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import (
|
||||
TokenResponse,
|
||||
authenticate_provider,
|
||||
@@ -33,11 +33,13 @@ 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 == ClientTypes.CONFIDENTIAL:
|
||||
if provider.client_type == ClientType.CONFIDENTIAL:
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
|
||||
@@ -16,7 +16,8 @@ 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 (
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -161,7 +162,12 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.grant_types = [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.CLIENT_CREDENTIALS,
|
||||
GrantType.PASSWORD,
|
||||
]
|
||||
self.client_type = ClientType.CONFIDENTIAL
|
||||
self.signing_key = None
|
||||
self.include_claims_in_id_token = True
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
|
||||
@@ -9,7 +9,7 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.models import ClientTypes
|
||||
from authentik.providers.oauth2.models import ClientType
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
|
||||
def test_update_defaults(self):
|
||||
"""Test create"""
|
||||
@@ -114,8 +114,8 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
provider.client_type = ClientTypes.PUBLIC
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
provider.client_type = ClientType.PUBLIC
|
||||
provider.save()
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:proxyprovider-detail", kwargs={"pk": provider.pk}),
|
||||
@@ -130,7 +130,7 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
|
||||
def test_sa_fetch(self):
|
||||
"""Test fetching the outpost config as the service account"""
|
||||
|
||||
@@ -97,6 +97,9 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
||||
if cached_config is not None:
|
||||
return cached_config
|
||||
|
||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.VCENTER:
|
||||
return default_config
|
||||
|
||||
# Attempt to fetch from remote
|
||||
path = "/ServiceProviderConfig"
|
||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.SALESFORCE:
|
||||
|
||||
@@ -94,6 +94,7 @@ class Migration(migrations.Migration):
|
||||
("slack", "Slack"),
|
||||
("sfdc", "Salesforce"),
|
||||
("webex", "Webex"),
|
||||
("vcenter", "vCenter"),
|
||||
],
|
||||
default="default",
|
||||
help_text="Alter authentik behavior for vendor-specific SCIM implementations.",
|
||||
|
||||
@@ -83,6 +83,7 @@ class SCIMCompatibilityMode(models.TextChoices):
|
||||
SLACK = "slack", _("Slack")
|
||||
SALESFORCE = "sfdc", _("Salesforce")
|
||||
WEBEX = "webex", _("Webex")
|
||||
VCENTER = "vcenter", _("vCenter")
|
||||
|
||||
|
||||
class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""authentik recovery create_admin_group"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.core.models import User
|
||||
@@ -12,7 +14,7 @@ class Command(TenantCommand):
|
||||
|
||||
help = _("Create admin group if the default group gets deleted.")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("user", action="store", help="User to add to the admin group.")
|
||||
|
||||
def handle_per_tenant(self, *args, **options):
|
||||
|
||||
@@ -5,6 +5,7 @@ from hashlib import sha512
|
||||
from ipaddress import ip_address
|
||||
from time import perf_counter, time
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from channels.exceptions import DenyConnection
|
||||
from django.conf import settings
|
||||
@@ -314,6 +315,126 @@ class ChannelsLoggingMiddleware:
|
||||
)
|
||||
|
||||
|
||||
CSP_HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
|
||||
CSP_HEADER_ENFORCE = "Content-Security-Policy"
|
||||
|
||||
|
||||
class ContentSecurityPolicyMiddleware:
|
||||
"""Emit a Content-Security-Policy(-Report-Only) header carrying the per-request nonce.
|
||||
|
||||
The policy is intentionally strict: inline `<script>`/`<style>` are rejected unless
|
||||
they carry the request's nonce (set via `request.request_id` and exposed to templates
|
||||
as `csp_nonce`). External resources from third-party login providers (Apple, Telegram)
|
||||
and configurable captcha hosts are allow-listed below; the report-only mode lets the
|
||||
browser surface anything else as a console violation without breaking the page.
|
||||
"""
|
||||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
|
||||
# Hosts that the bundled login flows pull resources from. The captcha stage allows
|
||||
# an admin-configured `js_url`, so the well-known third-party captcha origins are
|
||||
# included here so that report-only output is not drowned in expected violations.
|
||||
SCRIPT_SRC_THIRD_PARTY = (
|
||||
"https://appleid.cdn-apple.com",
|
||||
"https://telegram.org",
|
||||
"https://www.google.com",
|
||||
"https://www.gstatic.com",
|
||||
"https://www.recaptcha.net",
|
||||
"https://js.hcaptcha.com",
|
||||
"https://challenges.cloudflare.com",
|
||||
)
|
||||
FRAME_SRC_THIRD_PARTY = (
|
||||
"https://appleid.apple.com",
|
||||
"https://oauth.telegram.org",
|
||||
"https://www.google.com",
|
||||
"https://newassets.hcaptcha.com",
|
||||
"https://challenges.cloudflare.com",
|
||||
)
|
||||
|
||||
# Dev-only origins. The esbuild live-reload plugin opens an EventSource against
|
||||
# a dynamically chosen localhost port, so localhost on any scheme/port is allowed
|
||||
# when DEBUG is on. Never folded into prod policy.
|
||||
DEBUG_CONNECT_SRC = (
|
||||
"http://localhost:*",
|
||||
"https://localhost:*",
|
||||
"ws://localhost:*",
|
||||
"wss://localhost:*",
|
||||
)
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
self.report_only = CONFIG.get_bool("web.csp.report_only", True)
|
||||
self.debug = settings.DEBUG
|
||||
self.sentry_origin = self._sentry_origin(CONFIG.get("error_reporting.sentry_dsn", ""))
|
||||
|
||||
@staticmethod
|
||||
def _sentry_origin(dsn: str) -> str | None:
|
||||
"""Pull `scheme://host[:port]` out of a Sentry DSN so the browser SDK
|
||||
can ship envelopes to it. DSNs are `https://<key>@<host>/<project>`."""
|
||||
if not dsn:
|
||||
return None
|
||||
parts = urlsplit(dsn)
|
||||
if not parts.scheme or not parts.hostname:
|
||||
return None
|
||||
host = parts.hostname
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return f"{parts.scheme}://{host}"
|
||||
|
||||
def _build_policy(self, nonce: str) -> str:
|
||||
nonce_token = f"'nonce-{nonce}'"
|
||||
script_src = ("'self'", nonce_token, *self.SCRIPT_SRC_THIRD_PARTY)
|
||||
# Per CSP3 §6.6.2.2, browsers ignore `'unsafe-inline'` whenever a
|
||||
# nonce is also present in the same source list. Several runtime
|
||||
# libraries we ship (mermaid, PatternFly's own style injections,
|
||||
# DOMPurify's sanitization sandbox) emit `<style>` elements
|
||||
# dynamically without a nonce, so we drop the nonce for styles
|
||||
# and rely on `'unsafe-inline'`. Script-side CSP is unaffected
|
||||
# — the eval/script protections remain strict.
|
||||
style_src = ("'self'", "'unsafe-inline'")
|
||||
frame_src = ("'self'", *self.FRAME_SRC_THIRD_PARTY)
|
||||
connect_src: tuple[str, ...] = ("'self'", "ws:", "wss:")
|
||||
if self.sentry_origin:
|
||||
connect_src = (*connect_src, self.sentry_origin)
|
||||
if self.debug:
|
||||
connect_src = (*connect_src, *self.DEBUG_CONNECT_SRC)
|
||||
directives = {
|
||||
"default-src": ("'self'",),
|
||||
"script-src": script_src,
|
||||
"style-src": style_src,
|
||||
# Inline `style="..."` attributes can't carry a nonce; many libraries
|
||||
# (PatternFly, Lit style bindings) set them dynamically.
|
||||
"style-src-attr": ("'unsafe-inline'",),
|
||||
"img-src": ("'self'", "data:", "blob:", "https:"),
|
||||
"font-src": ("'self'", "data:"),
|
||||
"connect-src": connect_src,
|
||||
"frame-src": frame_src,
|
||||
"media-src": ("'self'",),
|
||||
"worker-src": ("'self'", "blob:"),
|
||||
"object-src": ("'none'",),
|
||||
"base-uri": ("'self'",),
|
||||
"form-action": ("'self'",),
|
||||
"frame-ancestors": ("'none'",),
|
||||
}
|
||||
return "; ".join(f"{name} {' '.join(values)}" for name, values in directives.items())
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
response = self.get_response(request)
|
||||
# Only attach to HTML responses — CSP on JSON/binary responses is just header bloat.
|
||||
content_type = response.get("Content-Type", "")
|
||||
if not content_type.startswith("text/html"):
|
||||
return response
|
||||
nonce = getattr(request, "request_id", None)
|
||||
if not nonce:
|
||||
return response
|
||||
header = CSP_HEADER_REPORT_ONLY if self.report_only else CSP_HEADER_ENFORCE
|
||||
# Don't clobber a policy a downstream view explicitly set.
|
||||
if header in response:
|
||||
return response
|
||||
response[header] = self._build_policy(nonce)
|
||||
return response
|
||||
|
||||
|
||||
class LoggingMiddleware:
|
||||
"""Logger middleware"""
|
||||
|
||||
|
||||
@@ -276,6 +276,7 @@ MIDDLEWARE = [
|
||||
"authentik.core.middleware.RequestIDMiddleware",
|
||||
"authentik.brands.middleware.BrandMiddleware",
|
||||
"authentik.events.middleware.AuditMiddleware",
|
||||
"authentik.root.middleware.ContentSecurityPolicyMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"authentik.root.middleware.CsrfViewMiddleware",
|
||||
@@ -446,8 +447,6 @@ DRAMATIQ = {
|
||||
("authentik.tasks.middleware.TaskLogMiddleware", {}),
|
||||
("authentik.tasks.middleware.LoggingMiddleware", {}),
|
||||
("authentik.tasks.middleware.DescriptionMiddleware", {}),
|
||||
("authentik.tasks.middleware.WorkerHealthcheckMiddleware", {}),
|
||||
("authentik.tasks.middleware.WorkerStatusMiddleware", {}),
|
||||
(
|
||||
"authentik.tasks.middleware.MetricsMiddleware",
|
||||
{
|
||||
|
||||
@@ -12,7 +12,13 @@ from django.db import connection, models
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
|
||||
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
|
||||
from ldap3.core.exceptions import (
|
||||
LDAPAdminLimitExceededResult,
|
||||
LDAPAttributeError,
|
||||
LDAPException,
|
||||
LDAPInsufficientAccessRightsResult,
|
||||
LDAPSchemaError,
|
||||
)
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@@ -278,10 +284,17 @@ class LDAPSource(IncomingSyncSource):
|
||||
successful = conn.bind()
|
||||
if successful:
|
||||
return conn
|
||||
except (LDAPSchemaError, LDAPInsufficientAccessRightsResult) as exc:
|
||||
# Schema error, so try connecting without schema info
|
||||
except (
|
||||
LDAPSchemaError,
|
||||
LDAPInsufficientAccessRightsResult,
|
||||
LDAPAdminLimitExceededResult,
|
||||
LDAPAttributeError,
|
||||
) as exc:
|
||||
# Schema error or rate limit during schema fetch, retry without schema info
|
||||
# See https://github.com/goauthentik/authentik/issues/4590
|
||||
# See also https://github.com/goauthentik/authentik/issues/3399
|
||||
# LDAPAdminLimitExceededResult: Google Secure LDAP rate-limits schema queries
|
||||
# LDAPAttributeError: Google Secure LDAP returns unsupported attrs in schema
|
||||
if server_kwargs.get("get_info", ALL) == NONE:
|
||||
LOGGER.warning("Failed to connect after schema downgrade", source=self, exc=exc)
|
||||
raise exc
|
||||
|
||||
@@ -17,7 +17,7 @@ from authentik.core.api.sources import SourceSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.models import OAuthSource, PKCEMethod
|
||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||
|
||||
|
||||
@@ -83,13 +83,24 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||
"authorization_url": "authorization_endpoint",
|
||||
"access_token_url": "token_endpoint",
|
||||
"profile_url": "userinfo_endpoint",
|
||||
"pkce": "code_challenge_methods_supported",
|
||||
}
|
||||
for ak_key, oidc_key in field_map.items():
|
||||
# Don't overwrite user-set values
|
||||
if ak_key in attrs and attrs[ak_key]:
|
||||
continue
|
||||
attrs[ak_key] = config.get(oidc_key, "")
|
||||
# code_challenge_methods_supported is a list per RFC 8414, not a
|
||||
# single method. Pick one (prefer S256, the RFC-recommended method)
|
||||
# rather than letting the list round-trip into the pkce TextField
|
||||
# and later str() into the authorize URL as "['plain', 'S256']".
|
||||
if not attrs.get("pkce"):
|
||||
supported_methods = config.get("code_challenge_methods_supported") or []
|
||||
attrs["pkce"] = PKCEMethod.NONE
|
||||
if isinstance(supported_methods, list):
|
||||
if PKCEMethod.S256 in supported_methods:
|
||||
attrs["pkce"] = PKCEMethod.S256
|
||||
elif PKCEMethod.PLAIN in supported_methods:
|
||||
attrs["pkce"] = PKCEMethod.PLAIN
|
||||
inferred_oidc_jwks_url = config.get("jwks_uri", "")
|
||||
|
||||
# Prefer user-entered URL to inferred URL to default URL
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user