mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
164 Commits
a11y-modal
...
core/separ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab456a7f51 | ||
|
|
9411d4724a | ||
|
|
74d555819a | ||
|
|
1ac7064b64 | ||
|
|
d285225eb8 | ||
|
|
6aaebf6ad4 | ||
|
|
fb9e1e6e1a | ||
|
|
1d267fa2a7 | ||
|
|
e2d8239581 | ||
|
|
d1ed30b6e0 | ||
|
|
d6604d971a | ||
|
|
197cde8fae | ||
|
|
0bc4739f54 | ||
|
|
bc0cbdf4b6 | ||
|
|
36ef00f548 | ||
|
|
b54fe8751d | ||
|
|
55205045d0 | ||
|
|
d217f763b3 | ||
|
|
bd4c846529 | ||
|
|
dd4237a2e3 | ||
|
|
d68b4f0b1f | ||
|
|
cbdec65b2f | ||
|
|
2a90a049db | ||
|
|
f8c26bada2 | ||
|
|
1236b231f6 | ||
|
|
9dd018f1fe | ||
|
|
89b03526d4 | ||
|
|
17594f17f4 | ||
|
|
82111d7f9d | ||
|
|
4c2469108c | ||
|
|
4de503056f | ||
|
|
cfc48f551a | ||
|
|
090d09fcdd | ||
|
|
e3ddc0422a | ||
|
|
676189f640 | ||
|
|
6de59560aa | ||
|
|
93bf83c981 | ||
|
|
5f57c6077d | ||
|
|
bda6a262d1 | ||
|
|
45857a0352 | ||
|
|
a9b1f8434f | ||
|
|
3725f8dc26 | ||
|
|
f81640a76b | ||
|
|
9fc4ff24de | ||
|
|
8059d7c5e5 | ||
|
|
ce3ee61434 | ||
|
|
f54418f7a7 | ||
|
|
3555fab0b5 | ||
|
|
0bd7b7375c | ||
|
|
4dfdf9afa3 | ||
|
|
545b1e8f19 | ||
|
|
763f7f9e64 | ||
|
|
a10ec34aec | ||
|
|
03b23b87e0 | ||
|
|
894f134893 | ||
|
|
25d3d5751e | ||
|
|
22d6f91bbc | ||
|
|
c49bc9e5a9 | ||
|
|
ba00882385 | ||
|
|
43941a5aba | ||
|
|
49c80ee9e6 | ||
|
|
810a479242 | ||
|
|
94cd66dd24 | ||
|
|
0e60d0a235 | ||
|
|
31261e12f8 | ||
|
|
b5cfe14606 | ||
|
|
046bc8ac98 | ||
|
|
0c8d07da26 | ||
|
|
e6c625a97b | ||
|
|
fa17d66bdd | ||
|
|
9584ceeea2 | ||
|
|
989cfe1f88 | ||
|
|
84a1429cf6 | ||
|
|
69b7acbb7a | ||
|
|
acaf3d09a8 | ||
|
|
d60aa804f6 | ||
|
|
1453e327a9 | ||
|
|
51d749eb21 | ||
|
|
585266b551 | ||
|
|
1ed8b21191 | ||
|
|
bad031445d | ||
|
|
13e14f1429 | ||
|
|
9225895ced | ||
|
|
ad2218611f | ||
|
|
056119f901 | ||
|
|
ee391b9a76 | ||
|
|
48e1edfaa2 | ||
|
|
a897535998 | ||
|
|
5831a24423 | ||
|
|
4a46f6f0c7 | ||
|
|
5fae44ff5b | ||
|
|
3cd982750f | ||
|
|
9497f503f8 | ||
|
|
9f2047e679 | ||
|
|
0e528cbcf0 | ||
|
|
e6c482150a | ||
|
|
da5f5419e5 | ||
|
|
ac4a3884c1 | ||
|
|
cce84dcf9d | ||
|
|
7bca2255a8 | ||
|
|
9376dd45c1 | ||
|
|
1c7094b723 | ||
|
|
57b2984f74 | ||
|
|
c2445d6f9b | ||
|
|
9f93a08244 | ||
|
|
c7cd24cf94 | ||
|
|
b404c8af8b | ||
|
|
ff24034edb | ||
|
|
470a16de24 | ||
|
|
d52eea9c5f | ||
|
|
fe020ed413 | ||
|
|
db6ca79e37 | ||
|
|
b3dda80166 | ||
|
|
15613c3eff | ||
|
|
270cf0b1d8 | ||
|
|
02e695e6a0 | ||
|
|
6b955cf607 | ||
|
|
b19c61ecdf | ||
|
|
378f7c67a5 | ||
|
|
6268da3007 | ||
|
|
db9081e7dc | ||
|
|
060766f16e | ||
|
|
669d54a768 | ||
|
|
60a90c0bd4 | ||
|
|
dd19a33b68 | ||
|
|
3e23e4f58b | ||
|
|
a2e99d4030 | ||
|
|
a9254715d1 | ||
|
|
c78c8e4fd5 | ||
|
|
d9ae4837b5 | ||
|
|
84d700f79c | ||
|
|
45dcef8e9d | ||
|
|
ced62a9332 | ||
|
|
aaac14a7c7 | ||
|
|
bc45ef6c9a | ||
|
|
1ae6051f8c | ||
|
|
c1445f6828 | ||
|
|
3d964ddd2e | ||
|
|
24a817cce8 | ||
|
|
59263ae678 | ||
|
|
e9b33be694 | ||
|
|
0ff3869ea3 | ||
|
|
219a110339 | ||
|
|
ef202f0a26 | ||
|
|
e80b1bfc2b | ||
|
|
fbd3008a0c | ||
|
|
77f8ed6c43 | ||
|
|
7d3aca97bb | ||
|
|
4ca3bfa3e4 | ||
|
|
d992929c93 | ||
|
|
39c4e87ab0 | ||
|
|
aba75e8cc2 | ||
|
|
9556528fd3 | ||
|
|
735cacd256 | ||
|
|
e1b6c19b21 | ||
|
|
69628863ae | ||
|
|
f9a1e534c1 | ||
|
|
f0ff3019c8 | ||
|
|
4f94843fe3 | ||
|
|
0e81885813 | ||
|
|
7acafc38a7 | ||
|
|
f90f120bd2 | ||
|
|
e3a94814c5 | ||
|
|
bed7d16663 |
35
.cargo/deny.toml
Normal file
35
.cargo/deny.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[licenses]
|
||||
allow = ["Apache-2.0", "MIT", "MPL-2.0", "Unicode-3.0"]
|
||||
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
|
||||
[bans]
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
[bans.workspace-dependencies]
|
||||
duplicates = "deny"
|
||||
include-path-dependencies = true
|
||||
unused = "deny"
|
||||
|
||||
# No non-FIPS compliant dependencies
|
||||
[[bans.deny]]
|
||||
name = "native-tls"
|
||||
[[bans.deny]]
|
||||
name = "openssl"
|
||||
[[bans.deny]]
|
||||
name = "openssl-sys"
|
||||
[[bans.deny]]
|
||||
name = "ring"
|
||||
[[bans.features]]
|
||||
allow = [
|
||||
"alloc",
|
||||
"aws-lc-sys",
|
||||
"default",
|
||||
"fips",
|
||||
"prebuilt-nasm",
|
||||
"ring-io",
|
||||
"ring-sig-verify",
|
||||
]
|
||||
name = "aws-lc-rs"
|
||||
exact = true
|
||||
16
.cargo/rustfmt.toml
Normal file
16
.cargo/rustfmt.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
comment_width = 100
|
||||
format_code_in_doc_comments = true
|
||||
format_strings = true
|
||||
group_imports = "StdExternalCrate"
|
||||
hex_literal_case = "Lower"
|
||||
imports_granularity = "Crate"
|
||||
max_width = 100
|
||||
newline_style = "Unix"
|
||||
normalize_comments = true
|
||||
normalize_doc_attributes = true
|
||||
reorder_impl_items = true
|
||||
style_edition = "2024"
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
where_single_line = true
|
||||
wrap_comments = true
|
||||
23
.github/actions/cherry-pick/action.yml
vendored
23
.github/actions/cherry-pick/action.yml
vendored
@@ -115,20 +115,13 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
|
||||
REASON: ${{ steps.should_run.outputs.reason }}
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
|
||||
|
||||
# Get PR details
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
|
||||
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine which labels to process
|
||||
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
|
||||
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
|
||||
# Only process the specific label that was just added
|
||||
if [ "${{ github.event_name }}" = "issues" ]; then
|
||||
LABEL_NAME="${{ github.event.label.name }}"
|
||||
@@ -152,13 +145,13 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
LABELS: '${{ steps.pr_details.outputs.labels }}'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
PR_NUMBER='${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA='${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE='${{ steps.pr_details.outputs.pr_title }}'
|
||||
PR_AUTHOR='${{ steps.pr_details.outputs.pr_author }}'
|
||||
LABELS='${{ steps.pr_details.outputs.labels }}'
|
||||
|
||||
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
|
||||
echo "Found backport labels: $LABELS"
|
||||
|
||||
21
.github/actions/setup/action.yml
vendored
21
.github/actions/setup/action.yml
vendored
@@ -4,7 +4,7 @@ description: "Setup authentik testing environment"
|
||||
inputs:
|
||||
dependencies:
|
||||
description: "List of dependencies to setup"
|
||||
default: "system,python,node,go,runtime"
|
||||
default: "system,python,rust,node,go,runtime"
|
||||
postgresql_version:
|
||||
description: "Optional postgresql image tag"
|
||||
default: "16"
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v5
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
@@ -34,6 +34,23 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup rust (stable)
|
||||
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
|
||||
with:
|
||||
rustflags: ""
|
||||
- name: Setup rust (nightly)
|
||||
if: ${{ contains(inputs.dependencies, 'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@06203676c62f0d3c765be3f2fcfbebbcb02d09f5 # 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
|
||||
|
||||
8
.github/actions/test-results/action.yml
vendored
8
.github/actions/test-results/action.yml
vendored
@@ -2,18 +2,22 @@ name: "Process test results"
|
||||
description: Convert test results to JUnit, add them to GitHub Actions and codecov
|
||||
|
||||
inputs:
|
||||
files:
|
||||
description: Comma-separated explicit list of files to upload
|
||||
flags:
|
||||
description: Codecov flags
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
|
||||
with:
|
||||
files: ${{ inputs.files }}
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5
|
||||
with:
|
||||
files: ${{ inputs.files }}
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
report_type: test_results
|
||||
|
||||
4
.github/workflows/api-ts-publish.yml
vendored
4
.github/workflows/api-ts-publish.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/ci-api-docs.yml
vendored
4
.github/workflows/ci-api-docs.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
|
||||
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v5
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
|
||||
59
.github/workflows/ci-main.yml
vendored
59
.github/workflows/ci-main.yml
vendored
@@ -28,20 +28,36 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- bandit
|
||||
- black
|
||||
- spellcheck
|
||||
- pending-migrations
|
||||
- ruff
|
||||
- mypy
|
||||
include:
|
||||
- job: bandit
|
||||
deps: python
|
||||
- job: black
|
||||
deps: python
|
||||
- job: spellcheck
|
||||
deps: node
|
||||
- job: pending-migrations
|
||||
deps: python,runtime
|
||||
- job: ruff
|
||||
deps: python
|
||||
- job: mypy
|
||||
deps: python
|
||||
- job: cargo-deny
|
||||
deps: rust
|
||||
- job: cargo-machete
|
||||
deps: rust
|
||||
- job: clippy
|
||||
deps: rust
|
||||
- job: rustfmt
|
||||
deps: rust-nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: ${{ matrix.deps }}
|
||||
- name: run job
|
||||
run: uv run make ci-${{ matrix.job }}
|
||||
run: make ci-lint-${{ matrix.job }}
|
||||
test-gen-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -215,7 +231,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
@@ -258,7 +274,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
@@ -283,6 +299,29 @@ jobs:
|
||||
with:
|
||||
name: conformance-certification-${{ matrix.job.name }}
|
||||
path: tests/openid_conformance/exports/
|
||||
test-rust:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: rust
|
||||
- name: run tests
|
||||
run: |
|
||||
cargo llvm-cov --no-report nextest --workspace
|
||||
cargo llvm-cov report --codecov --output-path target/llvm-cov-target/rust.json
|
||||
- uses: ./.github/actions/test-results
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
files: target/llvm-cov-target/rust.json
|
||||
flags: rust
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: test-rust
|
||||
path: target/llvm-cov-target/rust.json
|
||||
ci-core-mark:
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
6
.github/workflows/gen-image-compress.yml
vendored
6
.github/workflows/gen-image-compress.yml
vendored
@@ -29,16 +29,16 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
id: compress
|
||||
uses: calibreapp/image-actions@d9c8ee5c3dc52ae4622c82ead88d658f4b16b65f # main
|
||||
uses: calibreapp/image-actions@03c976c29803442fc4040a9de5509669e7759b81 # main
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/gh-cherry-pick.yml
vendored
4
.github/workflows/gh-cherry-pick.yml
vendored
@@ -10,11 +10,11 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
if: ${{ env.GH_APP_ID != '' }}
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
|
||||
4
.github/workflows/gh-ghcr-retention.yml
vendored
4
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
|
||||
with:
|
||||
|
||||
8
.github/workflows/release-branch-off.yml
vendored
8
.github/workflows/release-branch-off.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
@@ -57,10 +57,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
|
||||
2
.github/workflows/release-publish.yml
vendored
2
.github/workflows/release-publish.yml
vendored
@@ -180,7 +180,7 @@ jobs:
|
||||
export CGO_ENABLED=0
|
||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- name: Upload binaries to release
|
||||
uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
|
||||
14
.github/workflows/release-tag.yml
vendored
14
.github/workflows/release-tag.yml
vendored
@@ -67,10 +67,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
@@ -115,10 +115,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
@@ -157,10 +157,10 @@ jobs:
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
|
||||
4
.github/workflows/repo-stale.yml
vendored
4
.github/workflows/repo-stale.yml
vendored
@@ -15,10 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
steps:
|
||||
- id: generate_token
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -195,6 +195,24 @@ pyvenv.cfg
|
||||
pip-selfcheck.json
|
||||
|
||||
# End of https://www.gitignore.io/api/python,django
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/rust
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
|
||||
|
||||
### Rust ###
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/rust
|
||||
|
||||
/static/
|
||||
local.env.yml
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Backend
|
||||
authentik/ @goauthentik/backend
|
||||
blueprints/ @goauthentik/backend
|
||||
src/ @goauthentik/backend
|
||||
cmd/ @goauthentik/backend
|
||||
internal/ @goauthentik/backend
|
||||
lifecycle/ @goauthentik/backend
|
||||
@@ -11,8 +12,12 @@ scripts/ @goauthentik/backend
|
||||
tests/ @goauthentik/backend
|
||||
pyproject.toml @goauthentik/backend
|
||||
uv.lock @goauthentik/backend
|
||||
Cargo.toml @goauthentik/backend
|
||||
Cargo.lock @goauthentik/backend
|
||||
go.mod @goauthentik/backend
|
||||
go.sum @goauthentik/backend
|
||||
.cargo/ @goauthentik/backend
|
||||
rust-toolchain.toml @goauthentik/backend
|
||||
# Infrastructure
|
||||
.github/ @goauthentik/infrastructure
|
||||
lifecycle/aws/ @goauthentik/infrastructure
|
||||
|
||||
271
Cargo.lock
generated
Normal file
271
Cargo.lock
generated
Normal file
@@ -0,0 +1,271 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docsmg"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"colored",
|
||||
"dotenvy",
|
||||
"eyre",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
|
||||
dependencies = [
|
||||
"indenter",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indenter"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
133
Cargo.toml
Normal file
133
Cargo.toml
Normal file
@@ -0,0 +1,133 @@
|
||||
[workspace]
|
||||
members = ["website/scripts/docsmg"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
edition = "2024"
|
||||
readme = "README.md"
|
||||
homepage = "https://goauthentik.io"
|
||||
repository = "https://github.com/goauthentik/authentik.git"
|
||||
license-file = "LICENSE"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5.59", features = ["derive", "env"] }
|
||||
colored = "3.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
eyre = "0.6.12"
|
||||
regex = "1.12.3"
|
||||
|
||||
[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"
|
||||
macro_use_extern_crate = "deny"
|
||||
# must_not_suspend = "deny", unstable see https://github.com/rust-lang/rust/issues/83310
|
||||
non_ascii_idents = "deny"
|
||||
redundant_imports = "warn"
|
||||
semicolon_in_expressions_from_macros = "warn"
|
||||
trivial_casts = "warn"
|
||||
trivial_numeric_casts = "warn"
|
||||
unit_bindings = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unsafe_code = "deny"
|
||||
unused_extern_crates = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
unused_macro_rules = "warn"
|
||||
unused_qualifications = "warn"
|
||||
|
||||
[workspace.lints.rustdoc]
|
||||
unescaped_backticks = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
### enable all lints
|
||||
cargo = { priority = -1, level = "warn" }
|
||||
complexity = { priority = -1, level = "warn" }
|
||||
correctness = { priority = -1, level = "warn" }
|
||||
nursery = { priority = -1, level = "warn" }
|
||||
pedantic = { priority = -1, level = "warn" }
|
||||
perf = { priority = -1, level = "warn" }
|
||||
# Those are too restrictive and disabled by default, however we enable some below
|
||||
# restriction = { priority = -1, level = "warn" }
|
||||
style = { priority = -1, level = "warn" }
|
||||
suspicious = { priority = -1, level = "warn" }
|
||||
### and disable the ones we don't want
|
||||
### pedantic group
|
||||
redundant_closure_for_method_calls = "allow"
|
||||
too_many_lines = "allow"
|
||||
### nursery
|
||||
redundant_pub_crate = "allow"
|
||||
option_if_let_else = "allow"
|
||||
### restriction group
|
||||
allow_attributes = "warn"
|
||||
allow_attributes_without_reason = "warn"
|
||||
as_conversions = "warn"
|
||||
as_pointer_underscore = "warn"
|
||||
as_underscore = "warn"
|
||||
assertions_on_result_states = "warn"
|
||||
clone_on_ref_ptr = "warn"
|
||||
create_dir = "warn"
|
||||
dbg_macro = "warn"
|
||||
default_numeric_fallback = "warn"
|
||||
disallowed_script_idents = "warn"
|
||||
doc_paragraphs_missing_punctuation = "warn"
|
||||
empty_drop = "warn"
|
||||
empty_enum_variants_with_brackets = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
error_impl_error = "warn"
|
||||
exit = "warn"
|
||||
filetype_is_file = "warn"
|
||||
float_cmp_const = "warn"
|
||||
fn_to_numeric_cast_any = "warn"
|
||||
get_unwrap = "warn"
|
||||
if_then_some_else_none = "warn"
|
||||
impl_trait_in_params = "warn"
|
||||
infinite_loop = "warn"
|
||||
lossy_float_literal = "warn"
|
||||
map_with_unused_argument_over_ranges = "warn"
|
||||
mem_forget = "warn"
|
||||
missing_asserts_for_indexing = "warn"
|
||||
missing_trait_methods = "warn"
|
||||
mixed_read_write_in_expression = "warn"
|
||||
mutex_atomic = "warn"
|
||||
mutex_integer = "warn"
|
||||
needless_raw_strings = "warn"
|
||||
non_zero_suggestions = "warn"
|
||||
panic_in_result_fn = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
print_stdout = "warn"
|
||||
rc_buffer = "warn"
|
||||
redundant_test_prefix = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
ref_patterns = "warn"
|
||||
renamed_function_params = "warn"
|
||||
rest_pat_in_fully_bound_structs = "warn"
|
||||
return_and_then = "warn"
|
||||
same_name_method = "warn"
|
||||
semicolon_inside_block = "warn"
|
||||
str_to_string = "warn"
|
||||
string_add = "warn"
|
||||
suspicious_xor_used_as_pow = "warn"
|
||||
tests_outside_test_module = "warn"
|
||||
todo = "warn"
|
||||
try_err = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
unimplemented = "warn"
|
||||
unnecessary_safety_comment = "warn"
|
||||
unnecessary_safety_doc = "warn"
|
||||
unnecessary_self_imports = "warn"
|
||||
unneeded_field_pattern = "warn"
|
||||
unseparated_literal_suffix = "warn"
|
||||
unused_result_ok = "warn"
|
||||
unused_trait_names = "warn"
|
||||
unwrap_in_result = "warn"
|
||||
unwrap_used = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
40
Makefile
40
Makefile
@@ -23,6 +23,7 @@ BREW_LDFLAGS :=
|
||||
BREW_CPPFLAGS :=
|
||||
BREW_PKG_CONFIG_PATH :=
|
||||
|
||||
CARGO := cargo
|
||||
UV := uv
|
||||
|
||||
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
|
||||
@@ -69,22 +70,26 @@ help: ## Show this help
|
||||
sort
|
||||
@echo ""
|
||||
|
||||
go-test:
|
||||
go-test: ## Run the golang tests
|
||||
go test -timeout 0 -v -race -cover ./...
|
||||
|
||||
rust-test: ## Run the Rust tests
|
||||
$(CARGO) nextest run --workspace
|
||||
|
||||
test: ## Run the server tests and produce a coverage report (locally)
|
||||
$(UV) run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
||||
$(UV) run coverage html
|
||||
$(UV) run coverage report
|
||||
|
||||
lint-fix: lint-spellcheck ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||
$(UV) run black $(PY_SOURCES)
|
||||
$(UV) run ruff check --fix $(PY_SOURCES)
|
||||
$(CARGO) +nightly fmt --all -- --config-path .cargo/rustfmt.toml
|
||||
|
||||
lint-spellcheck: ## Reports spelling errors.
|
||||
npm run lint:spellcheck
|
||||
|
||||
lint: ci-bandit ci-mypy ## Lint the python and golang sources
|
||||
lint: ci-lint-bandit ci-lint-mypy ci-lint-cargo-deny ci-lint-cargo-machete ## Lint the python and golang sources
|
||||
golangci-lint run -v
|
||||
|
||||
core-install:
|
||||
@@ -331,27 +336,40 @@ test-docker:
|
||||
# which makes the YAML File a lot smaller
|
||||
|
||||
ci--meta-debug:
|
||||
$(UV) run python -V
|
||||
node --version
|
||||
$(UV) run python -V || echo "No python installed"
|
||||
$(CARGO) --version || echo "No rust installed"
|
||||
node --version || echo "No node installed"
|
||||
|
||||
ci-mypy: ci--meta-debug
|
||||
ci-lint-mypy: ci--meta-debug
|
||||
$(UV) run mypy --strict $(PY_SOURCES)
|
||||
|
||||
ci-black: ci--meta-debug
|
||||
ci-lint-black: ci--meta-debug
|
||||
$(UV) run black --check $(PY_SOURCES)
|
||||
|
||||
ci-ruff: ci--meta-debug
|
||||
ci-lint-ruff: ci--meta-debug
|
||||
$(UV) run ruff check $(PY_SOURCES)
|
||||
|
||||
ci-spellcheck: ci--meta-debug
|
||||
ci-lint-spellcheck: ci--meta-debug
|
||||
npm run lint:spellcheck
|
||||
|
||||
ci-bandit: ci--meta-debug
|
||||
ci-lint-bandit: ci--meta-debug
|
||||
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii
|
||||
|
||||
ci-pending-migrations: ci--meta-debug
|
||||
ci-lint-pending-migrations: ci--meta-debug
|
||||
$(UV) run ak makemigrations --check
|
||||
|
||||
ci-lint-cargo-deny: ci--meta-debug
|
||||
$(CARGO) deny --locked --workspace check --config .cargo/deny.toml
|
||||
|
||||
ci-lint-cargo-machete: ci--meta-debug
|
||||
$(CARGO) machete
|
||||
|
||||
ci-lint-rustfmt: ci--meta-debug
|
||||
$(CARGO) +nightly fmt --all --check -- --config-path .cargo/rustfmt.toml
|
||||
|
||||
ci-lint-clippy: ci--meta-debug
|
||||
$(CARGO) clippy --workspace -- -D warnings
|
||||
|
||||
ci-test: ci--meta-debug
|
||||
$(UV) run coverage run manage.py test --keepdb authentik
|
||||
$(UV) run coverage report
|
||||
|
||||
@@ -25,6 +25,7 @@ from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
@@ -163,6 +164,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
request.user = user
|
||||
for application in paginated_apps:
|
||||
engine = PolicyEngine(application, request.user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
applications.append(application)
|
||||
@@ -220,6 +222,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
if not for_user:
|
||||
raise ValidationError({"for_user": "User not found"})
|
||||
engine = PolicyEngine(application, for_user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.use_cache = False
|
||||
with capture_logs() as logs:
|
||||
engine.build()
|
||||
@@ -239,11 +242,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="superuser_full_list",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="for_user",
|
||||
location=OpenApiParameter.QUERY,
|
||||
@@ -254,18 +252,17 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
),
|
||||
]
|
||||
],
|
||||
responses={
|
||||
200: ApplicationSerializer(many=True),
|
||||
},
|
||||
operation_id="core_applications_accessible_list",
|
||||
)
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Custom list method that checks Policy based access instead of guardian"""
|
||||
@action(methods=["GET"], detail=False, url_path="@accessible")
|
||||
def accessible(self, request: Request) -> Response:
|
||||
"""Get applications accessible for user"""
|
||||
should_cache = request.query_params.get("search", "") == ""
|
||||
|
||||
superuser_full_list = (
|
||||
str(request.query_params.get("superuser_full_list", "false")).lower() == "true"
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"""authentik core app config"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
|
||||
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
|
||||
|
||||
default = True
|
||||
visibility = "none"
|
||||
description = _(
|
||||
"Configure if applications without any policy/group/user bindings "
|
||||
"should be accessible to any user."
|
||||
)
|
||||
|
||||
|
||||
class AuthentikCoreConfig(ManagedAppConfig):
|
||||
|
||||
@@ -1115,7 +1115,11 @@ class ExpiringModel(models.Model):
|
||||
default the object is deleted. This is less efficient compared
|
||||
to bulk deleting objects, but classes like Token() need to change
|
||||
values instead of being deleted."""
|
||||
return self.delete(*args, **kwargs)
|
||||
try:
|
||||
return self.delete(*args, **kwargs)
|
||||
except self.DoesNotExist:
|
||||
# Object has already been deleted, so this should be fine
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet[Self]:
|
||||
|
||||
@@ -24,7 +24,8 @@ from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest,
|
||||
# stage: Stage, context: dict[str, any]
|
||||
login_failed = Signal()
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -80,10 +80,10 @@ class TestApplicationsAPI(APITestCase):
|
||||
self.assertEqual(body["passing"], False)
|
||||
self.assertEqual(body["messages"], ["dummy"])
|
||||
|
||||
def test_list(self):
|
||||
"""Test list operation without superuser_full_list"""
|
||||
def test_list_accessible(self):
|
||||
"""Test list operation without"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("authentik_api:application-list"))
|
||||
response = self.client.get(reverse("authentik_api:application-accessible"))
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
@@ -136,12 +136,10 @@ class TestApplicationsAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_list_superuser_full_list(self):
|
||||
"""Test list operation with superuser_full_list"""
|
||||
def test_list_rbac(self):
|
||||
"""Test list operation"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-list") + "?superuser_full_list=true"
|
||||
)
|
||||
response = self.client.get(reverse("authentik_api:application-list"))
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
|
||||
101
authentik/core/tests/test_interface_views.py
Normal file
101
authentik/core/tests/test_interface_views.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Test interface view redirect behavior by user type"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application, UserTypes
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_user
|
||||
|
||||
|
||||
class TestInterfaceRedirects(TestCase):
|
||||
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
|
||||
|
||||
def setUp(self):
|
||||
self.app = Application.objects.create(name="test-app", slug="test-app")
|
||||
self.brand: Brand = create_test_brand(default_application=self.app)
|
||||
|
||||
def _assert_redirects_to_app(self, url_name: str, user_type: UserTypes):
|
||||
user = create_test_user(type=user_type)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(reverse(f"authentik_core:{url_name}"))
|
||||
self.assertRedirects(
|
||||
response,
|
||||
reverse(
|
||||
"authentik_core:application-launch", kwargs={"application_slug": self.app.slug}
|
||||
),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def _assert_no_redirect(self, url_name: str, user_type: UserTypes):
|
||||
"""Internal users should not be redirected away."""
|
||||
user = create_test_user(type=user_type)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(reverse(f"authentik_core:{url_name}"))
|
||||
# Internal users get a 200 (rendered template) or redirect to if-user, not to the app
|
||||
app_url = reverse(
|
||||
"authentik_core:application-launch", kwargs={"application_slug": self.app.slug}
|
||||
)
|
||||
self.assertNotEqual(response.get("Location"), app_url)
|
||||
|
||||
# --- RootRedirectView ---
|
||||
|
||||
def test_root_redirect_external_user(self):
|
||||
"""External users are redirected to the default app from root"""
|
||||
self._assert_redirects_to_app("root-redirect", UserTypes.EXTERNAL)
|
||||
|
||||
def test_root_redirect_service_account(self):
|
||||
"""Service accounts are redirected to the default app from root"""
|
||||
self._assert_redirects_to_app("root-redirect", UserTypes.SERVICE_ACCOUNT)
|
||||
|
||||
def test_root_redirect_internal_service_account(self):
|
||||
"""Internal service accounts are redirected to the default app from root"""
|
||||
self._assert_redirects_to_app("root-redirect", UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
|
||||
def test_root_redirect_internal_user(self):
|
||||
"""Internal users are NOT redirected to the app from root"""
|
||||
self._assert_no_redirect("root-redirect", UserTypes.INTERNAL)
|
||||
|
||||
# --- BrandDefaultRedirectView (if/user/) ---
|
||||
|
||||
def test_if_user_external_user(self):
|
||||
"""External users are redirected to the default app from if/user/"""
|
||||
self._assert_redirects_to_app("if-user", UserTypes.EXTERNAL)
|
||||
|
||||
def test_if_user_service_account(self):
|
||||
"""Service accounts are redirected to the default app from if/user/"""
|
||||
self._assert_redirects_to_app("if-user", UserTypes.SERVICE_ACCOUNT)
|
||||
|
||||
def test_if_user_internal_service_account(self):
|
||||
"""Internal service accounts are redirected to the default app from if/user/"""
|
||||
self._assert_redirects_to_app("if-user", UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
|
||||
def test_if_user_internal_user(self):
|
||||
"""Internal users are NOT redirected to the app from if/user/"""
|
||||
self._assert_no_redirect("if-user", UserTypes.INTERNAL)
|
||||
|
||||
# --- BrandDefaultRedirectView (if/admin/) ---
|
||||
|
||||
def test_if_admin_service_account(self):
|
||||
"""Service accounts are redirected to the default app from if/admin/"""
|
||||
self._assert_redirects_to_app("if-admin", UserTypes.SERVICE_ACCOUNT)
|
||||
|
||||
def test_if_admin_internal_service_account(self):
|
||||
"""Internal service accounts are redirected to the default app from if/admin/"""
|
||||
self._assert_redirects_to_app("if-admin", UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
|
||||
def test_if_admin_internal_user(self):
|
||||
"""Internal users are NOT redirected to the app from if/admin/"""
|
||||
self._assert_no_redirect("if-admin", UserTypes.INTERNAL)
|
||||
|
||||
# --- No default app set ---
|
||||
|
||||
def test_service_account_no_default_app_access_denied(self):
|
||||
"""Service accounts get access denied when no default app is configured"""
|
||||
self.brand.default_application = None
|
||||
self.brand.save()
|
||||
user = create_test_user(type=UserTypes.SERVICE_ACCOUNT)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(reverse("authentik_core:if-user"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"Interface can only be accessed by internal users", response.content)
|
||||
@@ -26,7 +26,11 @@ class RootRedirectView(RedirectView):
|
||||
query_string = True
|
||||
|
||||
def redirect_to_app(self, request: HttpRequest):
|
||||
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
|
||||
if request.user.is_authenticated and request.user.type in (
|
||||
UserTypes.EXTERNAL,
|
||||
UserTypes.SERVICE_ACCOUNT,
|
||||
UserTypes.INTERNAL_SERVICE_ACCOUNT,
|
||||
):
|
||||
brand: Brand = request.brand
|
||||
if brand.default_application:
|
||||
return redirect(
|
||||
@@ -62,7 +66,11 @@ class BrandDefaultRedirectView(InterfaceView):
|
||||
"""By default redirect to default app"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
|
||||
if request.user.is_authenticated and request.user.type in (
|
||||
UserTypes.EXTERNAL,
|
||||
UserTypes.SERVICE_ACCOUNT,
|
||||
UserTypes.INTERNAL_SERVICE_ACCOUNT,
|
||||
):
|
||||
brand: Brand = request.brand
|
||||
if brand.default_application:
|
||||
return redirect(
|
||||
|
||||
@@ -44,3 +44,6 @@ class BaseController[T: "Connector"]:
|
||||
|
||||
def stage_view_authentication(self) -> StageView | None:
|
||||
return None
|
||||
|
||||
def sync_endpoints(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -162,8 +162,11 @@ class Connector(ScheduledModel, SerializerModel):
|
||||
|
||||
@property
|
||||
def schedule_specs(self) -> list[ScheduleSpec]:
|
||||
from authentik.endpoints.controller import Capabilities
|
||||
from authentik.endpoints.tasks import endpoints_sync
|
||||
|
||||
if Capabilities.ENROLL_AUTOMATIC_API not in self.controller(self).capabilities():
|
||||
return []
|
||||
return [
|
||||
ScheduleSpec(
|
||||
actor=endpoints_sync,
|
||||
|
||||
@@ -21,7 +21,7 @@ def endpoints_sync(connector_pk: Any):
|
||||
return
|
||||
controller = connector.controller
|
||||
ctrl = controller(connector)
|
||||
if Capabilities.AUTOMATIC_API not in ctrl.capabilities():
|
||||
if Capabilities.ENROLL_AUTOMATIC_API not in ctrl.capabilities():
|
||||
return
|
||||
LOGGER.info("Syncing connector", connector=connector.name)
|
||||
ctrl.sync_endpoints()
|
||||
|
||||
35
authentik/endpoints/tests/test_tasks.py
Normal file
35
authentik/endpoints/tests/test_tasks.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.endpoints.controller import BaseController, Capabilities
|
||||
from authentik.endpoints.models import Connector
|
||||
from authentik.endpoints.tasks import endpoints_sync
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestEndpointTasks(APITestCase):
|
||||
def test_agent_sync(self):
|
||||
class controller(BaseController):
|
||||
def capabilities(self):
|
||||
return [Capabilities.ENROLL_AUTOMATIC_API]
|
||||
|
||||
def sync_endpoints(self):
|
||||
pass
|
||||
|
||||
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
|
||||
connector = Connector.objects.create(name=generate_id())
|
||||
self.assertEqual(len(connector.schedule_specs), 1)
|
||||
|
||||
endpoints_sync.send(connector.pk).get_result(block=True)
|
||||
|
||||
def test_agent_no_sync(self):
|
||||
class controller(BaseController):
|
||||
def capabilities(self):
|
||||
return []
|
||||
|
||||
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
|
||||
connector = Connector.objects.create(name=generate_id())
|
||||
self.assertEqual(len(connector.schedule_specs), 0)
|
||||
|
||||
endpoints_sync.send(connector.pk).get_result(block=True)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Enterprise app config"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
from authentik.tenants.flags import Flag
|
||||
@@ -9,6 +10,9 @@ from authentik.tenants.flags import Flag
|
||||
class AuditIncludeExpandedDiff(Flag[bool], key="enterprise_audit_include_expanded_diff"):
|
||||
default = False
|
||||
visibility = "none"
|
||||
description = _(
|
||||
"Include additional information in audit logs, may incur a performance penalty."
|
||||
)
|
||||
|
||||
|
||||
class AuthentikEnterpriseAuditConfig(EnterpriseConfig):
|
||||
|
||||
@@ -3,6 +3,7 @@ from hmac import compare_digest
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
|
||||
|
||||
from authentik.common.oauth.constants import QS_LOGIN_HINT
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
@@ -14,7 +15,7 @@ from authentik.enterprise.policy import EnterprisePolicyAccessView
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
||||
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
||||
|
||||
QS_AGENT_IA_TOKEN = "ak-auth-ia-token" # nosec
|
||||
@@ -64,14 +65,14 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
|
||||
|
||||
planner = FlowPlanner(self.connector.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
context = {
|
||||
PLAN_CONTEXT_DEVICE: self.device,
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
|
||||
}
|
||||
if QS_LOGIN_HINT in request.GET:
|
||||
context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = request.GET[QS_LOGIN_HINT]
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_DEVICE: self.device,
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN: self.auth_token,
|
||||
},
|
||||
)
|
||||
plan = planner.plan(self.request, context)
|
||||
except FlowNonApplicableException:
|
||||
return self.handle_no_permission_authenticated()
|
||||
plan.append_stage(in_memory_stage(AgentAuthFulfillmentStage))
|
||||
@@ -84,7 +85,6 @@ class AgentInteractiveAuth(EnterprisePolicyAccessView):
|
||||
|
||||
|
||||
class AgentAuthFulfillmentStage(StageView):
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
device: Device = self.executor.plan.context.pop(PLAN_CONTEXT_DEVICE)
|
||||
auth_token: DeviceAuthenticationToken = self.executor.plan.context.pop(
|
||||
|
||||
@@ -8,6 +8,7 @@ from dramatiq.actor import actor
|
||||
from requests.exceptions import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.providers.ssf.models import (
|
||||
DeliveryMethods,
|
||||
@@ -68,6 +69,7 @@ def _check_app_access(stream: Stream, event_data: dict) -> bool:
|
||||
if not user:
|
||||
return True
|
||||
engine = PolicyEngine(stream.provider.backchannel_application, user)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.use_cache = False
|
||||
engine.build()
|
||||
return engine.passing
|
||||
|
||||
@@ -63,6 +63,7 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"mode",
|
||||
"mode_verbose",
|
||||
"webhook_url",
|
||||
"webhook_ca",
|
||||
"webhook_mapping_body",
|
||||
"webhook_mapping_headers",
|
||||
"email_subject_prefix",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-10 10:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0006_certificatekeypair_cert_expiry_and_more"),
|
||||
("authentik_events", "0016_alter_event_action"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="webhook_ca",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="When set, the selected ceritifcate is used to validate the certificate of the webhook server.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -28,6 +28,7 @@ from authentik.core.middleware import (
|
||||
SESSION_KEY_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.context_processors.base import get_context_processors
|
||||
from authentik.events.utils import (
|
||||
cleanse_dict,
|
||||
@@ -41,6 +42,7 @@ from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.root.ws.consumer import build_user_group
|
||||
@@ -326,6 +328,16 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
|
||||
|
||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||
webhook_ca = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
help_text=_(
|
||||
"When set, the selected ceritifcate is used to "
|
||||
"validate the certificate of the webhook server."
|
||||
),
|
||||
)
|
||||
webhook_mapping_body = models.ForeignKey(
|
||||
"NotificationWebhookMapping",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
@@ -409,21 +421,29 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
notification=notification,
|
||||
)
|
||||
)
|
||||
try:
|
||||
response = get_http_session().post(
|
||||
self.webhook_url,
|
||||
json=default_body,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(
|
||||
exc.response.text if exc.response else str(exc)
|
||||
) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
def send(**kwargs):
|
||||
try:
|
||||
response = get_http_session().post(
|
||||
self.webhook_url,
|
||||
json=default_body,
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(
|
||||
exc.response.text if exc.response else str(exc)
|
||||
) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
if self.webhook_ca:
|
||||
with DockerInlineTLS(self.webhook_ca, authentication_kp=None) as tls:
|
||||
return send(verify=tls.ca_cert)
|
||||
return send()
|
||||
|
||||
def send_webhook_slack(self, notification: Notification) -> list[str]:
|
||||
"""Send notification to slack or slack-compatible endpoints"""
|
||||
|
||||
@@ -93,11 +93,13 @@ def on_login_failed(
|
||||
credentials: dict[str, str],
|
||||
request: HttpRequest,
|
||||
stage: Stage | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Failed Login, authentik custom event"""
|
||||
user = User.objects.filter(username=credentials.get("username")).first()
|
||||
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(
|
||||
context = context or {}
|
||||
Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **context).from_http(
|
||||
request, user
|
||||
)
|
||||
|
||||
|
||||
@@ -207,3 +207,9 @@ class TestEvents(TestCase):
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""Test creating an event with invalid unicode string data"""
|
||||
event = Event.new("unittest", foo="foo bar \u0000 baz")
|
||||
event.save()
|
||||
self.assertEqual(event.context["foo"], "foo bar baz")
|
||||
|
||||
@@ -10,6 +10,7 @@ from requests_mock import Mocker
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.api.notification_transports import NotificationTransportSerializer
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
@@ -61,6 +62,37 @@ class TestEventTransports(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_transport_webhook_ca_invalid_unset(self):
|
||||
"""Test webhook transport"""
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="https://localhost:1234/test",
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
mocker.post("https://localhost:1234/test")
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(mocker.call_count, 1)
|
||||
self.assertTrue(mocker.request_history[0].verify)
|
||||
|
||||
def test_transport_webhook_ca(self):
|
||||
"""Test webhook transport"""
|
||||
kp = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data="foo",
|
||||
)
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.WEBHOOK,
|
||||
webhook_url="https://localhost:1234/test",
|
||||
webhook_ca=kp,
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
mocker.post("https://localhost:1234/test")
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(mocker.call_count, 1)
|
||||
self.assertIsNotNone(mocker.request_history[0].verify)
|
||||
|
||||
def test_transport_webhook_mapping(self):
|
||||
"""Test webhook transport with custom mapping"""
|
||||
mapping_body = NotificationWebhookMapping.objects.create(
|
||||
|
||||
@@ -36,6 +36,10 @@ ALLOWED_SPECIAL_KEYS = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def cleanse_str(raw: Any) -> str:
|
||||
return str(raw).replace("\u0000", "")
|
||||
|
||||
|
||||
def cleanse_item(key: str, value: Any) -> Any:
|
||||
"""Cleanse a single item"""
|
||||
if isinstance(value, dict):
|
||||
@@ -66,7 +70,7 @@ def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
|
||||
def model_to_dict(model: Model) -> dict[str, Any]:
|
||||
"""Convert model to dict"""
|
||||
name = str(model)
|
||||
name = cleanse_str(model)
|
||||
if hasattr(model, "name"):
|
||||
name = model.name
|
||||
return {
|
||||
@@ -133,11 +137,11 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
if isinstance(value, ASN):
|
||||
return ASN_CONTEXT_PROCESSOR.asn_to_dict(value)
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, Exception):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, YAMLTag):
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, type):
|
||||
@@ -161,7 +165,7 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
raise ValueError("JSON can't represent timezone-aware times.")
|
||||
return value.isoformat()
|
||||
if isinstance(value, timedelta):
|
||||
return str(value.total_seconds())
|
||||
return cleanse_str(value.total_seconds())
|
||||
if callable(value):
|
||||
return {
|
||||
"type": "callable",
|
||||
@@ -174,8 +178,8 @@ def sanitize_item(value: Any) -> Any: # noqa: PLR0911, PLR0912
|
||||
try:
|
||||
return DjangoJSONEncoder().default(value)
|
||||
except TypeError:
|
||||
return str(value)
|
||||
return str(value)
|
||||
return cleanse_str(value)
|
||||
return cleanse_str(value)
|
||||
|
||||
|
||||
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""authentik flows app config"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from prometheus_client import Gauge, Histogram
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
@@ -27,12 +28,14 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Refresh other tabs after successful authentication.")
|
||||
|
||||
|
||||
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Upon successful authentication, re-start authentication in other open tabs.")
|
||||
|
||||
|
||||
class AuthentikFlowsConfig(ManagedAppConfig):
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
"layout": "{{ flow.layout }}",
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/static-%v.css' %}" />
|
||||
{% block interface_stylesheet %}
|
||||
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/flow-%v.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
|
||||
@@ -423,4 +423,5 @@ if __name__ == "__main__":
|
||||
if len(argv) < 2: # noqa: PLR2004
|
||||
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))
|
||||
else:
|
||||
print(CONFIG.get(argv[-1]))
|
||||
for arg in argv[1:]:
|
||||
print(CONFIG.get(arg))
|
||||
|
||||
@@ -22,6 +22,7 @@ postgresql:
|
||||
port: 5432
|
||||
password: "env://POSTGRES_PASSWORD"
|
||||
sslmode: disable
|
||||
conn_health_checks: false
|
||||
use_pool: False
|
||||
test:
|
||||
name: test_authentik
|
||||
@@ -32,12 +33,18 @@ postgresql:
|
||||
# host: replica1.example.com
|
||||
|
||||
listen:
|
||||
http: 0.0.0.0:9000
|
||||
https: 0.0.0.0:9443
|
||||
ldap: 0.0.0.0:3389
|
||||
ldaps: 0.0.0.0:6636
|
||||
radius: 0.0.0.0:1812
|
||||
metrics: 0.0.0.0:9300
|
||||
http:
|
||||
- "[::]:9000"
|
||||
https:
|
||||
- "[::]:9443"
|
||||
ldap:
|
||||
- "[::]:3389"
|
||||
ldaps:
|
||||
- "[::]:6636"
|
||||
radius:
|
||||
- "[::]:1812"
|
||||
metrics:
|
||||
- "[::]:9300"
|
||||
debug: 0.0.0.0:9900
|
||||
debug_py: 0.0.0.0:9901
|
||||
trusted_proxy_cidrs:
|
||||
@@ -137,8 +144,7 @@ tenants:
|
||||
blueprints_dir: /blueprints
|
||||
|
||||
web:
|
||||
# No default here as it's set dynamically
|
||||
# workers: 2
|
||||
workers: 2
|
||||
threads: 4
|
||||
path: /
|
||||
timeout_http_read_header: 5s
|
||||
@@ -183,3 +189,5 @@ storage:
|
||||
# backend: file # or s3
|
||||
# file: {}
|
||||
# s3: {}
|
||||
|
||||
skip_migrations: false
|
||||
|
||||
@@ -27,6 +27,12 @@ class DockerInlineTLS:
|
||||
self.authentication_kp = authentication_kp
|
||||
self._paths = []
|
||||
|
||||
def __enter__(self):
|
||||
return self.write()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.cleanup()
|
||||
|
||||
def write_file(self, name: str, contents: str) -> str:
|
||||
"""Wrapper for mkstemp that uses fdopen"""
|
||||
path = Path(gettempdir(), name)
|
||||
|
||||
@@ -163,4 +163,5 @@ def outpost_pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def outpost_logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
outpost_session_end.send(instance.session.session_key)
|
||||
if Outpost.objects.exists():
|
||||
outpost_session_end.send(instance.session.session_key)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Authentik policies app config
|
||||
"""authentik policies app config
|
||||
|
||||
Every system policy should be its own Django app under the `policies` app.
|
||||
For example: The 'dummy' policy is available at `authentik.policies.dummy`.
|
||||
@@ -7,7 +7,6 @@ For example: The 'dummy' policy is available at `authentik.policies.dummy`.
|
||||
from prometheus_client import Gauge, Histogram
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
GAUGE_POLICIES_CACHED = Gauge(
|
||||
"authentik_policies_cached",
|
||||
@@ -32,12 +31,6 @@ HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
)
|
||||
|
||||
|
||||
class BufferedPolicyAccessViewFlag(Flag[bool], key="policies_buffered_access_view"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
|
||||
|
||||
class AuthentikPoliciesConfig(ManagedAppConfig):
|
||||
"""authentik policies app config"""
|
||||
|
||||
@@ -45,4 +38,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
|
||||
label = "authentik_policies"
|
||||
verbose_name = "authentik Policies"
|
||||
default = True
|
||||
mountpoint = "policy/"
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
{% extends 'login/base_full.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
let redirecting = false;
|
||||
|
||||
async function checkAuth() {
|
||||
if (redirecting) {
|
||||
console.debug(
|
||||
"authentik/policies/buffer: Already authenticating in another tab. This page will refresh once authentication is completed.",
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = "{{ check_auth_url }}";
|
||||
console.debug("authentik/policies/buffer: Checking authentication...");
|
||||
|
||||
return fetch(url, {
|
||||
method: "HEAD",
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status >= 400) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug("authentik/policies/buffer: Continuing");
|
||||
|
||||
if ("{{ auth_req_method }}" === "post") {
|
||||
document.querySelector("form")?.submit();
|
||||
return true;
|
||||
}
|
||||
|
||||
window.location.assign("{{ continue_url|escapejs }}");
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("authentik/policies/buffer: Error checking authentication.", error);
|
||||
return false;
|
||||
})
|
||||
}
|
||||
|
||||
const offset = 20;
|
||||
|
||||
let timeoutID = -1;
|
||||
let timeout = 100;
|
||||
let attempts = 0;
|
||||
|
||||
async function main() {
|
||||
window.clearTimeout(timeoutID);
|
||||
|
||||
attempts += 1;
|
||||
|
||||
redirecting = await checkAuth();
|
||||
|
||||
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
|
||||
|
||||
timeoutID = window.setTimeout(main, timeout);
|
||||
|
||||
timeout += offset * attempts;
|
||||
|
||||
if (timeout >= 2000) {
|
||||
timeout = 2000;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.hidden) return;
|
||||
|
||||
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
|
||||
|
||||
redirecting = await checkAuth();
|
||||
});
|
||||
|
||||
main();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Waiting for authentication...' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
|
||||
{% if auth_req_method == "post" %}
|
||||
{% for key, value in auth_req_body.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="pf-c-empty-state">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<div class="pf-c-empty-state__icon">
|
||||
<span class="pf-c-spinner pf-m-xl" role="progressbar">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
|
||||
{% trans "Authenticate in this tab" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,29 +1,19 @@
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, Provider
|
||||
from authentik.core.tests.utils import (
|
||||
RequestFactory,
|
||||
create_test_brand,
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.flows.models import Flow, FlowDesignation
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.apps import BufferedPolicyAccessViewFlag
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.policies.views import (
|
||||
QS_BUFFER_ID,
|
||||
SESSION_KEY_BUFFER,
|
||||
BufferedPolicyAccessView,
|
||||
BufferView,
|
||||
PolicyAccessView,
|
||||
)
|
||||
from authentik.tenants.flags import patch_flag
|
||||
|
||||
|
||||
class TestPolicyViews(TestCase):
|
||||
@@ -124,71 +114,3 @@ class TestPolicyViews(TestCase):
|
||||
res = TestView.as_view()(req)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
self.assertEqual(res.url, "/if/flow/default-authentication-flow/?next=%2F")
|
||||
|
||||
@patch_flag(BufferedPolicyAccessViewFlag, True)
|
||||
def test_pav_buffer(self):
|
||||
"""Test simple policy access view"""
|
||||
provider = Provider.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
|
||||
class TestView(BufferedPolicyAccessView):
|
||||
def resolve_provider_application(self):
|
||||
self.provider = provider
|
||||
self.application = app
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return HttpResponse("foo")
|
||||
|
||||
req = self.factory.get("/")
|
||||
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||
req.session.save()
|
||||
res = TestView.as_view()(req)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
|
||||
|
||||
@patch_flag(BufferedPolicyAccessViewFlag, True)
|
||||
@apply_blueprint("default/flow-default-authentication-flow.yaml")
|
||||
def test_pav_buffer_skip(self):
|
||||
"""Test simple policy access view (skip buffer)"""
|
||||
provider = Provider.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
flow = Flow.objects.get(slug="default-authentication-flow")
|
||||
|
||||
class TestView(BufferedPolicyAccessView):
|
||||
def resolve_provider_application(self):
|
||||
self.provider = provider
|
||||
self.application = app
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return HttpResponse("foo")
|
||||
|
||||
req = self.factory.get("/?skip_buffer=true")
|
||||
req.brand = create_test_brand(flow_authentication=flow)
|
||||
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||
req.session.save()
|
||||
res = TestView.as_view()(req)
|
||||
self.assertEqual(res.status_code, 302)
|
||||
self.assertTrue(
|
||||
res.url.startswith(reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}))
|
||||
)
|
||||
|
||||
def test_buffer(self):
|
||||
"""Test buffer view"""
|
||||
uid = generate_id()
|
||||
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
|
||||
ts = generate_id()
|
||||
req.session[SESSION_KEY_BUFFER % uid] = {
|
||||
"method": "get",
|
||||
"body": {},
|
||||
"url": f"/{ts}",
|
||||
}
|
||||
req.session.save()
|
||||
|
||||
res = BufferView.as_view()(req)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(ts, res.render().content.decode())
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
"""API URLs"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from authentik.policies.api.bindings import PolicyBindingViewSet
|
||||
from authentik.policies.api.policies import PolicyViewSet
|
||||
from authentik.policies.views import BufferView
|
||||
|
||||
urlpatterns = [
|
||||
path("buffer", BufferView.as_view(), name="buffer"),
|
||||
]
|
||||
|
||||
api_urlpatterns = [
|
||||
("policies/all", PolicyViewSet),
|
||||
|
||||
@@ -1,43 +1,34 @@
|
||||
"""authentik access helper classes"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.http import Http404, HttpRequest, HttpResponse, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.base import TemplateView, View
|
||||
from django.views.generic.base import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import Application, Provider, User
|
||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from authentik.flows.models import Flow, FlowDesignation
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_POST,
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.views.executor import (
|
||||
SESSION_KEY_PLAN,
|
||||
SESSION_KEY_POST,
|
||||
ToDefaultFlow,
|
||||
)
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.policies.apps import BufferedPolicyAccessViewFlag
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
QS_BUFFER_ID = "af_bf_id"
|
||||
QS_SKIP_BUFFER = "skip_buffer"
|
||||
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
|
||||
|
||||
|
||||
class RequestValidationError(SentryIgnoredException):
|
||||
@@ -51,12 +42,6 @@ class RequestValidationError(SentryIgnoredException):
|
||||
self.response = response
|
||||
|
||||
|
||||
class BaseMixin:
|
||||
"""Base Mixin class, used to annotate View Member variables"""
|
||||
|
||||
request: HttpRequest
|
||||
|
||||
|
||||
class PolicyAccessView(AccessMixin, View):
|
||||
"""Mixin class for usage in Authorization views.
|
||||
Provider functions to check application access, etc"""
|
||||
@@ -151,6 +136,7 @@ class PolicyAccessView(AccessMixin, View):
|
||||
policy_engine = PolicyEngine(
|
||||
pbm or self.application, user or self.request.user, self.request
|
||||
)
|
||||
policy_engine.empty_result = AppAccessWithoutBindings.get()
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.request = self.modify_policy_request(policy_engine.request)
|
||||
policy_engine.build()
|
||||
@@ -167,66 +153,3 @@ class PolicyAccessView(AccessMixin, View):
|
||||
for message in result.messages:
|
||||
messages.error(self.request, _(message))
|
||||
return result
|
||||
|
||||
|
||||
def url_with_qs(url: str, **kwargs):
|
||||
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
|
||||
parameters are retained"""
|
||||
if "?" not in url:
|
||||
return url + f"?{urlencode(kwargs)}"
|
||||
url, _, qs = url.partition("?")
|
||||
qs = QueryDict(qs, mutable=True)
|
||||
qs.update(kwargs)
|
||||
return url + f"?{urlencode(qs.items())}"
|
||||
|
||||
|
||||
class BufferView(TemplateView):
|
||||
"""Buffer view"""
|
||||
|
||||
template_name = "policies/buffer.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
buf_id = self.request.GET.get(QS_BUFFER_ID)
|
||||
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
|
||||
kwargs["auth_req_method"] = buffer["method"]
|
||||
kwargs["auth_req_body"] = buffer["body"]
|
||||
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
|
||||
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
|
||||
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class BufferedPolicyAccessView(PolicyAccessView):
|
||||
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
|
||||
|
||||
def handle_no_permission(self):
|
||||
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
|
||||
if plan:
|
||||
flow = Flow.objects.filter(pk=plan.flow_pk).first()
|
||||
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
|
||||
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
|
||||
return super().handle_no_permission()
|
||||
if not plan:
|
||||
LOGGER.debug("Not buffering request, no flow plan active")
|
||||
return super().handle_no_permission()
|
||||
if not BufferedPolicyAccessViewFlag.get():
|
||||
return super().handle_no_permission()
|
||||
if self.request.GET.get(QS_SKIP_BUFFER):
|
||||
LOGGER.debug("Not buffering request, explicit skip")
|
||||
return super().handle_no_permission()
|
||||
buffer_id = str(uuid4())
|
||||
LOGGER.debug("Buffering access request", bf_id=buffer_id)
|
||||
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
|
||||
"body": self.request.POST,
|
||||
"url": self.request.build_absolute_uri(self.request.get_full_path()),
|
||||
"method": self.request.method.lower(),
|
||||
}
|
||||
return redirect(
|
||||
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
|
||||
)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
if QS_BUFFER_ID in self.request.GET:
|
||||
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
|
||||
return response
|
||||
|
||||
@@ -17,6 +17,7 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import Application
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
@@ -153,6 +154,7 @@ class LDAPOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
provider = get_object_or_404(LDAPProvider, pk=pk)
|
||||
application = get_object_or_404(Application, slug=request.query_params["app_slug"])
|
||||
engine = PolicyEngine(application, request.user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.use_cache = False
|
||||
engine.build()
|
||||
result = engine.result
|
||||
|
||||
@@ -15,7 +15,7 @@ from authentik.common.oauth.constants import (
|
||||
SCOPE_OPENID_PROFILE,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.models import USERNAME_MAX_LENGTH, Application, Group, User
|
||||
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
|
||||
@@ -27,7 +27,7 @@ from authentik.providers.oauth2.models import (
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.models import OAuthSource, OAuthSourcePropertyMapping
|
||||
|
||||
|
||||
class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
@@ -220,6 +220,10 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.filter(username=f"{self.provider.name}-foo").first()
|
||||
self.assertIsNotNone(user)
|
||||
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
_, alg = self.provider.jwt_key
|
||||
@@ -233,3 +237,54 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials JWT)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "test-foo")
|
||||
|
||||
def test_successful_mapping(self):
|
||||
"""test successful"""
|
||||
test_username = ("mapped-foo" + ("a" * 150))[:USERNAME_MAX_LENGTH]
|
||||
mapping = OAuthSourcePropertyMapping.objects.create(
|
||||
name="test-mapping",
|
||||
expression="""return {
|
||||
"email": oauth_userinfo.get("email"),
|
||||
"name": oauth_userinfo.get("name"),
|
||||
"username": oauth_userinfo.get("username"),
|
||||
}""",
|
||||
)
|
||||
self.source.user_property_mappings.add(mapping)
|
||||
|
||||
token = self.helper_provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"email": "test-user@example.com",
|
||||
"name": "Mapped Test User",
|
||||
"username": "mapped-foo" + ("a" * 150),
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
user = User.objects.filter(username=test_username).first()
|
||||
self.assertIsNotNone(user)
|
||||
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
key_obj, alg = self.provider.jwt_key
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=key_obj.public_key(),
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
|
||||
self.assertEqual(jwt["email"], "test-user@example.com")
|
||||
self.assertEqual(jwt["given_name"], "Mapped Test User")
|
||||
self.assertEqual(jwt["preferred_username"], test_username)
|
||||
|
||||
@@ -45,7 +45,7 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
from authentik.providers.oauth2.errors import (
|
||||
AuthorizeError,
|
||||
ClientIdError,
|
||||
@@ -338,7 +338,7 @@ class OAuthAuthorizationParams:
|
||||
return code
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(BufferedPolicyAccessView):
|
||||
class AuthorizationFlowInitView(PolicyAccessView):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
|
||||
@@ -33,6 +33,7 @@ from authentik.common.oauth.constants import (
|
||||
SCOPE_OFFLINE_ACCESS,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
@@ -45,6 +46,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.core.sources.mapper import SourceMapper
|
||||
from authentik.events.middleware import audit_ignore
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import get_login_event
|
||||
@@ -146,6 +148,7 @@ class TokenParams:
|
||||
):
|
||||
user = self.user if self.user else get_anonymous_user()
|
||||
engine = PolicyEngine(app, user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
# Don't cache as for client_credentials flows the user will not be set
|
||||
# so we'll get generic cache results
|
||||
engine.use_cache = False
|
||||
@@ -476,7 +479,7 @@ class TokenParams:
|
||||
|
||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||
if not provider:
|
||||
self.__create_user_from_jwt(token, app, source)
|
||||
self.__create_user_from_jwt(token, app, source, request)
|
||||
|
||||
method_args = {
|
||||
"jwt": token,
|
||||
@@ -530,18 +533,30 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
self.device_code = code
|
||||
|
||||
def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource):
|
||||
def __create_user_from_jwt(
|
||||
self, token: dict[str, Any], app: Application, source: OAuthSource, request: HttpRequest
|
||||
):
|
||||
"""Create user from JWT"""
|
||||
with audit_ignore():
|
||||
# Run the JWT payload through the core mapping engine
|
||||
mapped = SourceMapper(source).build_object_properties(
|
||||
User, request=request, info=token, oauth_userinfo=token
|
||||
)
|
||||
|
||||
self.user, created = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
username=mapped.get("username", f"{self.provider.name}-{token.get('sub')}")[
|
||||
:USERNAME_MAX_LENGTH
|
||||
],
|
||||
defaults={
|
||||
"last_login": timezone.now(),
|
||||
"name": (
|
||||
f"Autogenerated user from application {app.name} (client credentials JWT)"
|
||||
"name": mapped.get(
|
||||
"name",
|
||||
f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
),
|
||||
"email": mapped.get("email", ""),
|
||||
"path": source.get_user_path(),
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
"attributes": mapped.get("attributes", {}),
|
||||
},
|
||||
)
|
||||
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Proxy provider signals"""
|
||||
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
from authentik.providers.proxy.tasks import proxy_on_logout
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
proxy_on_logout.send(instance.session.session_key)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""proxy provider tasks"""
|
||||
|
||||
from channels.layers import get_channel_layer
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dramatiq.actor import actor
|
||||
|
||||
from authentik.outposts.consumer import build_outpost_group
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
|
||||
|
||||
@actor(description=_("Terminate session on Proxy outpost."))
|
||||
def proxy_on_logout(session_id: str):
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||
group = build_outpost_group(outpost.pk)
|
||||
layer.group_send_blocking(
|
||||
group,
|
||||
{
|
||||
"type": "event.provider.specific",
|
||||
"sub_type": "logout",
|
||||
"session_id": hashed_session_id,
|
||||
},
|
||||
)
|
||||
@@ -18,14 +18,14 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import RedirectStage
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.views import BufferedPolicyAccessView
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONNECTION_SETTINGS = "connection_settings"
|
||||
|
||||
|
||||
class RACStartView(BufferedPolicyAccessView):
|
||||
class RACStartView(PolicyAccessView):
|
||||
"""Start a RAC connection by checking access and creating a connection token"""
|
||||
|
||||
endpoint: Endpoint
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
@@ -169,6 +170,7 @@ class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet):
|
||||
provider = get_object_or_404(RadiusProvider, pk=pk)
|
||||
application = get_object_or_404(Application, slug=request.query_params["app_slug"])
|
||||
engine = PolicyEngine(application, request.user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.use_cache = False
|
||||
engine.build()
|
||||
result = engine.result
|
||||
|
||||
@@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import BufferedPolicyAccessView
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||
@@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SAMLSSOView(BufferedPolicyAccessView):
|
||||
class SAMLSSOView(PolicyAccessView):
|
||||
"""SAML SSO Base View, which plans a flow and injects our final stage.
|
||||
Calls get/post handler."""
|
||||
|
||||
@@ -88,7 +88,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
|
||||
|
||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""GET and POST use the same handler, but we can't
|
||||
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
|
||||
override .dispatch easily because PolicyAccessView's dispatch"""
|
||||
return self.get(request, application_slug)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.db import transaction
|
||||
from django.utils.http import urlencode
|
||||
from orjson import dumps
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
@@ -25,6 +24,7 @@ from authentik.providers.scim.clients.exceptions import (
|
||||
)
|
||||
from authentik.providers.scim.clients.schema import (
|
||||
SCIM_GROUP_SCHEMA,
|
||||
GroupMember,
|
||||
PatchOp,
|
||||
PatchOperation,
|
||||
PatchRequest,
|
||||
@@ -111,7 +111,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
raise exc
|
||||
groups = self._request(
|
||||
"GET",
|
||||
f"/Groups?{urlencode({'filter': f'displayName eq \"{group.name}\"'})}",
|
||||
f"/Groups?{urlencode({'filter': f'displayName eq "{group.name}"'})}",
|
||||
)
|
||||
groups_res = groups.get("Resources", [])
|
||||
if len(groups_res) < 1:
|
||||
@@ -321,7 +321,12 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
value=[
|
||||
GroupMember(value=x).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
for x in users_to_add
|
||||
],
|
||||
@@ -329,7 +334,12 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
value=[
|
||||
GroupMember(value=x).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
for x in users_to_remove
|
||||
],
|
||||
@@ -352,7 +362,12 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
value=[
|
||||
GroupMember(value=x).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
for x in user_ids
|
||||
],
|
||||
@@ -375,7 +390,12 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x}],
|
||||
value=[
|
||||
GroupMember(value=x).model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
for x in user_ids
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from pydantic import AnyUrl, BaseModel, ConfigDict, Field, model_validator
|
||||
from pydanticscim.group import Group as BaseGroup
|
||||
from pydanticscim.group import GroupMember as BaseGroupMember
|
||||
from pydanticscim.responses import PatchOperation as BasePatchOperation
|
||||
from pydanticscim.responses import PatchRequest as BasePatchRequest
|
||||
from pydanticscim.responses import SCIMError as BaseSCIMError
|
||||
@@ -160,6 +161,13 @@ class Group(BaseGroup):
|
||||
schemas: list[str] = [SCIM_GROUP_SCHEMA]
|
||||
externalId: str | None = None
|
||||
meta: dict | None = None
|
||||
members: list[GroupMember] | None = Field(None, description="A list of members of the Group.")
|
||||
|
||||
|
||||
class GroupMember(BaseGroupMember):
|
||||
"""Modified GroupMember that allows extra fields"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class Bulk(BaseBulk):
|
||||
|
||||
@@ -12,6 +12,7 @@ from requests.auth import AuthBase
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
@@ -193,13 +194,14 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
# Filter users by their access to the backchannel application if an application is set
|
||||
# This handles both policy bindings and group_filters
|
||||
if self.backchannel_application:
|
||||
base = base.filter(
|
||||
pk__in=[
|
||||
user.pk
|
||||
for user in base
|
||||
if PolicyEngine(self.backchannel_application, user, None).build().passing
|
||||
]
|
||||
)
|
||||
pks = []
|
||||
for user in base:
|
||||
engine = PolicyEngine(self.backchannel_application, user, None)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
pks.append(user.pk)
|
||||
base = base.filter(pk__in=pks)
|
||||
return base.order_by("pk")
|
||||
|
||||
if type == Group:
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
"""Metrics view"""
|
||||
|
||||
from hmac import compare_digest
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
from django.db.utils import OperationalError
|
||||
from django.dispatch import Signal
|
||||
@@ -18,18 +13,8 @@ monitoring_set = Signal()
|
||||
class MetricsView(View):
|
||||
"""Wrapper around ExportToDjangoView with authentication, accessed by the authentik router"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
_tmp = Path(gettempdir())
|
||||
with open(_tmp / "authentik-core-metrics.key") as _f:
|
||||
self.monitoring_key = _f.read()
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Check for HTTP-Basic auth"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
auth_type, _, given_credentials = auth_header.partition(" ")
|
||||
authed = auth_type == "Bearer" and compare_digest(given_credentials, self.monitoring_key)
|
||||
if not authed and not settings.DEBUG:
|
||||
return HttpResponse(status=401)
|
||||
monitoring_set.send_robust(self)
|
||||
return ExportToDjangoView(request)
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"SAMLBindingsEnum": "authentik.providers.saml.models.SAMLBindings",
|
||||
"UserTypeEnum": "authentik.core.models.UserTypes",
|
||||
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||
"WebAuthnHintEnum": "authentik.stages.authenticator_webauthn.models.WebAuthnHint",
|
||||
"SCIMAuthenticationModeEnum": "authentik.providers.scim.models.SCIMAuthenticationMode",
|
||||
"PKCEMethodEnum": "authentik.sources.oauth.models.PKCEMethod",
|
||||
"DeviceFactsOSFamily": "authentik.endpoints.facts.OSFamily",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""root tests"""
|
||||
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
from tempfile import gettempdir
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -11,26 +7,9 @@ from django.urls import reverse
|
||||
class TestRoot(TransactionTestCase):
|
||||
"""Test root application"""
|
||||
|
||||
def setUp(self):
|
||||
_tmp = Path(gettempdir())
|
||||
self.token = token_urlsafe(32)
|
||||
with open(_tmp / "authentik-core-metrics.key", "w") as _f:
|
||||
_f.write(self.token)
|
||||
|
||||
def tearDown(self):
|
||||
_tmp = Path(gettempdir())
|
||||
(_tmp / "authentik-core-metrics.key").unlink()
|
||||
|
||||
def test_monitoring_error(self):
|
||||
"""Test monitoring without any credentials"""
|
||||
response = self.client.get(reverse("metrics"))
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_monitoring_ok(self):
|
||||
def test_monitoring(self):
|
||||
"""Test monitoring with credentials"""
|
||||
auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||
response = self.client.get(reverse("metrics"), **auth_headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.client.get(reverse("metrics")).status_code, 200)
|
||||
|
||||
def test_monitoring_live(self):
|
||||
"""Test LiveView"""
|
||||
|
||||
@@ -60,11 +60,7 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
sources = sources.exclude(pk=self.instance.pk)
|
||||
if sources.exists():
|
||||
raise ValidationError(
|
||||
{
|
||||
"sync_users_password": _(
|
||||
"Only a single LDAP Source with password synchronization is allowed"
|
||||
)
|
||||
}
|
||||
_("Only a single LDAP Source with password synchronization is allowed")
|
||||
)
|
||||
return sync_users_password
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""LDAP Source API tests"""
|
||||
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.sources.ldap.api import LDAPSourceSerializer
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
@@ -26,12 +27,13 @@ class LDAPAPITests(APITestCase):
|
||||
}
|
||||
)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
self.assertEqual(serializer.errors, {})
|
||||
|
||||
def test_sync_users_password_invalid(self):
|
||||
"""Ensure only a single source with password sync can be created"""
|
||||
LDAPSource.objects.create(
|
||||
name="foo",
|
||||
slug="foo",
|
||||
slug=generate_id(),
|
||||
server_uri="ldaps://1.2.3.4",
|
||||
bind_cn="",
|
||||
bind_password=LDAP_PASSWORD,
|
||||
@@ -41,15 +43,26 @@ class LDAPAPITests(APITestCase):
|
||||
serializer = LDAPSourceSerializer(
|
||||
data={
|
||||
"name": "foo",
|
||||
"slug": " foo",
|
||||
"slug": generate_id(),
|
||||
"server_uri": "ldaps://1.2.3.4",
|
||||
"bind_cn": "",
|
||||
"bind_password": LDAP_PASSWORD,
|
||||
"base_dn": "dc=foo",
|
||||
"sync_users_password": False,
|
||||
"sync_users_password": True,
|
||||
}
|
||||
)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors,
|
||||
{
|
||||
"sync_users_password": [
|
||||
ErrorDetail(
|
||||
string="Only a single LDAP Source with password synchronization is allowed",
|
||||
code="invalid",
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_sync_users_mapping_empty(self):
|
||||
"""Check that when sync_users is enabled, property mappings must be set"""
|
||||
|
||||
@@ -36,6 +36,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
|
||||
"configuration_stages",
|
||||
"last_auth_threshold",
|
||||
"webauthn_user_verification",
|
||||
"webauthn_hints",
|
||||
"webauthn_allowed_device_types",
|
||||
"webauthn_allowed_device_types_obj",
|
||||
]
|
||||
|
||||
@@ -38,6 +38,7 @@ from authentik.stages.authenticator_validate.models import AuthenticatorValidate
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@@ -80,7 +81,10 @@ def get_webauthn_challenge_without_user(
|
||||
authentication_options.challenge
|
||||
)
|
||||
|
||||
return options_to_json_dict(authentication_options)
|
||||
options_dict = options_to_json_dict(authentication_options)
|
||||
if stage.webauthn_hints:
|
||||
options_dict["hints"] = list(stage.webauthn_hints)
|
||||
return options_dict
|
||||
|
||||
|
||||
def get_webauthn_challenge(
|
||||
@@ -109,7 +113,10 @@ def get_webauthn_challenge(
|
||||
authentication_options.challenge
|
||||
)
|
||||
|
||||
return options_to_json_dict(authentication_options)
|
||||
options_dict = options_to_json_dict(authentication_options)
|
||||
if stage.webauthn_hints:
|
||||
options_dict["hints"] = list(stage.webauthn_hints)
|
||||
return options_dict
|
||||
|
||||
|
||||
def select_challenge(request: HttpRequest, device: Device):
|
||||
@@ -143,7 +150,11 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device_class=DeviceClasses.TOTP.value,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device_class": DeviceClasses.TOTP.value,
|
||||
}
|
||||
},
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Invalid Token. Please ensure the time on your device is accurate and try again.")
|
||||
@@ -215,9 +226,13 @@ def validate_challenge_webauthn(
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device=device,
|
||||
device_class=DeviceClasses.WEBAUTHN.value,
|
||||
device_type=device.device_type,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device": device,
|
||||
"device_class": DeviceClasses.WEBAUTHN.value,
|
||||
"device_type": device.device_type,
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ValidationError("Assertion failed") from exc
|
||||
|
||||
@@ -267,8 +282,12 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
|
||||
credentials={"username": user.username},
|
||||
request=stage_view.request,
|
||||
stage=stage_view.executor.current_stage,
|
||||
device_class=DeviceClasses.DUO.value,
|
||||
duo_response=response,
|
||||
context={
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"device_class": DeviceClasses.DUO.value,
|
||||
"duo_response": response,
|
||||
}
|
||||
},
|
||||
)
|
||||
raise ValidationError("Duo denied access", code="denied")
|
||||
return device
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-04 02:30
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_validate",
|
||||
"0014_alter_authenticatorvalidatestage_device_classes",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="webauthn_hints",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("security-key", "Security Key"),
|
||||
("client-device", "Client Device"),
|
||||
("hybrid", "Hybrid"),
|
||||
]
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,7 +8,7 @@ from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.flows.models import NotConfiguredAction, Stage
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnHint
|
||||
|
||||
|
||||
class DeviceClasses(models.TextChoices):
|
||||
@@ -73,6 +73,11 @@ class AuthenticatorValidateStage(Stage):
|
||||
choices=UserVerification.choices,
|
||||
default=UserVerification.PREFERRED,
|
||||
)
|
||||
webauthn_hints = ArrayField(
|
||||
models.TextField(choices=WebAuthnHint.choices),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
webauthn_allowed_device_types = models.ManyToManyField(
|
||||
"authentik_stages_authenticator_webauthn.WebAuthnDeviceType", blank=True
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
||||
UserVerification,
|
||||
WebAuthnDevice,
|
||||
WebAuthnDeviceType,
|
||||
WebAuthnHint,
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||
@@ -256,6 +257,105 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
self.assertEqual(challenge["timeout"], 60000)
|
||||
self.assertEqual(challenge["userVerification"], "preferred")
|
||||
|
||||
def test_device_challenge_webauthn_with_hints(self):
|
||||
"""Test that webauthn hints are included in authentication challenge"""
|
||||
request = self.request_factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
webauthn_device = WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key=bytes_to_base64url(b"qwerqwerqre"),
|
||||
credential_id=bytes_to_base64url(b"foobarbaz"),
|
||||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
last_auth_threshold="milliseconds=0",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
webauthn_hints=[WebAuthnHint.CLIENT_DEVICE, WebAuthnHint.HYBRID],
|
||||
)
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||
self.assertEqual(challenge["hints"], ["client-device", "hybrid"])
|
||||
|
||||
def test_device_challenge_webauthn_no_hints(self):
|
||||
"""Test that hints key is absent when no hints configured"""
|
||||
request = self.request_factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
webauthn_device = WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key=bytes_to_base64url(b"qwerqwerqre"),
|
||||
credential_id=bytes_to_base64url(b"foobarbaz"),
|
||||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
last_auth_threshold="milliseconds=0",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
)
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||
self.assertNotIn("hints", challenge)
|
||||
|
||||
def test_get_challenge_userless_with_hints(self):
|
||||
"""Test that hints are included in userless/passwordless challenge"""
|
||||
request = self.request_factory.get("/")
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
webauthn_hints=[WebAuthnHint.SECURITY_KEY, WebAuthnHint.CLIENT_DEVICE],
|
||||
)
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_webauthn_challenge_without_user(stage_view, stage)
|
||||
self.assertEqual(challenge["hints"], ["security-key", "client-device"])
|
||||
|
||||
def test_device_challenge_webauthn_hints_order_preserved(self):
|
||||
"""Test that hint order is preserved in authentication challenge"""
|
||||
request = self.request_factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
webauthn_device = WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key=bytes_to_base64url(b"qwerqwerqre"),
|
||||
credential_id=bytes_to_base64url(b"foobarbaz"),
|
||||
sign_count=0,
|
||||
rp_id=generate_id(),
|
||||
)
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
last_auth_threshold="milliseconds=0",
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.WEBAUTHN],
|
||||
webauthn_user_verification=UserVerification.PREFERRED,
|
||||
webauthn_hints=[
|
||||
WebAuthnHint.HYBRID,
|
||||
WebAuthnHint.SECURITY_KEY,
|
||||
WebAuthnHint.CLIENT_DEVICE,
|
||||
],
|
||||
)
|
||||
plan = FlowPlan("")
|
||||
stage_view = AuthenticatorValidateStageView(
|
||||
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
|
||||
)
|
||||
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
|
||||
self.assertEqual(challenge["hints"], ["hybrid", "security-key", "client-device"])
|
||||
|
||||
def test_validate_challenge_unrestricted(self):
|
||||
"""Test webauthn authentication (unrestricted webauthn device)"""
|
||||
webauthn_mds_import.send(force=True).get_result()
|
||||
|
||||
@@ -23,6 +23,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
|
||||
"user_verification",
|
||||
"authenticator_attachment",
|
||||
"resident_key_requirement",
|
||||
"hints",
|
||||
"device_type_restrictions",
|
||||
"device_type_restrictions_obj",
|
||||
"max_attempts",
|
||||
@@ -34,6 +35,14 @@ class AuthenticatorWebAuthnStageViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
queryset = AuthenticatorWebAuthnStage.objects.all()
|
||||
serializer_class = AuthenticatorWebAuthnStageSerializer
|
||||
filterset_fields = "__all__"
|
||||
filterset_fields = [
|
||||
"name",
|
||||
"configure_flow",
|
||||
"user_verification",
|
||||
"authenticator_attachment",
|
||||
"resident_key_requirement",
|
||||
"device_type_restrictions",
|
||||
"max_attempts",
|
||||
]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
@@ -191,5 +191,8 @@
|
||||
"name": "Sticky Password Manager",
|
||||
"icon_dark": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmlld0JveD0iMCAwIDEwOCAxMDgiCiAgIHdpZHRoPSIxMDgiCiAgIGhlaWdodD0iMTA4IgogICB2ZXJzaW9uPSIxLjEiCiAgIGlkPSJzdmc1IgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDx0aXRsZT5TdGlja3kgUGFzc3dvcmQgTWFuYWdlcjwvdGl0bGU+CiAgPGRlZnMKICAgICBpZD0iZGVmczUiIC8+CiAgPGNpcmNsZQogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7c3Ryb2tlLXdpZHRoOjEuNzYyMDEiCiAgICAgaWQ9InBhdGg2IgogICAgIGN4PSI1NCIKICAgICBjeT0iNTQiCiAgICAgcj0iNTQiIC8+CiAgPGcKICAgICBpZD0iZzYiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMS4zNDMzMjQzLDAsMCwxLjM0MzMyNDMsLTE4LjU0MDYwNywtMTguNTI3NTk0KSI+CiAgICA8cGF0aAogICAgICAgZD0ibSA2NC4zNSw1My4xMSAtOS41LC05LjUgYyAtMC40OCwtMC40OCAtMS4yNiwtMC40OCAtMS43NSwwIGwgLTkuNSw5LjUgYyAtMC40OCwwLjQ4IC0wLjQ4LDEuMjYgMCwxLjc0IGwgOS41LDkuNSBjIDAuMjQsMC4yNCAwLjU2LDAuMzYgMC44NywwLjM2IDAuMzEsMCAwLjYzLC0wLjEyIDAuODcsLTAuMzYgbCA5LjUsLTkuNSBjIDAuMjMsLTAuMjMgMC4zNiwtMC41NCAwLjM2LC0wLjg3IDAsLTAuMzMgLTAuMTMsLTAuNjQgLTAuMzYsLTAuODcgeiIKICAgICAgIGZpbGw9IiMwMDAwMDAiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoMSIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDUwLjExLDY3LjM0IC05LjUsLTkuNSBjIC0wLjQ4LC0wLjQ4IC0xLjI2LC0wLjQ4IC0xLjc0LDAgbCAtOS40OSw5LjUgYyAtMC40OCwwLjQ4IC0wLjQ4LDEuMjYgMCwxLjc1IGwgOS40OSw5LjUgYyAwLjI0LDAuMjQgMC41NiwwLjM2IDAuODcsMC4zNiAwLjMxLDAgMC42MywtMC4xMiAwLjg3LC0wLjM2IGwgOS41LC05LjUgYyAwLjIzLC0wLjI0IDAuMzYsLTAuNTQgMC4zNiwtMC44NyAwLC0wLjMzIC0wLjEzLC0wLjY0IC0wLjM2LC0wLjg3IgogICAgICAgZmlsbD0iIzAwYTllMCIKICAgICAgIGZpbGwtcnVsZT0iZXZlbm9kZCIKICAgICAgIHN0cm9rZT0iIzAwMDAwMCIKICAgICAgIHN0cm9rZS13aWR0aD0iMy42MiIKICAgICAgIHN0cm9rZS1vcGFjaXR5PSIwIgogICAgICAgaWQ9InBhdGgyIiAvPgogICAgPHBhdGgKICAgICAgIGQ9Im0gNzguNTcsMzguODggLTkuNSwtOS40OSBjIC0wLjQ4LC0wLjQ4IC0xLjI3LC0wLjQ4IC0xLjc0LDAgbCAtOS41LDkuNSBjIC0wLjQ4LDAuNDggLTAuNDgsMS4yNiAwLDEuNzQgbCA5LjUsOS41IGMgMC4yNCwwLjI0IDAuNTUsMC4zNiAwLjg3LDAuMzYgMC4zMSwwIDAuNjMsLTAuMTIgMC44NywtMC4zNiBsIDkuNSwtOS41IGMgMC4yMywtMC4yNCAwLjM2LC0wLjU0IDAuMzYsLTAuODcgMCwtMC4zMyAtMC4xMywtMC42NCAtMC4zNiwtMC44NyIKICAgICAgIGZpbGw9IiNkNjE4MTgiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoMyIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDUwLjEsMzguODYgLTkuNSwtOS41IGMgLTAuNDgsLTAuNDggLTEuMjcsLTAuNDggLTEuNzQsMCBsIC05LjUsOS41IGMgLTAuNDgsMC40OCAtMC40OCwxLjI2IDAsMS43NSBsIDkuNSw5LjUgYyAwLjI0LDAuMjQgMC41NSwwLjM2IDAuODcsMC4zNiAwLjMyLDAgMC42MywtMC4xMiAwLjg3LC0wLjM2IGwgOS40OSwtOS41IGMgMC4yMywtMC4yMyAwLjM3LC0wLjU0IDAuMzcsLTAuODggMCwtMC4zMyAtMC4xMywtMC42NCAtMC4zNywtMC44NyIKICAgICAgIGZpbGw9IiM3YWI4MDAiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoNCIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDc4LjY0LDY3LjM5IC05LjUsLTkuNDkgYyAtMC40OCwtMC40OCAtMS4yNywtMC40OCAtMS43NSwwIGwgLTkuNDksOS40OSBjIC0wLjQ4LDAuNDggLTAuNDgsMS4yNiAwLDEuNzUgbCA5LjQ5LDkuNSBjIDAuMjQsMC4yNCAwLjU1LDAuMzYgMC44NywwLjM2IDAuMzIsMCAwLjYzLC0wLjEyIDAuODcsLTAuMzYgbCA5LjUsLTkuNSBjIDAuMjMsLTAuMjMgMC4zNiwtMC41NCAwLjM2LC0wLjg3IDAsLTAuMzMgLTAuMTMsLTAuNjQgLTAuMzYsLTAuODgiCiAgICAgICBmaWxsPSIjMDA0NmFkIgogICAgICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgICAgc3Ryb2tlPSIjMDAwMDAwIgogICAgICAgc3Ryb2tlLXdpZHRoPSIzLjYyIgogICAgICAgc3Ryb2tlLW9wYWNpdHk9IjAiCiAgICAgICBpZD0icGF0aDUiIC8+CiAgPC9nPgo8L3N2Zz4K",
|
||||
"icon_light": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmlld0JveD0iMCAwIDEwOCAxMDgiCiAgIHdpZHRoPSIxMDgiCiAgIGhlaWdodD0iMTA4IgogICB2ZXJzaW9uPSIxLjEiCiAgIGlkPSJzdmc1IgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDx0aXRsZT5TdGlja3kgUGFzc3dvcmQgTWFuYWdlcjwvdGl0bGU+CiAgPGRlZnMKICAgICBpZD0iZGVmczUiIC8+CiAgPGNpcmNsZQogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7c3Ryb2tlLXdpZHRoOjEuNzYyMDEiCiAgICAgaWQ9InBhdGg2IgogICAgIGN4PSI1NCIKICAgICBjeT0iNTQiCiAgICAgcj0iNTQiIC8+CiAgPGcKICAgICBpZD0iZzYiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMS4zNDMzMjQzLDAsMCwxLjM0MzMyNDMsLTE4LjU0MDYwNywtMTguNTI3NTk0KSI+CiAgICA8cGF0aAogICAgICAgZD0ibSA2NC4zNSw1My4xMSAtOS41LC05LjUgYyAtMC40OCwtMC40OCAtMS4yNiwtMC40OCAtMS43NSwwIGwgLTkuNSw5LjUgYyAtMC40OCwwLjQ4IC0wLjQ4LDEuMjYgMCwxLjc0IGwgOS41LDkuNSBjIDAuMjQsMC4yNCAwLjU2LDAuMzYgMC44NywwLjM2IDAuMzEsMCAwLjYzLC0wLjEyIDAuODcsLTAuMzYgbCA5LjUsLTkuNSBjIDAuMjMsLTAuMjMgMC4zNiwtMC41NCAwLjM2LC0wLjg3IDAsLTAuMzMgLTAuMTMsLTAuNjQgLTAuMzYsLTAuODcgeiIKICAgICAgIGZpbGw9IiMwMDAwMDAiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoMSIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDUwLjExLDY3LjM0IC05LjUsLTkuNSBjIC0wLjQ4LC0wLjQ4IC0xLjI2LC0wLjQ4IC0xLjc0LDAgbCAtOS40OSw5LjUgYyAtMC40OCwwLjQ4IC0wLjQ4LDEuMjYgMCwxLjc1IGwgOS40OSw5LjUgYyAwLjI0LDAuMjQgMC41NiwwLjM2IDAuODcsMC4zNiAwLjMxLDAgMC42MywtMC4xMiAwLjg3LC0wLjM2IGwgOS41LC05LjUgYyAwLjIzLC0wLjI0IDAuMzYsLTAuNTQgMC4zNiwtMC44NyAwLC0wLjMzIC0wLjEzLC0wLjY0IC0wLjM2LC0wLjg3IgogICAgICAgZmlsbD0iIzAwYTllMCIKICAgICAgIGZpbGwtcnVsZT0iZXZlbm9kZCIKICAgICAgIHN0cm9rZT0iIzAwMDAwMCIKICAgICAgIHN0cm9rZS13aWR0aD0iMy42MiIKICAgICAgIHN0cm9rZS1vcGFjaXR5PSIwIgogICAgICAgaWQ9InBhdGgyIiAvPgogICAgPHBhdGgKICAgICAgIGQ9Im0gNzguNTcsMzguODggLTkuNSwtOS40OSBjIC0wLjQ4LC0wLjQ4IC0xLjI3LC0wLjQ4IC0xLjc0LDAgbCAtOS41LDkuNSBjIC0wLjQ4LDAuNDggLTAuNDgsMS4yNiAwLDEuNzQgbCA5LjUsOS41IGMgMC4yNCwwLjI0IDAuNTUsMC4zNiAwLjg3LDAuMzYgMC4zMSwwIDAuNjMsLTAuMTIgMC44NywtMC4zNiBsIDkuNSwtOS41IGMgMC4yMywtMC4yNCAwLjM2LC0wLjU0IDAuMzYsLTAuODcgMCwtMC4zMyAtMC4xMywtMC42NCAtMC4zNiwtMC44NyIKICAgICAgIGZpbGw9IiNkNjE4MTgiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoMyIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDUwLjEsMzguODYgLTkuNSwtOS41IGMgLTAuNDgsLTAuNDggLTEuMjcsLTAuNDggLTEuNzQsMCBsIC05LjUsOS41IGMgLTAuNDgsMC40OCAtMC40OCwxLjI2IDAsMS43NSBsIDkuNSw5LjUgYyAwLjI0LDAuMjQgMC41NSwwLjM2IDAuODcsMC4zNiAwLjMyLDAgMC42MywtMC4xMiAwLjg3LC0wLjM2IGwgOS40OSwtOS41IGMgMC4yMywtMC4yMyAwLjM3LC0wLjU0IDAuMzcsLTAuODggMCwtMC4zMyAtMC4xMywtMC42NCAtMC4zNywtMC44NyIKICAgICAgIGZpbGw9IiM3YWI4MDAiCiAgICAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICAgICBzdHJva2U9IiMwMDAwMDAiCiAgICAgICBzdHJva2Utd2lkdGg9IjMuNjIiCiAgICAgICBzdHJva2Utb3BhY2l0eT0iMCIKICAgICAgIGlkPSJwYXRoNCIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDc4LjY0LDY3LjM5IC05LjUsLTkuNDkgYyAtMC40OCwtMC40OCAtMS4yNywtMC40OCAtMS43NSwwIGwgLTkuNDksOS40OSBjIC0wLjQ4LDAuNDggLTAuNDgsMS4yNiAwLDEuNzUgbCA5LjQ5LDkuNSBjIDAuMjQsMC4yNCAwLjU1LDAuMzYgMC44NywwLjM2IDAuMzIsMCAwLjYzLC0wLjEyIDAuODcsLTAuMzYgbCA5LjUsLTkuNSBjIDAuMjMsLTAuMjMgMC4zNiwtMC41NCAwLjM2LC0wLjg3IDAsLTAuMzMgLTAuMTMsLTAuNjQgLTAuMzYsLTAuODgiCiAgICAgICBmaWxsPSIjMDA0NmFkIgogICAgICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgICAgc3Ryb2tlPSIjMDAwMDAwIgogICAgICAgc3Ryb2tlLXdpZHRoPSIzLjYyIgogICAgICAgc3Ryb2tlLW9wYWNpdHk9IjAiCiAgICAgICBpZD0icGF0aDUiIC8+CiAgPC9nPgo8L3N2Zz4K"
|
||||
},
|
||||
"70617373-7761-6c6c-6669-646f32303236": {
|
||||
"name": "Passwall"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-04 02:30
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_webauthn",
|
||||
"0014_alter_authenticatorwebauthnstage_friendly_name",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="authenticatorwebauthnstage",
|
||||
name="hints",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("security-key", "Security Key"),
|
||||
("client-device", "Client Device"),
|
||||
("hybrid", "Hybrid"),
|
||||
]
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebAuthn stage"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields.array import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -67,6 +68,24 @@ class AuthenticatorAttachment(models.TextChoices):
|
||||
CROSS_PLATFORM = "cross-platform"
|
||||
|
||||
|
||||
class WebAuthnHint(models.TextChoices):
|
||||
"""Hints to guide the browser in prioritizing the preferred authenticator during
|
||||
WebAuthn registration and authentication. Unlike authenticatorAttachment, hints are
|
||||
advisory and browsers may ignore them.
|
||||
|
||||
Members:
|
||||
`SECURITY_KEY`: A portable FIDO2 authenticator, like a YubiKey
|
||||
`CLIENT_DEVICE`: The device WebAuthn is being called on, like TouchID or Windows Hello
|
||||
`HYBRID`: A platform authenticator on a mobile device, accessed via QR code
|
||||
|
||||
https://w3c.github.io/webauthn/#enumdef-publickeycredentialhint
|
||||
"""
|
||||
|
||||
SECURITY_KEY = "security-key"
|
||||
CLIENT_DEVICE = "client-device"
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
"""Setup WebAuthn-based authentication for the user."""
|
||||
|
||||
@@ -82,6 +101,12 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
choices=AuthenticatorAttachment.choices, default=None, null=True
|
||||
)
|
||||
|
||||
hints = ArrayField(
|
||||
models.TextField(choices=WebAuthnHint.choices),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(default=0)
|
||||
|
||||
@@ -16,6 +16,7 @@ from webauthn.helpers.structs import (
|
||||
AuthenticatorAttachment,
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialCreationOptions,
|
||||
PublicKeyCredentialHint,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
@@ -127,6 +128,20 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
if authenticator_attachment:
|
||||
authenticator_attachment = AuthenticatorAttachment(str(authenticator_attachment))
|
||||
|
||||
hints = [PublicKeyCredentialHint(h) for h in stage.hints] or None
|
||||
|
||||
# For compatibility with older user agents that don't support hints,
|
||||
# auto-infer authenticatorAttachment from hints when not explicitly set.
|
||||
# https://w3c.github.io/webauthn/#enum-hints
|
||||
if hints and not authenticator_attachment:
|
||||
hint_values = set(stage.hints)
|
||||
cross_platform = {"security-key", "hybrid"}
|
||||
platform = {"client-device"}
|
||||
if hint_values <= cross_platform:
|
||||
authenticator_attachment = AuthenticatorAttachment.CROSS_PLATFORM
|
||||
elif hint_values <= platform:
|
||||
authenticator_attachment = AuthenticatorAttachment.PLATFORM
|
||||
|
||||
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
|
||||
rp_id=get_rp_id(self.request),
|
||||
rp_name=self.request.brand.branding_title,
|
||||
@@ -139,6 +154,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
authenticator_attachment=authenticator_attachment,
|
||||
),
|
||||
attestation=AttestationConveyancePreference.DIRECT,
|
||||
hints=hints,
|
||||
)
|
||||
|
||||
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge
|
||||
|
||||
@@ -17,6 +17,7 @@ from authentik.stages.authenticator_webauthn.models import (
|
||||
AuthenticatorWebAuthnStage,
|
||||
WebAuthnDevice,
|
||||
WebAuthnDeviceType,
|
||||
WebAuthnHint,
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
|
||||
@@ -302,6 +303,145 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_registration_options_with_hints(self):
|
||||
"""Test that hints are included in registration options"""
|
||||
self.stage.hints = [WebAuthnHint.CLIENT_DEVICE, WebAuthnHint.SECURITY_KEY]
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertEqual(registration["hints"], ["client-device", "security-key"])
|
||||
|
||||
def test_registration_options_hints_empty(self):
|
||||
"""Test that no hints key is present when hints are empty"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertNotIn("hints", registration)
|
||||
|
||||
def test_registration_options_hints_infer_attachment_cross_platform(self):
|
||||
"""Test that authenticatorAttachment is auto-inferred as cross-platform
|
||||
from security-key/hybrid hints for backwards compatibility"""
|
||||
self.stage.hints = [WebAuthnHint.SECURITY_KEY]
|
||||
self.stage.authenticator_attachment = None
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertEqual(
|
||||
registration["authenticatorSelection"]["authenticatorAttachment"], "cross-platform"
|
||||
)
|
||||
|
||||
def test_registration_options_hints_infer_attachment_platform(self):
|
||||
"""Test that authenticatorAttachment is auto-inferred as platform
|
||||
from client-device hint for backwards compatibility"""
|
||||
self.stage.hints = [WebAuthnHint.CLIENT_DEVICE]
|
||||
self.stage.authenticator_attachment = None
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertEqual(
|
||||
registration["authenticatorSelection"]["authenticatorAttachment"], "platform"
|
||||
)
|
||||
|
||||
def test_registration_options_hints_no_infer_when_attachment_set(self):
|
||||
"""Test that authenticatorAttachment is NOT overridden when explicitly set"""
|
||||
self.stage.hints = [WebAuthnHint.SECURITY_KEY]
|
||||
self.stage.authenticator_attachment = "platform"
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertEqual(
|
||||
registration["authenticatorSelection"]["authenticatorAttachment"], "platform"
|
||||
)
|
||||
|
||||
def test_registration_options_hints_no_infer_mixed(self):
|
||||
"""Test that authenticatorAttachment is NOT inferred when hints are mixed"""
|
||||
self.stage.hints = [WebAuthnHint.SECURITY_KEY, WebAuthnHint.CLIENT_DEVICE]
|
||||
self.stage.authenticator_attachment = None
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertNotIn("authenticatorAttachment", registration["authenticatorSelection"])
|
||||
|
||||
def test_registration_options_hints_order_preserved(self):
|
||||
"""Test that hint order is preserved (first hint = highest priority)"""
|
||||
self.stage.hints = [
|
||||
WebAuthnHint.HYBRID,
|
||||
WebAuthnHint.CLIENT_DEVICE,
|
||||
WebAuthnHint.SECURITY_KEY,
|
||||
]
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
registration = response.json()["registration"]
|
||||
self.assertEqual(registration["hints"], ["hybrid", "client-device", "security-key"])
|
||||
|
||||
def test_register_max_retries(self):
|
||||
"""Test registration (exceeding max retries)"""
|
||||
self.stage.max_attempts = 2
|
||||
|
||||
@@ -224,7 +224,10 @@ class WorkerHealthcheckMiddleware(Middleware):
|
||||
thread: HTTPServerThread | None
|
||||
|
||||
def __init__(self):
|
||||
host, _, port = CONFIG.get("listen.http").rpartition(":")
|
||||
listen = CONFIG.get("listen.http", ["[::]:9000"])
|
||||
if isinstance(listen, str):
|
||||
listen = listen.split(",")
|
||||
host, _, port = listen[0].rpartition(":")
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
@@ -323,7 +326,10 @@ class MetricsMiddleware(BaseMetricsMiddleware):
|
||||
return []
|
||||
|
||||
def after_worker_boot(self, broker: Broker, worker: Worker):
|
||||
addr, _, port = CONFIG.get("listen.metrics").rpartition(":")
|
||||
listen = CONFIG.get("listen.metrics", ["[::]:9300"])
|
||||
if isinstance(listen, str):
|
||||
listen = listen.split(",")
|
||||
addr, _, port = listen[0].rpartition(":")
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
|
||||
@@ -5,5 +5,5 @@ from authentik.tasks.api.workers import WorkerView
|
||||
|
||||
api_urlpatterns = [
|
||||
("tasks/tasks", TaskViewSet),
|
||||
path("tasks/workers", WorkerView.as_view(), name="tasks_workers"),
|
||||
path("tasks/workers/", WorkerView.as_view(), name="tasks_workers"),
|
||||
]
|
||||
|
||||
@@ -44,6 +44,8 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
for flag in Flag.available():
|
||||
_flag = flag()
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
if _flag.description:
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
return build_object_type(props, required=props.keys())
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||
class Flag[T]:
|
||||
default: T | None = None
|
||||
visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] = "none"
|
||||
description: str | None = None
|
||||
|
||||
def __init_subclass__(cls, key: str, **kwargs):
|
||||
cls.__key = key
|
||||
|
||||
@@ -8236,6 +8236,12 @@
|
||||
"type": "string",
|
||||
"title": "Webhook url"
|
||||
},
|
||||
"webhook_ca": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Webhook ca",
|
||||
"description": "When set, the selected ceritifcate is used to validate the certificate of the webhook server."
|
||||
},
|
||||
"webhook_mapping_body": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
@@ -14728,6 +14734,19 @@
|
||||
"title": "Webauthn user verification",
|
||||
"description": "Enforce user verification for WebAuthn devices."
|
||||
},
|
||||
"webauthn_hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"security-key",
|
||||
"client-device",
|
||||
"hybrid"
|
||||
],
|
||||
"title": "Webauthn hints"
|
||||
},
|
||||
"title": "Webauthn hints"
|
||||
},
|
||||
"webauthn_allowed_device_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -14813,6 +14832,19 @@
|
||||
],
|
||||
"title": "Resident key requirement"
|
||||
},
|
||||
"hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"security-key",
|
||||
"client-device",
|
||||
"hybrid"
|
||||
],
|
||||
"title": "Hints"
|
||||
},
|
||||
"title": "Hints"
|
||||
},
|
||||
"device_type_restrictions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -12,7 +14,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
utils "goauthentik.io/internal/utils/web"
|
||||
"goauthentik.io/internal/web"
|
||||
)
|
||||
|
||||
var workerPidFile = path.Join(os.TempDir(), "authentik-worker.pid")
|
||||
@@ -44,9 +47,15 @@ func init() {
|
||||
|
||||
func checkServer() int {
|
||||
h := &http.Client{
|
||||
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
|
||||
Transport: utils.NewUserAgentTransport("goauthentik.io/healthcheck",
|
||||
&http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", path.Join(os.TempDir(), web.SocketName))
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
url := fmt.Sprintf("http://%s%s-/health/live/", config.Get().Listen.HTTP, config.Get().Web.Path)
|
||||
url := fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to send healthcheck request")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
@@ -51,9 +52,10 @@ var rootCmd = &cobra.Command{
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
u, err := url.Parse(fmt.Sprintf("http://%s%s", config.Get().Listen.HTTP, config.Get().Web.Path))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
u := url.URL{
|
||||
Scheme: "unix",
|
||||
Host: fmt.Sprintf("%s/%s", os.TempDir(), web.SocketName),
|
||||
Path: config.Get().Web.Path,
|
||||
}
|
||||
|
||||
ws := web.NewWebServer()
|
||||
@@ -70,13 +72,13 @@ var rootCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func attemptProxyStart(ws *web.WebServer, u *url.URL) {
|
||||
func attemptProxyStart(ws *web.WebServer, u url.URL) {
|
||||
maxTries := 100
|
||||
attempt := 0
|
||||
l := log.WithField("logger", "authentik.server")
|
||||
for {
|
||||
l.Debug("attempting to init outpost")
|
||||
ac := ak.NewAPIController(*u, config.Get().SecretKey)
|
||||
ac := ak.NewAPIController(u, config.Get().SecretKey)
|
||||
if ac == nil {
|
||||
attempt += 1
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
},
|
||||
"reporters": [
|
||||
"default",
|
||||
["@cspell/cspell-json-reporter", { "outFile": "./cspell-report.json" }]
|
||||
[
|
||||
"@cspell/cspell-json-reporter",
|
||||
{
|
||||
"outFile": "./cspell-report.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"dictionaryDefinitions": [
|
||||
{
|
||||
@@ -32,12 +37,16 @@
|
||||
"path": "./locale/en/dictionaries/python.txt",
|
||||
"addWords": true
|
||||
},
|
||||
{
|
||||
"name": "en-x-authentik-rust",
|
||||
"path": "./locale/en/dictionaries/rust.txt",
|
||||
"addWords": true
|
||||
},
|
||||
{
|
||||
"name": "en-x-authentik-golang",
|
||||
"path": "./locale/en/dictionaries/golang.txt",
|
||||
"addWords": true
|
||||
},
|
||||
|
||||
{
|
||||
"name": "en-x-authentik-people",
|
||||
"path": "./locale/en/dictionaries/people.txt",
|
||||
@@ -81,7 +90,10 @@
|
||||
{
|
||||
"name": "ConfSuffix",
|
||||
"description": "Variables with `conf` or `config` suffix",
|
||||
"pattern": ["\\w+(conf|config)\\b", "\\b(conf|config)\\w+"]
|
||||
"pattern": [
|
||||
"\\w+(conf|config)\\b",
|
||||
"\\b(conf|config)\\w+"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ignoreRegExpList": [
|
||||
@@ -119,7 +131,6 @@
|
||||
"\\w+l?ified\\b",
|
||||
// "ifying" suffix, e.g. "stringifying", "classifying".
|
||||
"\\w+l?ifying\\b",
|
||||
|
||||
"SpellCheckerIgnoreInDocSetting",
|
||||
"EncodedURI",
|
||||
"Urls",
|
||||
@@ -135,7 +146,11 @@
|
||||
"languageSettings": [
|
||||
{
|
||||
"languageId": "markdown,mdx",
|
||||
"dictionaries": ["en-x-authentik-python", "en-x-authentik-golang"],
|
||||
"dictionaries": [
|
||||
"en-x-authentik-python",
|
||||
"en-x-authentik-rust",
|
||||
"en-x-authentik-golang"
|
||||
],
|
||||
"ignoreRegExpList": [
|
||||
// Fenced code blocks
|
||||
"/^\\s*```[\\s\\S]*?^\\s*```/gm",
|
||||
@@ -146,7 +161,6 @@
|
||||
},
|
||||
{
|
||||
"languageId": "typescript,javascript,typescriptreact,javascriptreact,mdx,astro",
|
||||
|
||||
"ignoreRegExpList": [
|
||||
// Event handlers e.g. onClick, onmouseover
|
||||
"\\bon\\w+\\b",
|
||||
@@ -166,18 +180,33 @@
|
||||
},
|
||||
{
|
||||
"languageId": "python",
|
||||
"dictionaries": ["en-x-authentik-python"],
|
||||
"includeRegExpList": ["comments"]
|
||||
"dictionaries": [
|
||||
"en-x-authentik-python"
|
||||
],
|
||||
"includeRegExpList": [
|
||||
"comments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"languageId": "rust",
|
||||
"dictionaries": [
|
||||
"en-x-authentik-rust"
|
||||
]
|
||||
},
|
||||
{
|
||||
"languageId": "go",
|
||||
"dictionaries": ["en-x-authentik-golang"]
|
||||
"dictionaries": [
|
||||
"en-x-authentik-golang"
|
||||
]
|
||||
},
|
||||
{
|
||||
"languageId": "makefile",
|
||||
"dictionaries": ["en-x-authentik-python", "en-x-authentik-golang"]
|
||||
"languageId": "makefile,toml,yaml",
|
||||
"dictionaries": [
|
||||
"en-x-authentik-python",
|
||||
"en-x-authentik-rust",
|
||||
"en-x-authentik-golang"
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"languageId": "css,scss",
|
||||
"ignoreRegExpList": [
|
||||
@@ -188,20 +217,15 @@
|
||||
],
|
||||
"ignorePaths": [
|
||||
//#region i18n
|
||||
|
||||
"{cspell.*,cSpell.*,.cspell.*,cspell.config.*}", // CSpell configuration files
|
||||
"cspell-report.{json,html,txt}", // CSpell report files
|
||||
"dictionaries", // Custom dictionary files
|
||||
"ignore.txt", // Custom ignore list files
|
||||
|
||||
"./locale", // Locale files (Django, CSpell)
|
||||
"web/xliff", // XLIFF translation files
|
||||
"web/src/locales", // Generated TypeScript locale
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Monorepo
|
||||
|
||||
"CODEOWNERS", // GitHub code owners file
|
||||
"LICENSE", // License file
|
||||
".gitignore", // Git ignore file
|
||||
@@ -224,9 +248,7 @@
|
||||
"fixtures", // Test fixtures
|
||||
"tests/e2e/**/*.php", // PHP fixtures
|
||||
"compose.yml", // Docker Compose files
|
||||
|
||||
//#region JavaScript/TypeScript
|
||||
|
||||
".eslintignore", // ESLint ignore file
|
||||
".prettierignore", // Prettier ignore file
|
||||
".yarn", // Yarn cache and configuration
|
||||
@@ -239,7 +261,6 @@
|
||||
"*.min.{js,css}", // Minified JS and CSS files
|
||||
"*.min.{js,css}.map", // Source maps for minified files
|
||||
//#region Python
|
||||
|
||||
"pyproject.toml",
|
||||
"unittest.xml", // Pytest output
|
||||
".venv", // Python virtual environment
|
||||
@@ -248,37 +269,25 @@
|
||||
"blueprints",
|
||||
"mds",
|
||||
//#endregion
|
||||
|
||||
//#region Rust
|
||||
|
||||
"./target", // Rust compilation artifacts
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Docusaurus
|
||||
|
||||
"*.api.mdx", // Generated API docs
|
||||
".docusaurus/**", // Cache
|
||||
"./{docs,website}/build", // Topic docs build output
|
||||
"./{docs,website}/**/build", // Workspaces output
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Golang
|
||||
|
||||
"go.mod", // Go module file
|
||||
"go.sum", // Go module file
|
||||
"htmlcov", // Coverage HTML output
|
||||
"coverage.txt", // Coverage text output
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Media
|
||||
|
||||
"./data", // Media files
|
||||
"./media", // Legacy media files
|
||||
"*.{png,jpg,pdf,svg}" // Binary files
|
||||
|
||||
//#endregion
|
||||
],
|
||||
"useGitignore": true,
|
||||
|
||||
8
go.mod
8
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/getsentry/sentry-go v0.43.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-openapi/runtime v0.29.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -19,7 +19,7 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grafana/pyroscope-go v1.2.7
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
@@ -30,7 +30,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260323171523-ab05463a3eba
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
@@ -41,7 +41,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
||||
20
go.sum
20
go.sum
@@ -2,8 +2,8 @@ beryju.io/ldap v0.1.0 h1:rPjGE3qR1Klbvn9N+iECWdzt/tK87XHgz8W5wZJg9B8=
|
||||
beryju.io/ldap v0.1.0/go.mod h1:sOrYV+ZlDTDu/IvIiEiuAaXzjcpMBE+XXr4V+NJ0pWI=
|
||||
beryju.io/radius-eap v0.1.0 h1:5M3HwkzH3nIEBcKDA2z5+sb4nCY3WdKL/SDDKTBvoqw=
|
||||
beryju.io/radius-eap v0.1.0/go.mod h1:yYtO59iyoLNEepdyp1gZ0i1tGdjPbrR2M/v5yOz7Fkc=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
|
||||
@@ -34,8 +34,8 @@ github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9
|
||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -117,8 +117,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -213,10 +213,10 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260304104333-840924fe52c4 h1:zjmi1QNVQPABt0Yx5hws1lXR3tuTI23Ae7MwXffbP/s=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260304104333-840924fe52c4/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a h1:CipAaiYqzzyhQDO6xg3YfEC0saoyVCFFbUjRfAsJrxs=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260309103029-7c71e7d5673a/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260317190750-6ec0d12b221b h1:p+iDEXjvC15pC1VscaR59Vud9/c/xeNeTFmlv4arkNI=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260317190750-6ec0d12b221b/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260323171523-ab05463a3eba h1:qwBygmfe8YE7m2pObvrUFC17tdaRIe84w1qjHGvBJ4w=
|
||||
goauthentik.io/api/v3 v3.2026020.17-0.20260323171523-ab05463a3eba/go.mod h1:uYa+yGMglhJy8ymyUQ8KQiJjOb3UZTuPQ24Ot2s9BCo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
|
||||
@@ -50,12 +50,12 @@ type PostgreSQLConfig struct {
|
||||
}
|
||||
|
||||
type ListenConfig struct {
|
||||
HTTP string `yaml:"http" env:"HTTP, overwrite"`
|
||||
HTTPS string `yaml:"https" env:"HTTPS, overwrite"`
|
||||
LDAP string `yaml:"ldap" env:"LDAP, overwrite"`
|
||||
LDAPS string `yaml:"ldaps" env:"LDAPS, overwrite"`
|
||||
Radius string `yaml:"radius" env:"RADIUS, overwrite"`
|
||||
Metrics string `yaml:"metrics" env:"METRICS, overwrite"`
|
||||
HTTP []string `yaml:"http" env:"HTTP, overwrite"`
|
||||
HTTPS []string `yaml:"https" env:"HTTPS, overwrite"`
|
||||
LDAP []string `yaml:"ldap" env:"LDAP, overwrite"`
|
||||
LDAPS []string `yaml:"ldaps" env:"LDAPS, overwrite"`
|
||||
Radius []string `yaml:"radius" env:"RADIUS, overwrite"`
|
||||
Metrics []string `yaml:"metrics" env:"METRICS, overwrite"`
|
||||
Debug string `yaml:"debug" env:"DEBUG, overwrite"`
|
||||
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"TRUSTED_PROXY_CIDRS, overwrite"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -30,6 +31,7 @@ const ConfigLogLevel = "log_level"
|
||||
|
||||
// APIController main controller which connects to the authentik api via http and ws
|
||||
type APIController struct {
|
||||
akURL url.URL
|
||||
Client *api.APIClient
|
||||
Outpost api.Outpost
|
||||
GlobalConfig *api.Config
|
||||
@@ -54,19 +56,44 @@ type APIController struct {
|
||||
// NewAPIController initialise new API Controller instance from URL and API token
|
||||
func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
rsp := sentry.StartSpan(context.Background(), "authentik.outposts.init")
|
||||
log := log.WithField("logger", "authentik.outpost.ak-api-controller")
|
||||
|
||||
originalAkURL := akURL
|
||||
var client http.Client
|
||||
if akURL.Scheme == "unix" {
|
||||
log.WithField("host", akURL.Host).WithField("path", akURL.Path).Debug("using unix socket")
|
||||
socketPath := akURL.Host
|
||||
client = http.Client{
|
||||
Transport: web.NewUserAgentTransport(
|
||||
constants.UserAgentOutpost(),
|
||||
web.NewTracingTransport(
|
||||
rsp.Context(),
|
||||
&http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
akURL.Scheme = "http"
|
||||
akURL.Host = "localhost"
|
||||
} else {
|
||||
client = http.Client{
|
||||
Transport: web.NewUserAgentTransport(
|
||||
constants.UserAgentOutpost(),
|
||||
web.NewTracingTransport(
|
||||
rsp.Context(),
|
||||
GetTLSTransport(),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
apiConfig := api.NewConfiguration()
|
||||
apiConfig.Host = akURL.Host
|
||||
apiConfig.Scheme = akURL.Scheme
|
||||
apiConfig.HTTPClient = &http.Client{
|
||||
Transport: web.NewUserAgentTransport(
|
||||
constants.UserAgentOutpost(),
|
||||
web.NewTracingTransport(
|
||||
rsp.Context(),
|
||||
GetTLSTransport(),
|
||||
),
|
||||
),
|
||||
}
|
||||
apiConfig.HTTPClient = &client
|
||||
apiConfig.Servers = api.ServerConfigurations{
|
||||
{
|
||||
URL: fmt.Sprintf("%sapi/v3", akURL.Path),
|
||||
@@ -77,8 +104,6 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
// create the API client, with the transport
|
||||
apiClient := api.NewAPIClient(apiConfig)
|
||||
|
||||
log := log.WithField("logger", "authentik.outpost.ak-api-controller")
|
||||
|
||||
// Because we don't know the outpost UUID, we simply do a list and pick the first
|
||||
// The service account this token belongs to should only have access to a single outpost
|
||||
outposts, _ := retry.DoWithData[*api.PaginatedOutpostList](
|
||||
@@ -110,6 +135,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
// doGlobalSetup(outpost, akConfig)
|
||||
|
||||
ac := &APIController{
|
||||
akURL: originalAkURL,
|
||||
Client: apiClient,
|
||||
GlobalConfig: akConfig,
|
||||
|
||||
@@ -124,7 +150,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
}
|
||||
ac.logger.WithField("embedded", ac.IsEmbedded()).Info("Outpost mode")
|
||||
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
|
||||
err = ac.initEvent(akURL, outpost.Pk)
|
||||
err = ac.initEvent(outpost.Pk, 0)
|
||||
if err != nil {
|
||||
go ac.recentEvents()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -31,9 +32,11 @@ func (ac *APIController) getWebsocketURL(akURL url.URL, outpostUUID string, quer
|
||||
return wsUrl
|
||||
}
|
||||
|
||||
func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error {
|
||||
func (ac *APIController) initEvent(outpostUUID string, attempt int) error {
|
||||
akURL := ac.akURL
|
||||
query := akURL.Query()
|
||||
query.Set("instance_uuid", ac.instanceUUID.String())
|
||||
query.Set("attempt", strconv.Itoa(attempt))
|
||||
|
||||
authHeader := fmt.Sprintf("Bearer %s", ac.token)
|
||||
|
||||
@@ -45,9 +48,19 @@ func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error {
|
||||
dialer := websocket.Dialer{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
}
|
||||
if akURL.Scheme == "unix" {
|
||||
ac.logger.WithField("host", akURL.Host).WithField("path", akURL.Path).Debug("websocket is using unix connection")
|
||||
socketPath := akURL.Host
|
||||
dialer.NetDialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
}
|
||||
akURL.Scheme = "http"
|
||||
akURL.Host = "localhost"
|
||||
} else {
|
||||
dialer.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: config.Get().AuthentikInsecure,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
wsu := ac.getWebsocketURL(akURL, outpostUUID, query).String()
|
||||
@@ -95,18 +108,10 @@ func (ac *APIController) recentEvents() {
|
||||
return
|
||||
}
|
||||
ac.wsIsReconnecting = true
|
||||
u := url.URL{
|
||||
Host: ac.Client.GetConfig().Host,
|
||||
Scheme: ac.Client.GetConfig().Scheme,
|
||||
Path: strings.ReplaceAll(ac.Client.GetConfig().Servers[0].URL, "api/v3", ""),
|
||||
}
|
||||
attempt := 1
|
||||
_ = retry.Do(
|
||||
func() error {
|
||||
q := u.Query()
|
||||
q.Set("attempt", strconv.Itoa(attempt))
|
||||
u.RawQuery = q.Encode()
|
||||
err := ac.initEvent(u, ac.Outpost.Pk)
|
||||
err := ac.initEvent(ac.Outpost.Pk, attempt)
|
||||
attempt += 1
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
)
|
||||
|
||||
@@ -21,9 +24,15 @@ var Command = &cobra.Command{
|
||||
|
||||
func check() int {
|
||||
h := &http.Client{
|
||||
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
|
||||
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck",
|
||||
&http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return net.Dial("unix", path.Join(os.TempDir(), ak.MetricsSocketName))
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
url := fmt.Sprintf("http://%s/outpost.goauthentik.io/ping", config.Get().Listen.Metrics)
|
||||
url := "http://localhost/outpost.goauthentik.io/ping"
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to send healthcheck request")
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package ak
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/internal/utils/sentry"
|
||||
"goauthentik.io/internal/utils/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
OutpostInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
MetricsSocketName = "authentik-metrics.sock"
|
||||
OutpostInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "authentik_outpost_info",
|
||||
Help: "Outpost info",
|
||||
}, []string{"outpost_name", "outpost_type", "uuid", "version", "build"})
|
||||
@@ -19,3 +29,45 @@ var (
|
||||
Help: "Connection status",
|
||||
}, []string{"outpost_name", "outpost_type", "uuid"})
|
||||
)
|
||||
|
||||
func MetricsRouter() *mux.Router {
|
||||
m := mux.NewRouter()
|
||||
m.Use(sentry.SentryNoSampleMiddleware)
|
||||
m.HandleFunc("/outpost.goauthentik.io/ping", func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(204)
|
||||
})
|
||||
m.Path("/metrics").Handler(promhttp.Handler())
|
||||
return m
|
||||
}
|
||||
|
||||
func RunMetricsServer(listen string, router *mux.Router) {
|
||||
l := log.WithField("logger", "authentik.outpost.metrics").WithField("listen", listen)
|
||||
l.Info("Starting Metrics server")
|
||||
err := http.ListenAndServe(listen, router)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("Failed to start metrics listener")
|
||||
}
|
||||
}
|
||||
|
||||
func RunMetricsUnix(router *mux.Router) {
|
||||
socketPath := path.Join(os.TempDir(), MetricsSocketName)
|
||||
l := log.WithField("logger", "authentik.outpost.metrics").WithField("listen", socketPath)
|
||||
_ = os.Remove(socketPath)
|
||||
ln, err := unix.Listen(socketPath)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to listen")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := ln.Close()
|
||||
_ = os.Remove(socketPath)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to close listener")
|
||||
}
|
||||
}()
|
||||
l.WithField("listen", socketPath).Info("Starting Metrics server")
|
||||
err = http.Serve(ln, router)
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("Failed to start metrics listener")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/crypto"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ldap/metrics"
|
||||
"goauthentik.io/internal/utils"
|
||||
|
||||
"beryju.io/ldap"
|
||||
@@ -63,9 +62,7 @@ func (ls *LDAPServer) Type() string {
|
||||
return "ldap"
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) StartLDAPServer() error {
|
||||
listen := config.Get().Listen.LDAP
|
||||
|
||||
func (ls *LDAPServer) StartLDAPServer(listen string) error {
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
ls.log.WithField("listen", listen).WithError(err).Warning("Failed to listen (SSL)")
|
||||
@@ -89,26 +86,40 @@ func (ls *LDAPServer) StartLDAPServer() error {
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) Start() error {
|
||||
listenLdap := config.Get().Listen.LDAP
|
||||
listenLdaps := config.Get().Listen.LDAPS
|
||||
listenMetrics := config.Get().Listen.Metrics
|
||||
metricsRouter := ak.MetricsRouter()
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(3)
|
||||
wg.Add(len(listenLdap) + len(listenLdaps) + 1 + len(listenMetrics))
|
||||
for _, listen := range listenLdap {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartLDAPServer(listen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, listen := range listenLdaps {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartLDAPTLSServer(listen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
metrics.RunServer()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartLDAPServer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ls.StartLDAPTLSServer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ak.RunMetricsUnix(metricsRouter)
|
||||
}()
|
||||
for _, listen := range listenMetrics {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ak.RunMetricsServer(listen, metricsRouter)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net"
|
||||
|
||||
"github.com/pires/go-proxyproto"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/utils"
|
||||
)
|
||||
|
||||
@@ -37,8 +36,7 @@ func (ls *LDAPServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certifica
|
||||
return ls.defaultCert, nil
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) StartLDAPTLSServer() error {
|
||||
listen := config.Get().Listen.LDAPS
|
||||
func (ls *LDAPServer) StartLDAPTLSServer(listen string) error {
|
||||
tlsConfig := utils.GetTLSConfig()
|
||||
tlsConfig.GetCertificate = ls.getCertificates
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user