Merge branch 'main' into freethreading-explorations

This commit is contained in:
Marc 'risson' Schmitt
2026-08-25 16:04:42 +02:00
446 changed files with 16028 additions and 14967 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +1,2 @@
custom: https://goauthentik.io/pricing/
github: goauthentik

View File

@@ -37,7 +37,7 @@ runs:
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v5
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v5
with:
enable-cache: true
- name: Setup python
@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup pnpm

View File

@@ -20,6 +20,13 @@ updates:
- dependencies
cooldown:
default-days: 3
groups:
codeql:
patterns:
- "github/codeql-action/*"
regclient:
patterns:
- "regclient/actions//*"
#endregion

View File

@@ -24,3 +24,4 @@ Use `closes #N` to auto-close an issue on merge. Use `refs #N` for related issue
- [ ] The project has been linted, built, and tested (`make all`)
- [ ] The documentation has been updated and formatted (`make docs`)
- [ ] I have read the [AI usage policy](https://github.com/goauthentik/authentik/blob/main/AI_POLICY.md).

View File

@@ -61,7 +61,7 @@ jobs:
with:
ref: "${{ inputs.ref }}"
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- if: "${{ inputs.should-cache }}"
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:

View File

@@ -42,6 +42,7 @@ jobs:
image-build-args: "${{ inputs.image-build-args }}"
should-cache: "${{ inputs.should-cache }}"
cache-suffix: "${{ inputs.cache-suffix }}"
build-arm64:
uses: ./.github/workflows/_reusable-container-build-single.yml
secrets: inherit
@@ -54,6 +55,7 @@ jobs:
image-build-args: "${{ inputs.image-build-args }}"
should-cache: "${{ inputs.should-cache }}"
cache-suffix: "${{ inputs.cache-suffix }}"
merge:
runs-on: ubuntu-latest
needs:
@@ -65,22 +67,172 @@ jobs:
with:
artifact-ids: "${{ needs.build-amd64.outputs.artifact-id }},${{ needs.build-arm64.outputs.artifact-id }}"
merge-multiple: true
- name: merge
- name: Merge and flatten architecture indexes
shell: bash
run: |
regctl image import ocidir://${{ inputs.image-name }}-amd64:build container/${{ inputs.image-name }}-amd64.oci.tar
regctl image import ocidir://${{ inputs.image-name }}-arm64:build container/${{ inputs.image-name }}-arm64.oci.tar
set -euo pipefail
regctl index create ocidir://${{ inputs.image-name }}:build
image_name="${{ inputs.image-name }}"
target="ocidir://${image_name}:build"
regctl index add ocidir://${{ inputs.image-name }}:build --ref ocidir://${{ inputs.image-name }}-amd64:build --desc-platform linux/amd64
regctl index add ocidir://${{ inputs.image-name }}:build --ref ocidir://${{ inputs.image-name }}-arm64:build --desc-platform linux/arm64
regctl image import \
"ocidir://${image_name}-amd64:build" \
"container/${image_name}-amd64.oci.tar"
regctl image import \
"ocidir://${image_name}-arm64:build" \
"container/${image_name}-arm64.oci.tar"
regctl index create "${target}"
add_platform() {
local arch="$1"
local source="ocidir://${image_name}-${arch}:build"
local source_index
local image_digest
local attestation_digest
source_index="$(
regctl manifest get "${source}" --format raw-body
)"
# Select the runnable image manifest from the architecture index.
image_digest="$(
jq -er --arg arch "${arch}" '
[
.manifests[]
| select(
.mediaType
== "application/vnd.oci.image.manifest.v1+json"
and .platform.os == "linux"
and .platform.architecture == $arch
)
| .digest
]
| if length == 1 then .[0]
else error(
"expected exactly one linux/" + $arch
+ " image manifest"
)
end
' <<<"${source_index}"
)"
# Select the BuildKit attestation associated with that image.
attestation_digest="$(
jq -er --arg image_digest "${image_digest}" '
[
.manifests[]
| select(
.mediaType
== "application/vnd.oci.image.manifest.v1+json"
and .platform.os == "unknown"
and .platform.architecture == "unknown"
and .annotations["vnd.docker.reference.type"]
== "attestation-manifest"
and .annotations["vnd.docker.reference.digest"]
== $image_digest
)
| .digest
]
| if length == 1 then .[0]
else error(
"expected exactly one attestation for "
+ $image_digest
)
end
' <<<"${source_index}"
)"
# Add the runnable image manifest directly to the final index.
regctl index add "${target}" \
--ref "${source}@${image_digest}" \
--desc-platform "linux/${arch}"
# Add the associated attestation directly beside the image.
regctl index add "${target}" \
--ref "${source}@${attestation_digest}" \
--desc-platform "unknown/unknown" \
--desc-annotation \
"vnd.docker.reference.type=attestation-manifest" \
--desc-annotation \
"vnd.docker.reference.digest=${image_digest}"
}
add_platform amd64
add_platform arm64
final_index="$(
regctl manifest get "${target}" --format raw-body
)"
# Ensure the result is a flat index containing two runnable image
# manifests and their two attestations, with no nested OCI indexes.
jq -e '
.mediaType == "application/vnd.oci.image.index.v1+json"
and (.manifests | length == 4)
and (
[.manifests[].mediaType]
| all(
. == "application/vnd.oci.image.manifest.v1+json"
)
)
and (
[
.manifests[]
| select(.platform.os == "linux")
| .platform.architecture
]
| sort
== ["amd64", "arm64"]
)
and (
[
.manifests[]
| select(
.platform.os == "unknown"
and .platform.architecture == "unknown"
and .annotations["vnd.docker.reference.type"]
== "attestation-manifest"
)
]
| length == 2
)
and (
(
[
.manifests[]
| select(.platform.os == "linux")
| .digest
]
| sort
)
==
(
[
.manifests[]
| select(
.platform.os == "unknown"
and .platform.architecture == "unknown"
)
| .annotations["vnd.docker.reference.digest"]
]
| sort
)
)
' <<<"${final_index}"
mkdir -p build/container/
regctl image export ocidir://${{ inputs.image-name }}:build build/container/${{ inputs.image-name }}.oci.tar
regctl image export \
"${target}" \
"build/container/${image_name}.oci.tar"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: "container-build-${{ inputs.image-name }}"
path: build/
if-no-files-found: error
retention-days: 2
include-hidden-files: true
include-hidden-files: true

View File

@@ -50,6 +50,6 @@ jobs:
- check-changes-applied
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@@ -113,7 +113,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Container Registry
if: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -143,6 +143,6 @@ jobs:
- build-container
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@@ -479,7 +479,7 @@ jobs:
- test-rust
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
with:
jobs: ${{ toJSON(needs) }}
publish:

View File

@@ -38,7 +38,11 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v8
with:
version: latest
# latest (v2.13.0) bundles honnef.co/go/tools v0.8.0-rc.1, whose nilness
# analyzer panics on getsentry/sentry-go ("unhandled builtin recover",
# https://github.com/dominikh/go-tools/issues/1725). Unpin once a
# release with the fix ships.
version: v2.12.2
args: --timeout 5000s --verbose
skip-cache: true
test-unittest:
@@ -63,7 +67,7 @@ jobs:
- test-unittest
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
with:
jobs: ${{ toJSON(needs) }}
build-binary:
@@ -75,7 +79,6 @@ jobs:
fail-fast: false
matrix:
type:
- proxy
- ldap
- radius
- rac
@@ -88,24 +91,6 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Pin pnpm store directory
run: |
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
pnpm --dir web install --frozen-lockfile
- name: Build web
run: pnpm --dir web run build-proxy
- name: Build outpost
run: |
set -x

View File

@@ -80,7 +80,7 @@ jobs:
- lint
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
with:
jobs: ${{ toJSON(needs) }}
test:

View File

@@ -28,10 +28,10 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8

View File

@@ -103,7 +103,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:

View File

@@ -314,7 +314,7 @@ jobs:
name: Release ${{ needs.check-inputs.outputs.version }}
draft: true
prerelease: ${{ inputs.release_reason == 'prerelease' }}
generate_release_notes: true
generate_release_notes: false
body: |
See ${{ needs.check-inputs.outputs.changelog-url }}
files: |

View File

@@ -7,8 +7,8 @@ It is a **polyglot monorepo**. Most work lands in one of the subtrees below; whe
| Language | Where | What it is | Deeper guide |
| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
| **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, proxy, RAC, RADIUS) and the front reverse-proxy that fronts Django. | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) |
| **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) |
@@ -19,8 +19,8 @@ The Python core and the web UI talk through a **generated OpenAPI client** — n
```
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy)
internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
internal/ # Shared Go: outpost implementations, config
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
packages/ # Shared workspace packages, polyglot:
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
@@ -154,7 +154,7 @@ Authoritative contributor docs live under `website/docs/developer-docs/` and are
| --------------- | ------------------------------------------------------------------------ |
| Core server | Python 3.14, Django 5.2 + Django REST Framework, Channels (ASGI) |
| Background work | Dramatiq (Postgres broker) |
| Datastore | PostgreSQL (multi-tenant via `django-tenants`) + Redis |
| Datastore | PostgreSQL (multi-tenant via `django-tenants`) |
| Outposts | Go 1.26 (`goauthentik.io` module) — LDAP, proxy, RAC, RADIUS |
| Native services | Rust (2024 edition, `axum`) — server/worker components + shared crates |
| Web UI | TypeScript, Lit 3, PatternFly 4 (see `web/`) |

149
Cargo.lock generated
View File

@@ -360,7 +360,6 @@ dependencies = [
"authentik-common",
"axum",
"axum-server",
"client-ip",
"durstr",
"eyre",
"futures",
@@ -860,15 +859,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "client-ip"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729"
dependencies = [
"http",
]
[[package]]
name = "cmake"
version = "0.1.57"
@@ -1186,6 +1176,37 @@ dependencies = [
"uuid",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "der-parser"
version = "10.0.0"
@@ -1360,10 +1381,11 @@ dependencies = [
[[package]]
name = "eyre"
version = "0.6.12"
version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3"
dependencies = [
"autocfg",
"indenter",
"once_cell",
]
@@ -1467,9 +1489,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@@ -1482,9 +1504,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@@ -1492,15 +1514,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@@ -1520,38 +1542,38 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@@ -1644,9 +1666,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -1799,9 +1821,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
@@ -2133,6 +2155,41 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2910,6 +2967,15 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3249,9 +3315,9 @@ dependencies = [
[[package]]
name = "rcgen"
version = "0.14.8"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
dependencies = [
"aws-lc-rs",
"rustls-pki-types",
@@ -3763,14 +3829,15 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.21.0"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"jiff",
"serde_core",
"serde_json",
"time",
@@ -4793,9 +4860,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.2",
"js-sys",

View File

@@ -31,7 +31,6 @@ axum-extra = { version = "= 0.12.6", default-features = false, features = [
] }
base64 = "= 0.23.1"
clap = { version = "= 4.6.6", features = ["derive", "env"] }
client-ip = { version = "0.2.1" }
chrono = { version = "0.4.45", features = ["serde"] }
color-eyre = "= 0.6.5"
colored = "= 3.1.1"
@@ -42,10 +41,10 @@ config-rs = { package = "config", version = "= 0.15.22", default-features = fals
console-subscriber = "= 0.5.0"
dotenvy = "= 0.15.7"
durstr = "= 0.5.1"
eyre = "= 0.6.12"
futures = "= 0.3.33"
eyre = "= 0.6.14"
futures = "= 0.3.34"
glob = "= 0.3.4"
http-body-util = "= 0.1.4"
http-body-util = "= 0.1.5"
hyper = { version = "= 1.11.0", features = ["client", "http1", "http2"] }
hyper-rustls = { version = "= 0.27.9", default-features = false, features = [
"aws-lc-rs",
@@ -77,7 +76,7 @@ pin-project-lite = "= 0.2.17"
pyo3 = "= 0.29.0"
pyo3-build-config = "= 0.29.0"
rand = "= 0.10.2"
rcgen = { version = "= 0.14.8", default-features = false, features = [
rcgen = { version = "= 0.14.9", default-features = false, features = [
"aws_lc_rs",
"fips",
] }
@@ -111,7 +110,7 @@ sentry = { version = "= 0.49.1", default-features = false, features = [
serde = { version = "= 1.0.229", features = ["derive"] }
serde_json = "= 1.0.151"
serde_repr = "= 0.1.21"
serde_with = { version = "= 3.21.0", default-features = false, features = [
serde_with = { version = "= 3.22.0", default-features = false, features = [
"base64",
] }
sqlx = { version = "= 0.9.0", default-features = false, features = [
@@ -152,7 +151,7 @@ tracing-subscriber = { version = "= 0.3.23", features = [
"tracing-log",
] }
url = "= 2.5.8"
uuid = { version = "= 1.24.0", features = ["serde", "v4"] }
uuid = { version = "= 1.24.1", features = ["serde", "v4"] }
ak-axum = { package = "authentik-axum", version = "2026.11.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.11.0-rc1", path = "./packages/client-rust" }

View File

@@ -10,7 +10,7 @@ DOCKER_IMAGE ?= "authentik:test"
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED_INPLACE = sed -i ''
SED_INPLACE = /usr/bin/sed -i ''
else
SED_INPLACE = sed -i
endif

View File

@@ -1,4 +1,4 @@
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce its existence and release the fixed version.
## Independent audits and pentests
@@ -6,7 +6,7 @@ We are committed to engaging in regular pentesting and security audits of authen
## What authentik classifies as a CVE
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is:
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is either a vulnerability or exposure. Per NIST, A vulnerability is:
“Weakness in an information system, system security procedures, internal controls, or implementation that could be exploited or triggered by a threat source.”
@@ -20,15 +20,12 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| --------- | --------- |
| 2026.2.x | ✅ |
| 2026.5.x | ✅ |
| 2026.8.x | ✅ |
## Reporting a Vulnerability
If you discover a potential vulnerability, please report it responsibly through one of the following channels:
- **Email**: [security@goauthentik.io](mailto:security@goauthentik.io)
- **GitHub**: Submit a private security advisory via our [repositorys advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)
If you discover a potential vulnerability, please report it responsibly by submitting a private security advisory via our [repositorys advisory portal](https://github.com/goauthentik/authentik/security/advisories/new).
When submitting a report, please include as much detail as possible, such as:
@@ -96,7 +93,7 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
## Disclosure process
1. Report from Github or Issue is reported via Email as listed above.
1. Vulnerability is reported via a GitHub Security Advisory, as listed above.
2. The authentik Security team will try to reproduce the issue and ask for more information if required.
3. A severity level is assigned.
4. A fix is created, and if possible tested by the issue reporter.
@@ -107,3 +104,9 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
## Getting security notifications
To get security notifications, subscribe to the mailing list [here](https://groups.google.com/g/authentik-security-announcements) or join the [discord](https://goauthentik.io/discord) server.
## Contact
For general inquiries, you can reach the authentik Security team at [security@goauthentik.io](mailto:security@goauthentik.io).
_Please do not use email for vulnerability reports, instead use our [repositorys advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)._

View File

@@ -118,7 +118,7 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
props[_flag.key]["description"] = _flag.description
if _flag.deprecated:
props[_flag.key]["deprecated"] = _flag.deprecated
if visibility == "public":
if visibility == "public" and not _flag.deprecated:
required.append(_flag.key)
return build_object_type(props, required=required)

View File

@@ -18,13 +18,17 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
assigned_application_slug = ReadOnlyField(
source="application.slug", allow_null=True, required=False
)
assigned_application_name = ReadOnlyField(
source="application.name", allow_null=True, required=False
)
assigned_backchannel_application_slug = ReadOnlyField(
source="backchannel_application.slug", allow_null=True
source="backchannel_application.slug", allow_null=True, required=False
)
assigned_backchannel_application_name = ReadOnlyField(
source="backchannel_application.name", allow_null=True
source="backchannel_application.name", allow_null=True, required=False
)
component = SerializerMethodField()
@@ -53,7 +57,12 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"verbose_name_plural",
"meta_model_name",
]
extra_kwargs = {
# This serializer is only a general read serializer for listing/reading
# providers without their specific type,
# setting whether authorization_flow/invalidation_flow are required
# is up to the child serializer
extra_kwargs = {}
extra_write_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
"invalidation_flow": {"required": True, "allow_null": False},
}

View File

@@ -84,7 +84,6 @@ from authentik.core.models import (
USER_PATH_SERVICE_ACCOUNT,
USERNAME_MAX_LENGTH,
Group,
Session,
Token,
TokenIntents,
User,
@@ -556,7 +555,7 @@ class UsersFilter(FilterSet):
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
path_startswith = CharFilter(field_name="path", method="filter_path_startswith")
type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type")
@@ -585,6 +584,15 @@ class UsersFilter(FilterSet):
return queryset.filter(groups__is_superuser=True).distinct()
return queryset.exclude(groups__is_superuser=True).distinct()
def filter_path_startswith(self, queryset, name, value):
"""Filter users by the given path and any of its sub-paths. A plain `startswith`
lookup would also match sibling paths sharing the same prefix, so that `foo/bar`
would incorrectly match users in `foo/bar2`."""
value = value.rstrip("/")
if not value:
return queryset
return queryset.filter(Q(path=value) | Q(path__startswith=f"{value}/"))
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
@@ -1117,11 +1125,3 @@ class UserViewSet(
)
}
)
def partial_update(self, request: Request, *args, **kwargs) -> Response:
response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object()
if not instance.is_active:
Session.objects.filter(authenticatedsession__user=instance).delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.17 on 2026-08-19 10:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0063_actor"),
("authentik_rbac", "0011_initialpermissionspermission"),
]
operations = [
migrations.AddIndex(
model_name="user",
index=models.Index(
fields=["username", "is_active", "type"], name="authentik_c_usernam_2f0e4b_idx"
),
),
]

View File

@@ -402,6 +402,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
models.Index(fields=["type"]),
models.Index(fields=["date_joined"]),
models.Index(fields=["last_updated"]),
models.Index(fields=["username", "is_active", "type"]),
]
def __str__(self):
@@ -609,15 +610,14 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
def locale(self, request: HttpRequest | None = None) -> str:
"""Get the locale the user has configured"""
if request and hasattr(request, "LANGUAGE_CODE"):
return request.LANGUAGE_CODE
try:
return self.attributes.get("settings", {}).get("locale", "")
locale = self.attributes.get("settings", {}).get("locale", "")
if locale:
return locale
except Exception as exc: # noqa
LOGGER.warning("Failed to get default locale", exc=exc)
if request:
return request.brand.locale
if request and hasattr(request, "LANGUAGE_CODE"):
return request.LANGUAGE_CODE
return ""
@property

View File

@@ -1,6 +1,8 @@
"""authentik core signals"""
from channels.layers import get_channel_layer
from contextlib import contextmanager
from contextvars import ContextVar
from django.contrib.auth.signals import user_logged_in
from django.core.cache import cache
from django.db.models import Model
@@ -17,9 +19,7 @@ from authentik.core.models import (
User,
default_token_duration,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.lib.models import ExpiringModel
from authentik.root.ws.consumer import build_device_group
password_changed = Signal()
"""Arguments: user: User, password: str"""
@@ -33,6 +33,47 @@ admin_authenticated_session_deleted = Signal()
LOGGER = get_logger()
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP = ContextVar[bool](
"authentik_core_inhibit_deactivation_session_cleanup",
default=False,
)
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP = ContextVar[bool](
"authentik_core_inhibit_deactivation_token_cleanup",
default=False,
)
@contextmanager
def deactivation_inhibit_cleanup(*, sessions: bool = True, tokens: bool = True):
"""
Prevent the automatic cleanup that runs when a deactivated user is saved,
for callers that revoke sessions and/or tokens themselves (e.g. enterprise
revocation). `sessions` inhibits session deletion, `tokens` inhibits
provider token revocation; both are inhibited by default. Nested uses can
add inhibition but not remove the outer context's.
"""
reset_sessions = _CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.set(
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.get() or sessions
)
reset_tokens = _CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.set(
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.get() or tokens
)
try:
yield
finally:
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.reset(reset_sessions)
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.reset(reset_tokens)
def deactivation_session_cleanup_inhibited() -> bool:
"""Whether deactivation session cleanup is inhibited in the current context"""
return _CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.get()
def deactivation_token_cleanup_inhibited() -> bool:
"""Whether deactivation token cleanup is inhibited in the current context"""
return _CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.get()
@receiver(post_save, sender=Application)
def post_save_application(sender: type[Model], instance, created: bool, **_):
@@ -53,15 +94,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
AuthenticatedSession.create_from_request(request, user)
if not RefreshOtherFlowsAfterAuthentication.get():
@receiver(post_save, sender=User)
def user_deactivated_delete_sessions(sender: type[Model], instance: User, **_):
"""Delete all of a user's sessions when they are deactivated"""
if instance.is_active:
return
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
if device_cookie:
layer.group_send_blocking(
build_device_group(device_cookie),
{"type": "event.session.authenticated"},
)
if deactivation_session_cleanup_inhibited():
return
Session.objects.filter(authenticatedsession__user=instance).delete()
LOGGER.debug("Deleted deactivated user's sessions", user=instance.username)
@receiver(post_delete, sender=AuthenticatedSession)

View File

@@ -130,18 +130,51 @@ class SourceFlowManager:
self.user_properties = self.mapper.build_object_properties(
object_type=User, request=request, user=None, **self.user_info
)
groups_manager = self.mapper.get_manager(Group, ["group_id", *self.user_info.keys()])
self.groups_properties = {
group_id: self.mapper.build_object_properties(
object_type=Group,
manager=groups_manager,
request=request,
user=None,
group_id=group_id,
**self.user_info,
)
for group_id in self.user_properties.setdefault("groups", [])
for group_id in self._keyable_group_ids(self.user_properties.setdefault("groups", []))
}
del self.user_properties["groups"]
def _keyable_group_ids(self, group_ids: list[Any]) -> list[Any]:
"""Drop group identifiers that cannot be used as a `groups_properties` key.
An unhashable identifier, such as an object in an IdP's `groups` claim,
raised `TypeError` out of the constructor and surfaced as HTTP 500,
locking every member of that group out of the source. Skipped entries are
recorded so a user arriving with fewer groups than the IdP granted stays
visible to an operator.
"""
keyable = []
skipped = []
for group_id in group_ids:
try:
hash(group_id)
except TypeError:
skipped.append(group_id)
else:
keyable.append(group_id)
if skipped:
self._logger.warning("Skipping groups with an unusable identifier", groups=skipped)
Event.new(
EventAction.CONFIGURATION_ERROR,
message=(
f"Source '{self.source.name}' returned {len(skipped)} group(s) whose "
"identifier is not a string; they were not applied to the user."
),
source=self.source,
groups=skipped,
).from_http(self.request)
return keyable
def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
"""decide which action should be taken"""
# When request is authenticated, always link
@@ -154,7 +187,7 @@ class SourceFlowManager:
if existing := self.user_connection_type.objects.filter(
source=self.source, identifier=self.identifier
).first():
existing = self.update_user_connection(existing)
existing = self.update_user_connection(existing, **kwargs)
return Action.AUTH, existing
return Action.LINK, new_connection

View File

@@ -0,0 +1,91 @@
"""Test core signals"""
from django.test import TestCase
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import AuthenticatedSession, Session, User
from authentik.core.signals import deactivation_inhibit_cleanup
from authentik.core.tests.utils import create_test_session, create_test_user
from authentik.lib.generators import generate_id
class TestUserDeactivatedSignal(TestCase):
"""Test that deactivating a user always deletes their sessions"""
def test_deactivate_deletes_sessions(self):
"""Saving a deactivated user deletes all their sessions"""
user = create_test_user()
sessions = [create_test_session(user) for _ in range(3)]
other_user = create_test_user()
other_session = create_test_session(other_user)
self.assertEqual(AuthenticatedSession.objects.filter(user=user).count(), 3)
user.is_active = False
user.save()
for session in sessions:
self.assertFalse(
Session.objects.filter(session_key=session.session.session_key).exists()
)
self.assertFalse(AuthenticatedSession.objects.filter(user=user).exists())
# Other users' sessions are untouched
self.assertTrue(
Session.objects.filter(session_key=other_session.session.session_key).exists()
)
def test_create_inactive_user(self):
"""A user can be created as inactive and saved again without errors"""
user = User.objects.create(username=generate_id(), name=generate_id(), is_active=False)
user.name = generate_id()
user.save()
user.save(update_fields=["name"])
self.assertFalse(user.is_active)
self.assertFalse(AuthenticatedSession.objects.filter(user=user).exists())
def test_deactivate_inhibited(self):
"""deactivation_inhibit_cleanup leaves the user's sessions alone"""
user = create_test_user()
session = create_test_session(user)
user.is_active = False
with deactivation_inhibit_cleanup():
user.save()
self.assertFalse(user.is_active)
self.assertTrue(Session.objects.filter(session_key=session.session.session_key).exists())
# Once the context manager exits, deactivating saves delete sessions again
user.save()
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
def test_deactivate_inhibit_tokens_only(self):
"""Inhibiting only token cleanup still deletes sessions"""
user = create_test_user()
session = create_test_session(user)
user.is_active = False
with deactivation_inhibit_cleanup(sessions=False, tokens=True):
user.save()
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
def test_deactivate_via_blueprint(self):
"""Deactivating a user via blueprint deletes their sessions"""
user = create_test_user()
session = create_test_session(user)
importer = Importer.from_string(f"""version: 1
entries:
- model: authentik_core.user
state: present
identifiers:
username: {user.username}
attrs:
name: {user.name}
is_active: false
""")
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())

View File

@@ -1,7 +1,9 @@
"""Test Source flow_manager"""
from django.contrib.auth.models import AnonymousUser
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from guardian.shortcuts import get_anonymous_user
@@ -10,13 +12,18 @@ from authentik.core.sources.flow_manager import Action
from authentik.core.sources.matcher import MatchFailureReason
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import RequestFactory, create_test_flow
from authentik.events.models import Event, EventAction
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.denied import AccessDeniedResponse
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.sources.oauth.models import (
OAuthSource,
OAuthSourcePropertyMapping,
UserOAuthSourceConnection,
)
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
@@ -82,6 +89,49 @@ class TestSourceFlowManager(TestCase):
reverse("authentik_core:if-user") + "#/settings;page-sources",
)
def test_authenticated_auth_forwards_kwargs(self):
"""kwargs must reach update_user_connection() for the *existing* connection.
Sources carry their credential in kwargs — Plex's `plex_token`, for
example. The AUTH branch dropped them, so `update_user_connection()`
read `kwargs.get(...)` as None and overwrote a valid stored credential
with NULL on every subsequent login through the source.
`get_action()` calls `update_user_connection()` twice on this path: once
for a fresh unsaved connection (which always forwarded kwargs) and once
for the existing row (which did not). Recording per call and keying on
whether the connection is saved is what separates them — an accumulated
dict is satisfied by the first call and proves nothing.
"""
user = User.objects.create(username="foo", email="foo@bar.baz")
existing_connection = UserOAuthSourceConnection.objects.create(
user=user, source=self.source, identifier=self.identifier
)
request = self.request_factory.get("/", user=user)
calls: list[tuple] = []
class RecordingFlowManager(OAuthSourceFlowManager):
def update_user_connection(self, connection, **kwargs):
calls.append((connection.pk, kwargs))
return connection
flow_manager = RecordingFlowManager(self.source, request, self.identifier, {"info": {}}, {})
action, connection = flow_manager.get_action(some_token="a-credential")
self.assertEqual(action, Action.AUTH)
self.assertEqual(connection.pk, existing_connection.pk)
for_existing = [kw for pk, kw in calls if pk == existing_connection.pk]
self.assertEqual(
len(for_existing), 1, "expected exactly one update for the existing connection"
)
self.assertEqual(
for_existing[0].get("some_token"),
"a-credential",
"get_action() dropped kwargs when updating the existing connection",
)
def test_authenticated_auth(self):
"""Test authenticated user linking"""
user = User.objects.create(username="foo", email="foo@bar.baz")
@@ -112,6 +162,27 @@ class TestSourceFlowManager(TestCase):
self.assertIsNone(connection.pk)
flow_manager.get_flow()
def test_unusable_group_identifier_does_not_abort(self):
"""Test a group identifier that cannot be used as a key being skipped (#25191)"""
request = self.request_factory.get("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source,
request,
self.identifier,
{"info": {"groups": ["usable", {"id": "unusable"}, ["also-unusable"]]}},
{},
)
# Enrolling has to remain possible: raising here surfaced as HTTP 500 and
# locked out every member of such a group.
self.assertEqual(list(flow_manager.groups_properties.keys()), ["usable"])
self.assertEqual(flow_manager.get_action()[0], Action.ENROLL)
self.assertEqual(flow_manager.get_flow().status_code, 302)
event = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR).first()
self.assertIsNotNone(event)
self.assertIn("2 group(s)", event.context["message"])
self.assertEqual(event.context["groups"], [{"id": "unusable"}, ["also-unusable"]])
def test_unauthenticated_enroll_email(self):
"""Test un-authenticated user enrolling (link on email)"""
User.objects.create(username="foo", email="foo@bar.baz")
@@ -268,3 +339,37 @@ class TestSourceFlowManager(TestCase):
self.assertIsInstance(response, AccessDeniedResponse)
self.assertEqual(response.error_message, "foo")
def calculate_group_property_mapping_queries(self, group_count: int) -> int:
"""Build group properties for `group_count` groups, check them, and return the
number of queries it took"""
group_ids = [f"group-{index}" for index in range(group_count)]
with CaptureQueriesContext(connection) as queries:
flow_manager = OAuthSourceFlowManager(
self.source,
self.request_factory.get("/", user=AnonymousUser()),
self.identifier,
{"info": {"groups": group_ids}},
{},
)
self.assertEqual(len(flow_manager.groups_properties), group_count)
for group_id in group_ids:
# Each group must get its own properties, from both the base properties and
# the property mapping
self.assertEqual(
flow_manager.groups_properties[group_id],
{"name": group_id, "attributes": {"group_id": group_id}},
)
return len(queries.captured_queries)
def test_group_properties_reuse_mapping_manager(self):
"""Test that building group properties doesn't re-fetch and re-compile the
source's group property mappings for every single group"""
self.source.group_property_mappings.add(
OAuthSourcePropertyMapping.objects.create(
name=generate_id(),
expression="""return {"attributes": {"group_id": group_id}}""",
)
)
# Building N groups must take fewer than N queries
self.assertLess(self.calculate_group_property_mapping_queries(100), 100)

View File

@@ -3,6 +3,7 @@
from unittest.mock import patch
from django.contrib.auth.hashers import make_password
from django.http import HttpRequest
from django.test.testcases import TestCase
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
@@ -40,6 +41,46 @@ class TestUsers(TestCase):
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
def test_locale_user_setting_wins_over_language_code(self):
"""Test the user's saved locale takes precedence over request.LANGUAGE_CODE"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": "de"}},
)
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "de")
def test_locale_falls_back_to_language_code(self):
"""Test request.LANGUAGE_CODE is used when the user has no saved locale"""
user = User.objects.create(username=generate_id())
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "fr")
def test_locale_empty_user_setting_falls_back_to_language_code(self):
"""Test an empty saved locale does not shadow request.LANGUAGE_CODE"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": ""}},
)
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "fr")
def test_locale_no_request_returns_user_setting(self):
"""Test the user's saved locale is returned when there is no request"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": "de"}},
)
self.assertEqual(user.locale(), "de")
def test_locale_no_request_no_setting_returns_empty(self):
"""Test an empty string is returned when there is no request and no saved locale"""
user = User.objects.create(username=generate_id())
self.assertEqual(user.locale(), "")
def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
"""Test hash password updates do not expose a raw password to sync receivers."""
user = User.objects.create(

View File

@@ -22,6 +22,7 @@ from authentik.core.tests.utils import (
create_test_admin_user,
create_test_brand,
create_test_flow,
create_test_session,
create_test_user,
)
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
@@ -455,6 +456,33 @@ class TestUsersAPI(APITestCase):
)
self.assertJSONEqual(response.content.decode(), {"paths": expected})
def test_path_startswith(self):
"""Test path_startswith, which must not match sibling paths sharing a prefix"""
root = generate_id(20)
exact = create_test_user(path=f"{root}/group1")
nested = create_test_user(path=f"{root}/group1/sub")
sibling = create_test_user(path=f"{root}/group11")
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"path_startswith": f"{root}/group1"},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
pks = [r["pk"] for r in body["results"]]
self.assertCountEqual(pks, [exact.pk, nested.pk])
self.assertNotIn(sibling.pk, pks)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"path_startswith": root},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
pks = [r["pk"] for r in body["results"]]
self.assertCountEqual(pks, [exact.pk, nested.pk, sibling.pk])
def test_path_valid(self):
"""Test path"""
self.client.force_login(self.admin)
@@ -541,6 +569,28 @@ class TestUsersAPI(APITestCase):
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)
def test_session_delete_put(self):
"""Ensure sessions are deleted when a user is deactivated via PUT"""
user = create_test_admin_user()
session = create_test_session(user)
session_id = session.session.session_key
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
data={
"username": user.username,
"name": user.name,
"is_active": False,
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(Session.objects.filter(session_key=session_id).exists())
self.assertFalse(
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)
def test_sort_by_last_updated(self):
"""Test API sorting by last_updated"""
User.objects.all().delete()

View File

@@ -27,10 +27,10 @@ from authentik.core.views.interface import (
InterfaceView,
RootRedirectView,
)
from authentik.events.consumer import ClientConsumer
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.middleware import ChannelsLoggingMiddleware
from authentik.root.ws.consumer import MessageConsumer
from authentik.tenants.channels import TenantsAwareMiddleware
urlpatterns = [
@@ -116,7 +116,7 @@ websocket_urlpatterns = [
path(
"ws/client/",
ChannelsLoggingMiddleware(
TenantsAwareMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi()))
TenantsAwareMiddleware(AuthMiddlewareStack(ClientConsumer.as_asgi()))
),
),
]

View File

@@ -124,10 +124,10 @@ class AgentConnectorViewSet(
token: EnrollmentToken = request.auth
data = EnrollSerializer(data=request.data)
data.is_valid(raise_exception=True)
device, _ = Device.objects.get_or_create(
device = Device.get_or_create(
identifier=data.validated_data["device_serial"],
name=data.validated_data["device_name"],
defaults={
"name": data.validated_data["device_name"],
"expiring": False,
"access_group": token.device_group,
},

View File

@@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4
from django.core.cache import cache
from django.db import models
from django.db.models import OuterRef, Subquery
from django.db import models, transaction
from django.db.models import OuterRef, Q, Subquery
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
@@ -43,6 +43,15 @@ class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindi
"DeviceAccessGroup", null=True, on_delete=models.SET_DEFAULT, default=None
)
@staticmethod
@transaction.atomic
def get_or_create(identifier: str, name: str, defaults: dict[str, Any] | None = None) -> Device:
defaults = defaults or {}
try:
return Device.objects.get(Q(identifier=identifier) | Q(name=name))
except Device.DoesNotExist:
return Device.objects.create(name=name, identifier=identifier, **defaults)
@property
def cache_key_facts(self):
return f"goauthentik.io/endpoints/devices/{self.device_uuid}/facts"
@@ -187,7 +196,6 @@ class Connector(ScheduledModel, SerializerModel):
class DeviceAccessGroup(AttributesMixin, SerializerModel, PolicyBindingModel):
name = models.TextField(unique=True)
@property

View File

@@ -1,15 +1,16 @@
from urllib.parse import urlencode
from django.urls import reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from structlog.stdlib import get_logger
from authentik.endpoints.connectors.agent.api.agent import (
AgentAuthenticationResponse,
)
from authentik.common.oauth.constants import QS_LOGIN_HINT
from authentik.endpoints.connectors.agent.api.agent import AgentAuthenticationResponse
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.endpoints.connectors.agent.models import (
DeviceAuthenticationToken,
@@ -25,6 +26,7 @@ class AgentConnectorViewSetMixin:
@extend_schema(
request=OpenApiTypes.NONE,
responses=AgentAuthenticationResponse(),
parameters=[OpenApiParameter(QS_LOGIN_HINT, type=OpenApiTypes.STR, required=False)],
)
@action(
methods=["POST"],
@@ -41,13 +43,12 @@ class AgentConnectorViewSetMixin:
device_token=token,
connector=token.device.connector.agentconnector,
)
return Response(
{
"url": request.build_absolute_uri(
reverse(
"authentik_enterprise_endpoints_connectors_agent:authenticate",
kwargs={"token_uuid": auth_token.identifier},
)
),
}
final_url = request.build_absolute_uri(
reverse(
"authentik_enterprise_endpoints_connectors_agent:authenticate",
kwargs={"token_uuid": auth_token.identifier},
)
)
if hint := request.query_params.get(QS_LOGIN_HINT):
final_url += "?" + urlencode({QS_LOGIN_HINT: hint})
return Response({"url": final_url})

View File

@@ -127,8 +127,8 @@ class FleetController(BaseController[DBC]):
self.logger.warning("Failed to sync conditional access CA", exc=exc)
for host in self._paginate_hosts():
serial = host["hardware_serial"]
device, _ = Device.objects.get_or_create(
identifier=serial, defaults={"name": host["hostname"], "expiring": False}
device = Device.get_or_create(
identifier=serial, name=host["hostname"], defaults={"expiring": False}
)
connection, _ = DeviceConnection.objects.update_or_create(
device=device,

View File

@@ -59,12 +59,7 @@ class GoogleChromeController(BaseController[GoogleChromeConnector]):
# Remove deprecated string representation of deviceSignals
response.pop("deviceSignal", None)
signals = DeviceSignals(response["deviceSignals"])
device, _ = Device.objects.update_or_create(
identifier=signals["serialNumber"],
defaults={
"name": signals["hostname"],
},
)
device = Device.get_or_create(identifier=signals["serialNumber"], name=signals["hostname"])
conn, _ = DeviceConnection.objects.update_or_create(
device=device,
connector=self.connector,

View File

@@ -38,7 +38,7 @@ from authentik.enterprise.models import (
from authentik.tenants.utils import get_unique_identifier
CACHE_KEY_ENTERPRISE_LICENSE = "goauthentik.io/enterprise/license"
CACHE_EXPIRY_ENTERPRISE_LICENSE = 3 * 60 * 60 # 2 Hours
CACHE_EXPIRY_ENTERPRISE_LICENSE = 12 * 60 * 60 # 12 Hours
@lru_cache
@@ -219,22 +219,25 @@ class LicenseKey:
external_user_count=self.get_external_user_count(),
status=self.status(),
)
summary = asdict(self.summary())
# Also cache the latest summary for the middleware
cache.set(CACHE_KEY_ENTERPRISE_LICENSE, summary, timeout=CACHE_EXPIRY_ENTERPRISE_LICENSE)
return usage
def summary(self) -> LicenseSummary:
"""Summary of license status"""
status = self.status()
latest_valid = datetime.fromtimestamp(self.exp).replace(tzinfo=UTC)
return LicenseSummary(
summary = LicenseSummary(
latest_valid=latest_valid,
internal_users=self.internal_users,
external_users=self.external_users,
status=status,
license_flags=self.license_flags,
)
cache.set(
CACHE_KEY_ENTERPRISE_LICENSE,
asdict(summary),
timeout=CACHE_EXPIRY_ENTERPRISE_LICENSE,
)
return summary
@staticmethod
def cached_summary() -> LicenseSummary:

View File

@@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.core.signals import deactivation_inhibit_cleanup
from authentik.enterprise.core.revocation import revoke_user_access
from authentik.enterprise.lifecycle.offboarding.models import OffboardingAction
from authentik.events.models import Event, EventAction
@@ -57,7 +58,8 @@ def offboard_user(
if action == OffboardingAction.DEACTIVATE:
user.is_active = False
user.save(update_fields=["is_active"])
with deactivation_inhibit_cleanup(sessions=not revoke_sessions, tokens=not revoke_tokens):
user.save(update_fields=["is_active"])
elif action == OffboardingAction.DELETE:
user.delete()
else:

View File

@@ -99,7 +99,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
"url_wsfed",
"url_issuer",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class WSFederationProviderViewSet(SAMLProviderViewSet):

View File

@@ -94,8 +94,8 @@ def _binding_default(field_name: str) -> timedelta:
class GrantRequestSerializer(EnterpriseRequiredMixin, ModelSerializer):
created_by = PartialUserSerializer(read_only=True)
revoked_by = PartialUserSerializer(read_only=True)
agent_owner = PartialUserSerializer(read_only=True)
revoked_by = PartialUserSerializer(read_only=True, allow_null=True)
agent_owner = PartialUserSerializer(read_only=True, allow_null=True)
is_active = BooleanField(read_only=True)
target_objs = SerializerMethodField()

View File

@@ -11,6 +11,7 @@ from dramatiq.composition import group
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import User, UserTypes
from authentik.core.signals import deactivation_inhibit_cleanup
from authentik.enterprise.core.revocation import revoke_user_access
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.events.models import Event, EventAction
@@ -89,7 +90,12 @@ class AccountLockdownStageView(StageView):
if stage.set_unusable_password:
user.set_unusable_password()
if stage.deactivate_user:
with sync_outgoing_inhibit_dispatch():
with (
sync_outgoing_inhibit_dispatch(),
deactivation_inhibit_cleanup(
sessions=not stage.delete_sessions, tokens=not stage.revoke_tokens
),
):
user.save()
return
user.save()

View File

@@ -0,0 +1,39 @@
"""websocket Message consumer"""
from hashlib import sha256
from asgiref.sync import async_to_sync
from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from django.db import connection
from authentik.core.models import User
def build_user_group(user: User):
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
class ClientConsumer(JsonWebsocketConsumer):
"""Consumer which sends django.contrib.messages Messages over WS.
channel_name is saved into cache with user_id, and when a add_message is called"""
user: User | None = None
def connect(self):
user = self.scope.get("user")
if user is None or not user.is_authenticated:
raise DenyConnection()
self.user = user
self.accept()
async_to_sync(self.channel_layer.group_add)(build_user_group(self.user), self.channel_name)
def disconnect(self, code):
if self.user:
async_to_sync(self.channel_layer.group_discard)(
build_user_group(self.user), self.channel_name
)
def event_notification(self, event: dict):
"""Event handler for new notifications"""
self.send_json({"message_type": "notification.new", **event})

View File

@@ -29,6 +29,7 @@ from authentik.core.middleware import (
)
from authentik.core.models import Group, PropertyMapping, User
from authentik.crypto.models import CertificateKeyPair
from authentik.events.consumer import build_user_group
from authentik.events.context_processors.base import get_context_processors
from authentik.events.utils import (
cleanse_dict,
@@ -50,7 +51,6 @@ 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
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tasks.models import TasksModel

View File

@@ -0,0 +1,59 @@
from asgiref.sync import sync_to_async
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.test import TransactionTestCase
from authentik.core.tests.utils import create_test_user
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationTransport,
TransportMode,
)
from authentik.lib.generators import generate_id
from authentik.root import websocket
class TestClientWS(TransactionTestCase):
def setUp(self):
self.user = create_test_user()
async def test_unauthenticated(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
connected, _ = await communicator.connect()
self.assertFalse(connected)
async def test_notification(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
communicator.scope["user"] = self.user
connected, _ = await communicator.connect()
self.assertTrue(connected)
transport = await NotificationTransport.objects.acreate(
name=generate_id(), mode=TransportMode.LOCAL
)
event = await sync_to_async(Event.new)(EventAction.LOGIN)
event.set_user(self.user)
await event.asave()
notification = Notification(
user=self.user,
body="foo",
event=event,
hyperlink="goauthentik.io",
hyperlink_label="a link",
)
await sync_to_async(transport.send_local)(notification)
evt = await communicator.receive_json_from(timeout=5)
self.assertEqual(evt["message_type"], "notification.new")
self.assertEqual(evt["id"], str(notification.pk))
self.assertEqual(evt["data"]["pk"], str(notification.pk))
self.assertEqual(evt["data"]["body"], "foo")
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
await communicator.disconnect()

View File

@@ -24,14 +24,6 @@ HIST_FLOWS_PLAN_TIME = Histogram(
)
class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"):
default = False
visibility = "public"
description = _("Refresh other tabs after successful authentication.")
deprecated = True
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
default = False

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import TYPE_CHECKING, TypedDict
from uuid import UUID
from django.contrib.messages import DEFAULT_TAGS
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.http import JsonResponse
@@ -42,6 +43,16 @@ class ErrorDetailSerializer(PassiveSerializer):
code = CharField()
FLOW_MESSAGE_LEVELS = list(DEFAULT_TAGS.values())
class FlowMessageSerializer(PassiveSerializer):
"""Serializer for a django.contrib.messages message"""
level = ChoiceField(choices=FLOW_MESSAGE_LEVELS, source="level_tag")
message = CharField()
class ContextualFlowInfo(PassiveSerializer):
"""Contextual flow information for a challenge"""
@@ -50,6 +61,7 @@ class ContextualFlowInfo(PassiveSerializer):
background_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
cancel_url = CharField()
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])
messages = FlowMessageSerializer(many=True, required=False)
class Challenge(PassiveSerializer):
@@ -179,7 +191,6 @@ class FrameChallenge(Challenge):
class FrameChallengeResponse(ChallengeResponse):
component = CharField(default="xak-flow-frame")

View File

@@ -5,6 +5,7 @@ from urllib.parse import urlencode
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages import get_messages
from django.http import HttpRequest
from django.http.request import QueryDict
from django.http.response import HttpResponse
@@ -22,6 +23,7 @@ from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ContextualFlowInfo,
FlowMessageSerializer,
HttpChallengeResponse,
RedirectChallenge,
SessionEndChallenge,
@@ -194,6 +196,9 @@ class ChallengeStageView(StageView):
if not hasattr(challenge, "initial_data"):
challenge.initial_data = {}
if "flow_info" not in challenge.initial_data:
messages = []
if self.request is not None and not isinstance(challenge, RedirectChallenge):
messages = get_messages(self.request)
# Flow payloads can outlive the previous signed media JWT, so
# refreshes must mint fresh URLs instead of reusing cached ones.
flow_info = ContextualFlowInfo(
@@ -205,7 +210,8 @@ class ChallengeStageView(StageView):
),
"cancel_url": self.cancel_url,
"layout": self.executor.flow.layout,
}
"messages": FlowMessageSerializer(messages, many=True).data,
},
)
flow_info.is_valid()
challenge.initial_data["flow_info"] = flow_info.data

View File

@@ -822,6 +822,7 @@ class TestFlowExecutor(FlowTestCase):
"cancel_url": "/flows/-/cancel/?next=%2Ffoo",
"layout": "stacked",
"title": flow.title,
"messages": [],
},
)

View File

@@ -55,6 +55,7 @@ class TestFlowInspector(APITestCase):
"cancel_url": reverse("authentik_flows:cancel"),
"title": flow.title,
"layout": "stacked",
"messages": [],
},
"flow_designation": "authentication",
"passkey_challenge": None,

View File

@@ -0,0 +1,116 @@
"""Tests for messages attached to challenges"""
from django.contrib.messages import add_message
from django.contrib.messages.constants import SUCCESS, WARNING
from django.contrib.messages.storage.session import SessionStorage
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from authentik.core.tests.utils import create_test_flow
from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.flows.models import FlowStageBinding, in_memory_stage
from authentik.flows.planner import FlowPlan
from authentik.flows.stage import ChallengeStageView, StageView
from authentik.flows.tests import FlowTestCase
from authentik.stages.dummy.models import DummyStage
class MessageStageView(StageView):
"""Stage which queues a message and continues to the next stage"""
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
add_message(request, SUCCESS, "stage message")
return self.executor.stage_ok()
class MessageChallengeStageView(ChallengeStageView):
"""Stage which queues a message while rendering its challenge"""
def get_challenge(self, *args, **kwargs) -> Challenge:
add_message(self.request, WARNING, "challenge message")
return Challenge(data={"component": "ak-stage-dummy"})
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()
class TestFlowMessages(FlowTestCase):
"""Test messages attached to challenges"""
def setUp(self):
self.flow = create_test_flow()
self.url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
def test_challenge(self):
"""Test message queued while the challenge is rendered"""
plan = FlowPlan(flow_pk=self.flow.pk.hex)
plan.append_stage(in_memory_stage(MessageChallengeStageView))
self.set_flow_plan(plan)
response = self.client.get(self.url)
raw_response = self.assertStageResponse(response, self.flow)
self.assertEqual(
raw_response["flow_info"]["messages"],
[{"level": "warning", "message": "challenge message"}],
)
def test_challenge_not_repeated(self):
"""Test that a message is only ever attached to a single challenge"""
plan = FlowPlan(flow_pk=self.flow.pk.hex)
plan.append_stage(in_memory_stage(MessageChallengeStageView))
self.set_flow_plan(plan)
self.client.get(self.url)
# The stage queues a new message on every render, so the second challenge only
# contains the message queued for it
response = self.client.get(self.url)
raw_response = self.assertStageResponse(response, self.flow)
self.assertEqual(
raw_response["flow_info"]["messages"],
[{"level": "warning", "message": "challenge message"}],
)
def test_previous_stage(self):
"""Test message queued by a stage which doesn't render a challenge itself, it is
attached to the challenge of the next stage"""
FlowStageBinding.objects.create(
target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
plan = FlowPlan(flow_pk=self.flow.pk.hex)
plan.append_stage(in_memory_stage(MessageStageView))
plan.append(FlowStageBinding.objects.filter(target=self.flow).first())
self.set_flow_plan(plan)
response = self.client.get(self.url, follow=True)
raw_response = self.assertStageResponse(
response,
self.flow,
component="ak-stage-dummy",
)
self.assertEqual(
raw_response["flow_info"]["messages"],
[{"level": "success", "message": "stage message"}],
)
def test_redirect_challenge(self):
"""Test message queued by the last stage of a flow. The client navigates away as soon
as it gets the redirect challenge the flow finishes with, so the message is left
queued for the page we redirect to"""
plan = FlowPlan(flow_pk=self.flow.pk.hex)
plan.append_stage(in_memory_stage(MessageStageView))
self.set_flow_plan(plan)
response = self.client.get(self.url)
raw_response = self.assertStageResponse(response, component="xak-flow-redirect")
self.assertNotIn("messages", raw_response.get("flow_info", {}))
self.assertIn("stage message", self.client.session[SessionStorage.session_key])
def test_no_messages(self):
"""Test that challenges without messages have an empty list"""
FlowStageBinding.objects.create(
target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
response = self.client.get(self.url)
raw_response = self.assertStageResponse(response)
self.assertEqual(raw_response["flow_info"]["messages"], [])

View File

@@ -2,6 +2,7 @@
import re
import socket
from copy import deepcopy
from ipaddress import ip_address, ip_network
from smtplib import SMTPException
from textwrap import indent
@@ -174,8 +175,8 @@ class BaseEvaluator:
return fallback value."""
attrs = getattr(obj, "attributes", {})
value = get_path_from_dict(attrs, attr_key)
if value is None and fallback:
return getattr(obj, fallback)
if value is None and fallback is not None:
return getattr(obj, fallback, fallback)
return value
def expr_event_create(self, action: str, **kwargs):
@@ -207,7 +208,7 @@ class BaseEvaluator:
user = self._context.get("user", get_anonymous_user())
req = PolicyRequest(user)
if "request" in self._context:
req = self._context["request"]
req = deepcopy(self._context["request"])
req.context.update(kwargs)
proc = PolicyThread(PolicyBinding(policy=policy), request=req)
return proc.profiling_wrapper()

View File

@@ -39,6 +39,17 @@ class TestEvaluator(TestCase):
"""Test expr_is_group_member"""
self.assertFalse(BaseEvaluator.expr_is_group_member(create_test_admin_user(), name="test"))
def test_expr_obj_attr(self):
"""Test expr_obj_attr"""
user = create_test_user()
user.attributes = {"locale": "en-US"}
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "locale", "en-GB"), "en-US")
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", "username"), user.username)
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", "en-GB"), "en-GB")
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", ""), "")
self.assertIsNone(BaseEvaluator.expr_obj_attr(user, "missing"))
def test_expr_event_create(self):
"""Test expr_event_create"""
evaluator = BaseEvaluator(generate_id())

View File

@@ -22,6 +22,7 @@ from authentik.outposts.models import (
DockerServiceConnection,
Outpost,
OutpostServiceConnectionState,
OutpostType,
ServiceConnectionInvalid,
)
@@ -195,6 +196,10 @@ class DockerController(BaseController):
except NotFound:
self.logger.info("(Re-)creating container...")
image_name = self.try_pull_image()
# Go outposts have a different syntax for this than the rust proxy outpost
healthcheck_cmd = [f"/{self.outpost.type}", "healthcheck"]
if self.outpost.type == OutpostType.PROXY:
healthcheck_cmd = ["/authentik", "healthcheck", self.outpost.type]
container_args = {
"image": image_name,
"name": self.name,
@@ -204,7 +209,7 @@ class DockerController(BaseController):
"restart_policy": {"Name": "unless-stopped"},
"network": self.outpost.config.docker_network,
"healthcheck": {
"test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
"test": ["CMD", *healthcheck_cmd],
"interval": 5 * 1_000 * 1_000_000,
"retries": 20,
"start_period": 3 * 1_000 * 1_000_000,

View File

@@ -111,6 +111,25 @@ class TestEvaluator(TestCase):
res = proc.profiling_wrapper()
self.assertEqual(res.messages, ("/", "/", "/"))
def test_call_policy_kwargs_pollute(self):
"""test ak_call_policy"""
expr = ExpressionPolicy.objects.create(
name=generate_id(),
execution_logging=True,
expression="return context.get('subkey', False)",
)
expr2 = ExpressionPolicy.objects.create(
name=generate_id(),
execution_logging=True,
expression=f"""
ak_message(ak_call_policy('{expr.name}', subkey=True).passing)
ak_message(ak_call_policy('{expr.name}').passing)
""",
)
proc = PolicyProcess(PolicyBinding(policy=expr2), request=self.request, connection=None)
res = proc.profiling_wrapper()
self.assertEqual(res.messages, (True, False))
def test_call_policy_test_like(self):
"""test ak_call_policy without `obj` set, as if it was when testing policies"""
expr = ExpressionPolicy.objects.create(

View File

@@ -43,7 +43,7 @@ class LDAPProviderSerializer(ProviderSerializer):
"bind_mode",
"mfa_support",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class LDAPProviderFilter(FilterSet):

View File

@@ -95,7 +95,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"jwt_federation_sources",
"jwt_federation_providers",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class OAuth2ProviderSetupURLs(PassiveSerializer):

View File

@@ -8,6 +8,7 @@ from authentik.common.oauth.constants import (
PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS,
)
from authentik.core.models import AuthenticatedSession, ProviderPropertyMapping, User
from authentik.core.signals import deactivation_token_cleanup_inhibited
from authentik.flows.models import in_memory_stage
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.oauth2.models import (
@@ -122,6 +123,8 @@ def user_deactivated(sender, instance: User, **_):
"""Remove user tokens when deactivated"""
if instance.is_active:
return
if deactivation_token_cleanup_inhibited():
return
AccessToken.objects.including_expired().filter(user=instance).delete()
RefreshToken.objects.including_expired().filter(user=instance).delete()
DeviceToken.objects.including_expired().filter(user=instance).delete()

View File

@@ -1,18 +1,23 @@
"""Test OAuth2 Back-Channel Logout implementation"""
import json
from dataclasses import asdict
from unittest.mock import Mock, patch
import jwt
from django.test import RequestFactory
from django.urls import reverse
from django.utils import timezone
from dramatiq.results.errors import ResultFailure
from requests import Response
from requests.exceptions import HTTPError, Timeout
from authentik.core.models import Application
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.oauth2.id_token import IDToken, hash_session_key
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2LogoutMethod,
OAuth2Provider,
RedirectURI,
@@ -191,3 +196,129 @@ class TestBackChannelLogout(OAuthTestCase):
send_backchannel_logout_request.send(
self.provider.pk, "http://testserver", sub="test-user-uid"
).get_result()
@patch("authentik.providers.oauth2.tasks.get_http_session")
def test_frontchannel_provider_no_backchannel(self, mock_get_session):
"""Deleting a session does not back-channel a front-channel provider"""
self.provider.logout_uri = "http://testserver/logout"
self.provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL
self.provider.save()
mock_session = Mock()
mock_get_session.return_value = mock_session
session = Session.objects.create(
session_key=generate_id(),
last_ip="255.255.255.255",
last_user_agent="",
)
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=auth_session,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(asdict(IDToken(iss="http://testserver", sub=str(self.user.uid)))),
)
session.delete()
mock_session.post.assert_not_called()
class TestBackChannelLogoutUserDeactivation(OAuthTestCase):
"""Test that deactivating a user triggers back-channel logout"""
def setUp(self) -> None:
super().setUp()
self.admin = create_test_admin_user()
self.user = create_test_admin_user()
self.provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback"),
],
signing_key=self.keypair,
logout_uri="http://testserver/backchannel_logout",
logout_method=OAuth2LogoutMethod.BACKCHANNEL,
)
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.provider
)
self.session_key = generate_id()
self.session = Session.objects.create(
session_key=self.session_key,
last_ip="255.255.255.255",
last_user_agent="",
)
self.auth_session = AuthenticatedSession.objects.create(
session=self.session,
user=self.user,
)
self.token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=self.auth_session,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(asdict(IDToken(iss="http://testserver", sub=str(self.user.uid)))),
)
def _mock_http(self, mock_get_session: Mock) -> Mock:
"""Wire up a mocked HTTP session that always succeeds"""
mock_session = Mock()
mock_get_session.return_value = mock_session
mock_response = Mock(spec=Response)
mock_response.status_code = 200
mock_response.raise_for_status.return_value = None
mock_session.post.return_value = mock_response
return mock_session
def _assert_logout_token_sent(self, mock_session: Mock):
"""Assert a single logout token was POSTed to the provider's logout URI"""
mock_session.post.assert_called_once()
args, kwargs = mock_session.post.call_args
self.assertEqual(args[0], self.provider.logout_uri)
key, alg = self.provider.jwt_key
if alg != "HS256":
key = self.provider.signing_key.public_key
decoded = jwt.decode(
kwargs["data"]["logout_token"],
key,
algorithms=[alg],
options={"verify_exp": False, "verify_aud": False},
)
self.assertEqual(decoded["sub"], str(self.user.uid))
self.assertEqual(decoded["sid"], hash_session_key(self.session_key))
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded["events"])
@patch("authentik.providers.oauth2.tasks.get_http_session")
def test_user_deactivated_model(self, mock_get_session):
"""Deactivating a user directly sends back-channel logout"""
mock_session = self._mock_http(mock_get_session)
self.user.is_active = False
self.user.save()
self._assert_logout_token_sent(mock_session)
self.assertFalse(Session.objects.filter(session_key=self.session_key).exists())
self.assertEqual(AccessToken.objects.including_expired().filter(user=self.user).count(), 0)
@patch("authentik.providers.oauth2.tasks.get_http_session")
def test_user_deactivated_api(self, mock_get_session):
"""Deactivating a user through the API sends back-channel logout"""
mock_session = self._mock_http(mock_get_session)
self.client.force_login(self.admin)
response = self.client.patch(
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
data=json.dumps({"is_active": False}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self._assert_logout_token_sent(mock_session)
self.assertFalse(Session.objects.filter(session_key=self.session_key).exists())

View File

@@ -93,6 +93,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
"cancel_url": "/flows/-/cancel/",
"layout": "stacked",
"title": self.device_flow.title,
"messages": [],
},
},
)

View File

@@ -8,6 +8,7 @@ from django.urls import reverse
from django.utils import timezone
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.signals import deactivation_inhibit_cleanup
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
@@ -233,6 +234,67 @@ class TesOAuth2Revoke(OAuthTestCase):
self.assertEqual(RefreshToken.objects.including_expired().all().count(), 0)
self.assertEqual(DeviceToken.objects.including_expired().all().count(), 0)
def test_revoke_user_deactivated_inhibited(self):
"""Test tokens are kept when deactivation cleanup is inhibited"""
AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
DeviceToken.objects.create(
provider=self.provider,
user=self.user,
_scope="openid user profile",
)
self.user.is_active = False
with deactivation_inhibit_cleanup():
self.user.save()
self.assertEqual(AccessToken.objects.including_expired().all().count(), 1)
self.assertEqual(RefreshToken.objects.including_expired().all().count(), 1)
self.assertEqual(DeviceToken.objects.including_expired().all().count(), 1)
def test_revoke_user_deactivated_inhibit_sessions_only(self):
"""Test tokens are still revoked when only session cleanup is inhibited"""
AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
self.user.is_active = False
with deactivation_inhibit_cleanup(sessions=True, tokens=False):
self.user.save()
self.assertEqual(AccessToken.objects.including_expired().all().count(), 0)
def test_revoke_provider_fed(self):
"""Test revoke with federation. self.provider is a confidential
client and other_provider is a public client."""

View File

@@ -99,16 +99,22 @@ class TestTokenExchange(OAuthTestCase):
self.user = create_test_user()
self.subject_token = self.create_subject_token(self.user)
def create_subject_token(self, user: User, expires_in: timedelta = timedelta(hours=2)) -> str:
def create_subject_token(
self,
user: User,
expires_in: timedelta = timedelta(hours=2),
provider: OAuth2Provider | None = None,
) -> str:
"""Issue an access token from the federated provider, usable as a subject token"""
token = self.other_provider.encode(
provider = provider or self.other_provider
token = provider.encode(
{
"sub": "foo",
"exp": datetime.now() + expires_in,
}
)
AccessToken.objects.create(
provider=self.other_provider,
provider=provider,
token=token,
user=user,
auth_time=now(),
@@ -307,6 +313,45 @@ class TestTokenExchange(OAuthTestCase):
body = loads(response.content.decode())
self.assertEqual(body["scope"], SCOPE_OPENID)
def test_audience_subject_token_from_self(self):
"""test that a token the requesting provider issued for itself is a valid subject
token when `audience` targets another provider -- the target has already opted into
the requesting provider, which is what authorizes the exchange"""
subject_token = self.create_subject_token(self.user, provider=self.provider)
response = self._exchange(
subject_token=subject_token, audience=self.target_provider.client_id
)
self.assertEqual(response.status_code, 200, response.content)
body = loads(response.content.decode())
jwt = self._decode_for(self.target_provider, body["access_token"])
self.assertEqual(jwt["aud"], self.target_provider.client_id)
access_token = AccessToken.objects.get(token=body["access_token"])
self.assertEqual(access_token.provider_id, self.target_provider.pk)
self.assertEqual(access_token.user_id, self.user.pk)
def test_subject_token_from_self_without_audience(self):
"""test that a self-issued subject token is only accepted when `audience` targets
another provider -- naming the requesting provider itself does not widen the trust,
since that is the default behavior and no target opted in"""
subject_token = self.create_subject_token(self.user, provider=self.provider)
for audience in [None, self.provider.client_id]:
with self.subTest(audience=audience):
extra = {"audience": audience} if audience else {}
response = self._exchange(subject_token=subject_token, **extra)
self.assertEqual(response.status_code, 400)
self.assertEqual(loads(response.content.decode())["error"], "invalid_grant")
def test_audience_subject_token_from_self_not_federated(self):
"""test that a self-issued subject token does not bypass the target's opt-in"""
self.target_provider.jwt_federation_providers.clear()
subject_token = self.create_subject_token(self.user, provider=self.provider)
response = self._exchange(
subject_token=subject_token, audience=self.target_provider.client_id
)
self.assertEqual(response.status_code, 400)
self.assertEqual(loads(response.content.decode())["error"], "invalid_target")
def test_resource_rejected(self):
"""test that a requested resource is refused rather than silently ignored"""
response = self.client.post(
@@ -536,17 +581,18 @@ class TestTokenExchange(OAuthTestCase):
def _decode(self, access_token: str) -> dict:
return self._decode_for(self.provider, access_token)
def _actor_token_jwt(self, actor: Actor) -> str:
def _actor_token_jwt(self, actor: Actor, provider: OAuth2Provider | None = None) -> str:
"""Issue an access token for `actor` from the federated provider, usable as a
JWT actor_token"""
token = self.other_provider.encode(
provider = provider or self.other_provider
token = provider.encode(
{
"sub": "actor",
"exp": datetime.now() + timedelta(hours=2),
}
)
AccessToken.objects.create(
provider=self.other_provider,
provider=provider,
token=token,
user=actor,
auth_time=now(),
@@ -652,6 +698,59 @@ class TestTokenExchange(OAuthTestCase):
access_token = AccessToken.objects.get(token=body["access_token"])
self.assertEqual(access_token.actor_id, actor.pk)
def test_actor_token_jwt_from_self_with_audience(self):
"""test that an actor_token issued by the requesting provider itself is accepted
when `audience` targets another provider, matching the subject_token trust set"""
actor = Actor.for_user(self.user, ActorPolicyInheritance.NONE)
actor_token = self._actor_token_jwt(actor, provider=self.provider)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
"scope": SCOPES,
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"subject_token": self.subject_token,
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
"actor_token": actor_token,
"actor_token_type": TOKEN_TYPE_URI_JWT,
"audience": self.target_provider.client_id,
},
)
self.assertEqual(response.status_code, 200, response.content)
body = loads(response.content.decode())
jwt = self._decode_for(self.target_provider, body["access_token"])
self.assertIn("act", jwt)
self.assertEqual(jwt["act"]["sub"], actor.uid)
access_token = AccessToken.objects.get(token=body["access_token"])
self.assertEqual(access_token.provider_id, self.target_provider.pk)
self.assertEqual(access_token.actor_id, actor.pk)
def test_actor_token_jwt_from_self_without_audience(self):
"""test that an actor_token issued by the requesting provider itself is rejected
without an `audience` targeting another provider"""
actor = Actor.for_user(self.user, ActorPolicyInheritance.NONE)
actor_token = self._actor_token_jwt(actor, provider=self.provider)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
"scope": SCOPES,
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"subject_token": self.subject_token,
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
"actor_token": actor_token,
"actor_token_type": TOKEN_TYPE_URI_JWT,
},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(loads(response.content.decode())["error"], "invalid_grant")
def test_actor_token_unowned_builtin_rejected(self):
"""test that an actor with no owner cannot be delegated to via a built-in Token
actor_token -- only JWTs are supported for ownerless actors"""

View File

@@ -1,6 +1,7 @@
from dataclasses import dataclass, field
from typing import Any
from django.db.models import Q
from django.http import HttpRequest
from django.utils import timezone
from jwt import PyJWK, PyJWT, PyJWTError, decode
@@ -111,9 +112,12 @@ class FederatedTokenRequest(TokenRequest):
def validate_jwt_from_provider(self, assertion: str) -> FederatedParty | None:
token = provider = resolved_user = _key = None
federated_token = AccessToken.objects.filter(
token=assertion, provider__in=self.provider.jwt_federation_providers.all()
).first()
providers = Q(provider__in=self.provider.jwt_federation_providers.all())
if self.audience_provider:
# For token exchange with actor, the given token may likely be a token
# of the provider this request is for
providers |= Q(provider=self.provider)
federated_token = AccessToken.objects.filter(providers, token=assertion).first()
if federated_token:
_key, _alg = federated_token.provider.jwt_key
try:

View File

@@ -100,7 +100,7 @@ class ProxyProviderSerializer(ProviderSerializer):
"refresh_token_validity",
"outpost_set",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class ProxyProviderViewSet(UsedByMixin, ModelViewSet):

View File

@@ -52,7 +52,7 @@ class RadiusProviderSerializer(
"mfa_support",
"certificate",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class RadiusProviderViewSet(UsedByMixin, ModelViewSet):

View File

@@ -286,7 +286,7 @@ class SAMLProviderSerializer(ProviderSerializer):
"url_slo_post",
"url_slo_redirect",
]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
class SAMLMetadataSerializer(PassiveSerializer):

View File

@@ -1,6 +1,6 @@
"""SAML Provider signals"""
from django.db.models.signals import post_save, pre_delete
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from django.urls import reverse
@@ -228,37 +228,3 @@ def user_session_deleted_saml_logout(sender, instance: AuthenticatedSession, **_
session_index=saml_session.session_index,
issuer=saml_session.issuer,
)
@receiver(post_save, sender=User)
def user_deactivated_saml_logout(sender, instance: User, **kwargs):
"""Send SAML backchannel logout requests when user is deactivated"""
if instance.is_active:
return
backchannel_saml_sessions = (
SAMLSession.objects.filter(
user=instance,
provider__logout_method=SAMLLogoutMethods.BACKCHANNEL,
provider__sls_binding=SAMLBindings.POST,
)
.exclude(provider__sls_url="")
.select_related("provider")
)
for saml_session in backchannel_saml_sessions:
LOGGER.info(
"Triggering backchannel SAML logout for deactivated user",
user=instance,
provider=saml_session.provider.name,
session_index=saml_session.session_index,
)
send_saml_logout_request.send(
provider_pk=saml_session.provider.pk,
sls_url=saml_session.provider.sls_url,
name_id=saml_session.name_id,
name_id_format=saml_session.name_id_format,
session_index=saml_session.session_index,
issuer=saml_session.issuer,
)

View File

@@ -171,7 +171,15 @@ class GroupMember(BaseGroupMember):
class Bulk(BaseBulk):
maxOperations: int = Field()
# RFC 7644 Section 5 only defines the bulk limits alongside bulk support, so a
# service provider that answers `"bulk": {"supported": false}` and nothing else is
# conforming. Requiring the field made that response fail validation, which sent
# `get_service_provider_config()` to its fallback and reported `patch` and `filter`
# as unsupported regardless of what the provider actually advertised.
#
# 0 is the value the fallback itself uses, and `_patch_chunked` already reads any
# value below 1 as "no limit declared".
maxOperations: int = Field(default=0)
class ServiceProviderConfiguration(BaseServiceProviderConfiguration):

View File

@@ -68,7 +68,7 @@ class SCIMClientTests(TestCase):
}
],
"patch": {"supported": True},
"bulk": {"supported": False, "maxOperations": 1, "maxPayloadSize": 1048576},
"bulk": {"supported": False},
"filter": {"supported": True, "maxResults": 50},
"changePassword": {"supported": False},
"sort": {"supported": False},
@@ -80,6 +80,35 @@ class SCIMClientTests(TestCase):
self.assertEqual(mock.request_history[0].method, "GET")
self.assertFalse(client._config.is_fallback)
def test_config_bulk_unsupported_without_max_operations(self):
"""A provider that reports bulk as unsupported may omit the bulk limits.
RFC 7644 Section 5 only defines maxOperations alongside bulk support, so
such a response is conforming and must not push the client onto its
fallback config -- doing so reports patch and filter as unsupported
regardless of what the provider advertised, which silently disables
PATCH updates and the filtered lookup that adopts existing objects."""
with Mocker() as mock:
mock: Mocker
mock.get(
"https://localhost/ServiceProviderConfig",
json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"authenticationSchemes": [],
"patch": {"supported": True},
"bulk": {"supported": False},
"filter": {"supported": True, "maxResults": 50},
"changePassword": {"supported": False},
"sort": {"supported": False},
"etag": {"supported": False},
},
)
client = SCIMClient(self.provider)
self.assertFalse(client._config.is_fallback)
self.assertTrue(client._config.patch.supported)
self.assertTrue(client._config.filter.supported)
self.assertEqual(client._config.bulk.maxOperations, 0)
def test_config_invalid(self):
"""Test invalid config"""
with Mocker() as mock:

View File

@@ -193,6 +193,7 @@ SPECTACULAR_SETTINGS = {
"EventActions": "authentik.events.models.EventAction",
"FlowDesignationEnum": "authentik.flows.models.FlowDesignation",
"FlowLayoutEnum": "authentik.flows.models.FlowLayout",
"FlowMessageLevelEnum": "authentik.flows.challenge.FLOW_MESSAGE_LEVELS",
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
"ModelEnum": "authentik.lib.api.Models",
"OffboardingActionEnum": (
@@ -290,7 +291,7 @@ SESSION_COOKIE_AGE = timedelta_from_string(
).total_seconds()
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
MESSAGE_STORAGE = "authentik.root.ws.storage.ChannelsStorage"
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
MIDDLEWARE_FIRST = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",

View File

@@ -1,115 +0,0 @@
from unittest.mock import patch
from asgiref.sync import sync_to_async
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.http import HttpRequest
from django.test import TransactionTestCase
from authentik.core.tests.utils import create_test_user
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationTransport,
TransportMode,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.lib.generators import generate_id
from authentik.root import websocket
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
from authentik.tenants.utils import get_current_tenant
class TestClientWS(TransactionTestCase):
def setUp(self):
tenant = get_current_tenant()
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
tenant.save()
self.user = create_test_user()
async def _alogin_cookie(self, user, **kwargs):
"""Similar to `client.aforce_login` but allow setting of cookies"""
from django.contrib.auth import alogin
# Create a fake request to store login details.
request = HttpRequest()
session = await self.client.asession()
request.session = session
request.COOKIES.update(kwargs)
await alogin(request, user, BACKEND_INBUILT)
# Save the session values.
await request.session.asave()
self.client._set_login_cookies(request)
async def test_auth_blank(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
await communicator.receive_nothing()
await communicator.receive_json_from()
await communicator.disconnect()
async def test_tab_refresh(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
flag.return_value = True
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
evt = await communicator.receive_json_from()
self.assertEqual(
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
)
await communicator.disconnect()
async def test_notification(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
communicator.scope["user"] = self.user
connected, _ = await communicator.connect()
self.assertTrue(connected)
transport = await NotificationTransport.objects.acreate(
name=generate_id(), mode=TransportMode.LOCAL
)
event = await sync_to_async(Event.new)(EventAction.LOGIN)
event.set_user(self.user)
await event.asave()
notification = Notification(
user=self.user,
body="foo",
event=event,
hyperlink="goauthentik.io",
hyperlink_label="a link",
)
await sync_to_async(transport.send_local)(notification)
evt = await communicator.receive_json_from(timeout=5)
self.assertEqual(evt["message_type"], "notification.new")
self.assertEqual(evt["id"], str(notification.pk))
self.assertEqual(evt["data"]["pk"], str(notification.pk))
self.assertEqual(evt["data"]["body"], "foo")
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
await communicator.disconnect()

View File

@@ -1,76 +0,0 @@
"""websocket Message consumer"""
from hashlib import sha256
from asgiref.sync import async_to_sync
from channels.generic.websocket import JsonWebsocketConsumer
from django.core.cache import cache
from django.db import connection
from authentik.core.models import User
from authentik.root.ws.storage import CACHE_PREFIX
def build_session_group(session_key: str):
return sha256(
f"{connection.schema_name}/group_client_session_{str(session_key)}".encode()
).hexdigest()
def build_device_group(device_id: str):
return sha256(
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
).hexdigest()
def build_user_group(user: User):
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
class MessageConsumer(JsonWebsocketConsumer):
"""Consumer which sends django.contrib.messages Messages over WS.
channel_name is saved into cache with user_id, and when a add_message is called"""
session_key: str
device_cookie: str | None = None
user: User | None = None
def connect(self):
self.accept()
self.session_key = self.scope["session"].session_key
if self.session_key:
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
if user := self.scope.get("user"):
if user.is_authenticated:
async_to_sync(self.channel_layer.group_add)(
build_user_group(user), self.channel_name
)
if device_cookie := self.scope["cookies"].get("authentik_device", None):
self.device_cookie = device_cookie
async_to_sync(self.channel_layer.group_add)(
build_device_group(self.device_cookie), self.channel_name
)
def disconnect(self, code):
if self.session_key:
cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}")
if self.device_cookie:
async_to_sync(self.channel_layer.group_discard)(
build_device_group(self.device_cookie), self.channel_name
)
if self.user:
async_to_sync(self.channel_layer.group_discard)(
build_user_group(self.user), self.channel_name
)
def event_message(self, event: dict):
"""Event handler which is called by Messages Storage backend"""
self.send_json(event)
def event_session_authenticated(self, event: dict):
"""Event handler post user authentication"""
self.send_json({"message_type": "session.authenticated", **event})
def event_notification(self, event: dict):
"""Event handler for new notifications"""
self.send_json({"message_type": "notification.new", **event})

View File

@@ -1,41 +0,0 @@
"""Channels Messages storage"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.session import SessionStorage
from django.core.cache import cache
from django.http.request import HttpRequest
SESSION_KEY = "_messages"
CACHE_PREFIX = "goauthentik.io/root/messages_"
class ChannelsStorage(SessionStorage):
"""Send contrib.messages over websocket"""
def __init__(self, request: HttpRequest) -> None:
super().__init__(request)
self.channel = get_channel_layer()
def _store(self, messages: list[Message], response, *args, **kwargs):
prefix = f"{CACHE_PREFIX}{self.request.session.session_key}_messages_"
keys = cache.keys(f"{prefix}*")
# if no active connections are open, fallback to storing messages in the
# session, so they can always be retrieved
if len(keys) < 1:
return super()._store(messages, response, *args, **kwargs)
for key in keys:
uid = key.replace(prefix, "")
for message in messages:
async_to_sync(self.channel.send)(
uid,
{
"type": "event.message",
"message_type": "message",
"level": message.level_tag,
"tags": message.tags,
"message": message.message,
},
)
return []

View File

@@ -1,7 +1,8 @@
"""Kerberos Source sync tests"""
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import User
from authentik.core.models import Session, User
from authentik.core.tests.utils import create_test_session
from authentik.lib.generators import generate_id
from authentik.sources.kerberos.models import KerberosSource, KerberosSourcePropertyMapping
from authentik.sources.kerberos.sync import KerberosSync
@@ -41,6 +42,21 @@ class TestKerberosSync(KerberosTestCase):
User.objects.filter(username=self.realm.nfs_princ.rsplit("@", 1)[0]).exists()
)
def test_sync_deactivate_expired_principal(self):
"""Test that a user whose principal expired is deactivated and their sessions deleted"""
KerberosSync(self.source, Task()).sync()
user = User.objects.get(username=self.realm.user_princ.rsplit("@", 1)[0])
self.assertTrue(user.is_active)
session = create_test_session(user)
self.realm.run_kadminl(f'modprinc -expire "yesterday" {self.realm.user_princ}')
KerberosSync(self.source, Task()).sync()
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
def test_sync_mapping(self):
"""Test property mappings"""
noop = KerberosSourcePropertyMapping.objects.create(

View File

@@ -8,8 +8,8 @@ from ldap3.core.exceptions import LDAPInvalidFilterError
from ldap3.utils.conv import escape_filter_chars
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.core.models import Group, Session, User
from authentik.core.tests.utils import create_test_admin_user, create_test_session
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id, generate_key
from authentik.lib.sync.outgoing.exceptions import StopSync
@@ -253,6 +253,32 @@ class LDAPSyncTests(TestCase):
self.assertFalse(User.objects.filter(username="user1_sn").exists())
self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active)
def test_sync_users_freeipa_deactivate_deletes_sessions(self):
"""Test that a user deactivated by sync (nsaccountlock) has their sessions deleted"""
self.source.object_uniqueness_field = "uid"
self.source.user_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
)
)
user = User.objects.create(username="user-nsaccountlock", is_active=True)
UserLDAPSourceConnection.objects.create(
user=user,
source=self.source,
identifier="user-nsaccountlock",
)
session = create_test_session(user)
connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source, Task())
user_sync.sync_full()
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(
Session.objects.filter(session_key=session.session.session_key).exists()
)
def test_sync_groups_freeipa_memberOf(self):
"""Test group sync when membership is derived from memberOf user attribute"""
self.source.object_uniqueness_field = "uid"

View File

@@ -108,3 +108,20 @@ class TestPropertyMappings(TestCase):
},
},
)
def test_group_property_mappings_with_object_groups(self):
"""An object-shaped `groups` entry is skipped instead of aborting the flow.
Added in #25195 asserting the `TypeError` that #25191 is about; the
identifier is still unusable as a key, so the entry is dropped rather
than mapped.
"""
info = deepcopy(INFO)
info["groups"] = [
{"id": "group-1", "name": "Admins"},
]
request = self.request_factory.get("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(self.source, request, IDENTIFIER, {"info": info}, {})
self.assertEqual(flow_manager.groups_properties, {})

View File

@@ -1,6 +1,6 @@
"""Test SCIM Group"""
from json import dumps
from json import dumps, loads
from uuid import uuid4
from django.urls import reverse
@@ -60,6 +60,96 @@ class TestSCIMGroups(APITestCase):
self.assertEqual(response.status_code, second=200)
SCIMGroupSchema.model_validate_json(response.content, strict=True)
def test_group_list_filter_members(self):
"""Test group list filtered by ID and member"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
group.users.add(user)
other_group = Group.objects.create(name=generate_id())
other_group.users.add(user)
for _group in [group, other_group]:
SCIMSourceGroup.objects.create(
source=self.source, group=_group, external_id=str(uuid4())
)
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": f'id eq "{group.pk}" and members eq "{user.uuid}"'},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200, response.content)
body = loads(response.content)
self.assertEqual(body["totalResults"], 1)
self.assertEqual(body["Resources"][0]["id"], str(group.pk))
def test_group_list_filter_members_no_match(self):
"""Test group list filtered by a member that isn't part of the group"""
group = Group.objects.create(name=generate_id())
group.users.add(create_test_user())
SCIMSourceGroup.objects.create(source=self.source, group=group, external_id=str(uuid4()))
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": f'id eq "{group.pk}" and members eq "{uuid4()}"'},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(loads(response.content)["totalResults"], 0)
def test_group_list_filter_unknown_attribute(self):
"""Test group list filtered by an attribute that can't be mapped"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": f'urn:foo:bar eq "{generate_id()}"'},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"detail": "Unsupported filter attribute.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "invalidFilter",
"status": 400,
},
)
def test_group_list_filter_invalid(self):
"""Test group list filtered by an unparsable filter"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": "displayName eq"},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"detail": "Invalid filter.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "invalidFilter",
"status": 400,
},
)
def test_group_create(self):
"""Test group create"""
ext_id = generate_id()

View File

@@ -1,12 +1,13 @@
"""Test SCIM User"""
from json import dumps
from json import dumps, loads
from uuid import uuid4
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_user
from authentik.core.models import Session
from authentik.core.tests.utils import create_test_session, create_test_user
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
@@ -55,6 +56,50 @@ class TestSCIMUsers(APITestCase):
self.assertEqual(response.status_code, 200)
SCIMUserSchema.model_validate_json(response.content, strict=True)
def test_user_list_filter(self):
"""Test user list with a filter"""
user = create_test_user()
SCIMSourceUser.objects.create(source=self.source, user=user, external_id=str(uuid4()))
other_user = create_test_user()
SCIMSourceUser.objects.create(source=self.source, user=other_user, external_id=str(uuid4()))
response = self.client.get(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": f'id eq "{user.uuid}" and userName eq "{user.username}"'},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200, response.content)
body = loads(response.content)
self.assertEqual(body["totalResults"], 1)
self.assertEqual(body["Resources"][0]["id"], str(user.uuid))
def test_user_list_filter_invalid(self):
"""Test user list with an unparsable filter"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
data={"filter": "userName eq"},
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"detail": "Invalid filter.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "invalidFilter",
"status": 400,
},
)
def test_user_create(self):
"""Test user create"""
user = create_test_user()
@@ -214,6 +259,40 @@ class TestSCIMUsers(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_user_update_deactivate(self):
"""Test user deactivated via update deletes their sessions"""
user = create_test_user()
session = create_test_session(user)
existing = SCIMSourceUser.objects.create(source=self.source, user=user, external_id=uuid4())
response = self.client.put(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
data=dumps(
{
"id": str(existing.pk),
"userName": user.username,
"active": False,
"emails": [
{
"primary": True,
"value": user.email,
}
],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
user.refresh_from_db()
self.assertFalse(user.is_active)
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
def test_user_update_patch(self):
"""Test user update (patch)"""
user = create_test_user()

View File

@@ -3,6 +3,7 @@
from typing import Any
from uuid import UUID
from django.core.exceptions import FieldError
from django.core.paginator import Page, Paginator
from django.db.models import Q, QuerySet
from django.http import HttpRequest
@@ -12,6 +13,7 @@ from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from scim2_filter_parser.parser import SCIMParserError
from scim2_filter_parser.transpilers.django_q_object import get_query
from structlog import BoundLogger
from structlog.stdlib import get_logger
@@ -21,7 +23,7 @@ from authentik.core.sources.mapper import SourceMapper
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.sources.scim.models import SCIMSource
from authentik.sources.scim.views.v2.auth import SCIMTokenAuth
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
from authentik.sources.scim.views.v2.exceptions import SCIMInvalidFilterError, SCIMNotFoundError
SCIM_CONTENT_TYPE = "application/scim+json"
@@ -62,27 +64,47 @@ class SCIMView(APIView):
data.pop(key.strip(), None)
return data
def filter_parse(self, request: Request):
"""Parse the path of a Patch Operation"""
def filter_parse(self, request: Request) -> Q:
"""Parse the `filter` query parameter into a Q object"""
path = request.query_params.get("filter")
if not path:
return Q()
attr_map = {}
if self.model == User:
attr_map = {
("id", None, None): "user__uuid",
("userName", None, None): "user__username",
("active", None, None): "user__is_active",
("name", "familyName", None): "attributes__familyName",
}
elif self.model == Group:
attr_map = {
("id", None, None): "group__group_uuid",
("displayName", None, None): "group__name",
("members", None, None): "group__users",
# `members` is a many-to-many relation, so it has to be filtered on a
# concrete field of the related user instead of on the relation itself
("members", None, None): "group__users__uuid",
}
return get_query(
path,
attr_map,
)
try:
query = get_query(path, attr_map)
except (SCIMParserError, ValueError) as exc:
self.logger.debug("Failed to parse filter", filter=path, exc=exc)
raise SCIMInvalidFilterError("Invalid filter.") from exc
if query is None:
# None is returned when none of the attributes in the filter can be mapped
# to a field, in which case we can't return any meaningful result
self.logger.debug("Failed to map filter to any field", filter=path)
raise SCIMInvalidFilterError("Unsupported filter attribute.")
return query
def filter_query(self, request: Request, query: QuerySet) -> QuerySet:
"""Apply the `filter` query parameter to `query`"""
parsed = self.filter_parse(request)
try:
return query.filter(parsed)
except FieldError as exc:
self.logger.debug("Failed to apply filter", filter=parsed, exc=exc)
raise SCIMInvalidFilterError("Unsupported filter.") from exc
def paginate_query(self, query: QuerySet) -> Page:
per_page = int(self.request.tenant.pagination_default_page_size)

View File

@@ -56,3 +56,16 @@ class SCIMNotFoundError(SCIMValidationError):
status=self.status_code,
)
)
class SCIMInvalidFilterError(SCIMValidationError):
status_code = 400
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
scimType=SCIMErrorTypes.invalid_filter,
status=self.status_code,
)
)

View File

@@ -72,8 +72,8 @@ class GroupsView(SCIMObjectView):
if not connection:
raise SCIMNotFoundError("Group not found.")
return Response(self.group_to_scim(connection))
connections = (
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
connections = self.filter_query(
request, base_query.filter(source=self.source).order_by("pk")
)
page = self.paginate_query(connections)
return Response(

View File

@@ -74,10 +74,10 @@ class UsersView(SCIMObjectView):
if not connection:
raise SCIMNotFoundError("User not found.")
return Response(self.user_to_scim(connection))
connections = (
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
connections = self.filter_query(
request,
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk"),
)
connections = connections.filter(self.filter_parse(request))
page = self.paginate_query(connections)
return Response(
{

View File

@@ -11,7 +11,10 @@ from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.sources.telegram.models import UserTelegramSourceConnection
from authentik.sources.telegram.stage import TelegramChallengeResponse
from authentik.stages.identification.models import IdentificationStage, UserFields
@@ -185,6 +188,53 @@ class TestTelegramViews(MockTelegramResponseMixin, FlowTestCase):
),
)
def test_challenge_view_preserves_next_redirect(self):
"""Regression test for #18450: completing Telegram auth must preserve the OIDC
redirect_uri so the user returns to the application instead of /if/user/.
Root cause: the pre_authentication_flow executor overwrites SESSION_KEY_GET with
an empty dict (its redirect URL has no query string), losing the original next=
parameter. The fix reads PLAN_CONTEXT_REDIRECT from the executor plan context —
where TelegramStartView saved it before the overwrite — and restores SESSION_KEY_GET.
"""
initial_redirect = f"http://{generate_id()}"
self._make_initial_request()
# Simulate SESSION_KEY_GET as set by the flow executor when the user arrives
# from an OIDC authorization request (e.g. next=/application/o/authorize/?...).
session = self.client.session
session[SESSION_KEY_GET] = {NEXT_ARG_NAME: initial_redirect}
session.save()
# TelegramStartView reads SESSION_KEY_GET and saves it as PLAN_CONTEXT_REDIRECT
# in the pre_authentication_flow plan, then redirects to that flow.
self._make_start_request()
# GET the pre_auth_flow executor. This runs FlowExecutorView.dispatch() which
# unconditionally overwrites SESSION_KEY_GET with an empty dict (the redirect URL
# from TelegramStartView carries no query string). PLAN_CONTEXT_REDIRECT in the
# plan is still intact at this point.
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.pre_auth_flow.slug})
self.client.get(url)
form_data = self._make_valid_response()
form_data["component"] = "ak-source-telegram"
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(
response,
reverse(
"authentik_core:if-flow", kwargs={"flow_slug": self.source.enrollment_flow.slug}
),
)
# The enrollment plan must carry PLAN_CONTEXT_REDIRECT so the user eventually
# reaches the application's callback URL rather than the default /if/user/.
plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN)
self.assertIsNotNone(plan)
self.assertEqual(plan.context.get(PLAN_CONTEXT_REDIRECT), initial_redirect)
def test_connect_user(self):
user = create_test_user("testuser")
user2 = create_test_user("testuser2")

View File

@@ -86,6 +86,12 @@ class TelegramLoginView(ChallengeStageView):
raw_info.pop("hash")
raw_info.pop("auth_date")
source = self.source
# The pre_authentication_flow executor overwrites SESSION_KEY_GET with an empty
# dict (its redirect URL carries no query string), losing the original next= param.
# Restore it from PLAN_CONTEXT_REDIRECT, which TelegramStartView saved before that
# overwrite. Mirrors the same fix in the SAML source's ACSView.
if plan_redirect := self.executor.plan.context.get(PLAN_CONTEXT_REDIRECT):
self.request.session[SESSION_KEY_GET] = {NEXT_ARG_NAME: plan_redirect}
sfm = TelegramSourceFlowManager(
source=source,
request=self.request,

View File

@@ -1,8 +1,10 @@
"""AuthenticatorDuoStage API Views"""
from ssl import SSLCertVerificationError, SSLError
from typing import Any
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import get_objects_for_user
@@ -208,10 +210,33 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
)
created += 1
return {"error": "", "count": created}
# `duo_client` surfaces transport failures as the underlying socket/TLS
# error, which is an OSError and not a RuntimeError. Catching only
# RuntimeError let those escape the view entirely, so an unreachable or
# untrusted Duo endpoint produced no actionable error at all.
# SSLCertVerificationError < SSLError < OSError, so order matters here.
except SSLCertVerificationError as exc:
LOGGER.warning("failed to verify duo api certificate", exc=exc)
return {
"error": _("Failed to connect to Duo: TLS certificate verification failed."),
"count": created,
}
except SSLError as exc:
LOGGER.warning("tls error connecting to duo", exc=exc)
return {
"error": _("Failed to connect to Duo: TLS error."),
"count": created,
}
except OSError as exc:
LOGGER.warning("failed to connect to duo", exc=exc)
return {
"error": _("Failed to connect to Duo."),
"count": created,
}
except RuntimeError as exc:
LOGGER.warning("failed to get users from duo", exc=exc)
return {
"error": "An internal error occurred while importing devices.",
"error": _("An internal error occurred while importing devices."),
"count": created,
}

View File

@@ -1,5 +1,6 @@
"""Test duo stage"""
from ssl import SSLCertVerificationError, SSLError
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -187,6 +188,120 @@ class AuthenticatorDuoStageTests(FlowTestCase):
},
)
def test_api_import_automatic_tls_failure(self):
"""test `import_devices_automatic` when the Duo API certificate doesn't verify
Regression test for #22896: `SSLCertVerificationError` is an `OSError`,
not a `RuntimeError`, so it escaped the handler entirely instead of
producing the documented 400 with a descriptive error.
"""
self.client.force_login(self.user)
stage = AuthenticatorDuoStage.objects.create(
name=generate_id(),
client_id=generate_id(),
client_secret=generate_id(),
api_hostname=generate_id(),
admin_integration_key=generate_id(),
admin_secret_key=generate_id(),
)
ssl_error = SSLCertVerificationError(
1,
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: "
"unable to get local issuer certificate (_ssl.c:1081)",
)
with patch(
"duo_client.admin.Admin.get_users_iterator",
MagicMock(side_effect=ssl_error),
):
response = self.client.post(
reverse(
"authentik_api:authenticatorduostage-import-devices-automatic",
kwargs={
"pk": str(stage.pk),
},
),
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"error": "Failed to connect to Duo: TLS certificate verification failed.",
"count": 0,
},
)
def test_api_import_automatic_tls_error(self):
"""test `import_devices_automatic` on a non-certificate TLS failure
A generic `SSLError` must be reported as a TLS error rather than
claiming certificate verification specifically failed.
"""
self.client.force_login(self.user)
stage = AuthenticatorDuoStage.objects.create(
name=generate_id(),
client_id=generate_id(),
client_secret=generate_id(),
api_hostname=generate_id(),
admin_integration_key=generate_id(),
admin_secret_key=generate_id(),
)
with patch(
"duo_client.admin.Admin.get_users_iterator",
MagicMock(side_effect=SSLError("handshake failure")),
):
response = self.client.post(
reverse(
"authentik_api:authenticatorduostage-import-devices-automatic",
kwargs={
"pk": str(stage.pk),
},
),
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"error": "Failed to connect to Duo: TLS error.",
"count": 0,
},
)
def test_api_import_automatic_connection_failure(self):
"""test `import_devices_automatic` when the Duo API is unreachable
A non-TLS `OSError` must also surface as a descriptive 400 rather than
escaping the handler.
"""
self.client.force_login(self.user)
stage = AuthenticatorDuoStage.objects.create(
name=generate_id(),
client_id=generate_id(),
client_secret=generate_id(),
api_hostname=generate_id(),
admin_integration_key=generate_id(),
admin_secret_key=generate_id(),
)
with patch(
"duo_client.admin.Admin.get_users_iterator",
MagicMock(side_effect=OSError("connection refused")),
):
response = self.client.post(
reverse(
"authentik_api:authenticatorduostage-import-devices-automatic",
kwargs={
"pk": str(stage.pk),
},
),
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{
"error": "Failed to connect to Duo.",
"count": 0,
},
)
def test_api_import_automatic(self):
"""test `import_devices_automatic`"""
self.client.force_login(self.user)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,8 @@
"""Send a test-email with global settings"""
from uuid import uuid4
from django.core.management.base import no_translations
from authentik.lib.utils.reflection import class_to_path
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mail
from authentik.stages.email.utils import TemplateEmailMessage
@@ -16,34 +15,27 @@ class Command(TenantCommand):
@no_translations
def handle_per_tenant(self, *args, **options):
"""Send a test-email with global settings"""
delete_stage = False
stage = None
if options["stage"]:
stages = EmailStage.objects.filter(name=options["stage"])
if not stages.exists():
self.stderr.write(f"Stage '{options['stage']}' does not exist")
return
stage = stages.first()
else:
stage = EmailStage.objects.create(
name=f"temp-global-stage-{uuid4()}", use_global_settings=True
)
delete_stage = True
message = TemplateEmailMessage(
subject="authentik Test-Email",
to=[("", options["to"])],
template_name="email/setup.html",
template_context={},
)
try:
if not stage.use_global_settings:
message.from_email = stage.from_address
# Use the class path instead of the class itself for serialization
stage_class_path, stage_pk = None, None
if stage:
stage_class_path = class_to_path(stage.__class__)
stage_pk = str(stage.pk)
send_mail.send(message.__dict__, stage_class_path, stage_pk).get_result(block=True)
send_mail.send(message.__dict__, stage.pk).get_result(block=True)
self.stdout.write(self.style.SUCCESS(f"Test email sent to {options['to']}"))
finally:
if delete_stage:
stage.delete()
self.stdout.write(self.style.SUCCESS(f"Test email sent to {options['to']}"))
def add_arguments(self, parser):
parser.add_argument("to", type=str)

View File

@@ -1,6 +1,6 @@
"""Test email management commands"""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
@@ -49,18 +49,34 @@ class TestEmailManagementCommands(TestCase):
self.assertEqual(len(mail.outbox), 0)
def test_test_email_command_with_custom_from(self):
"""Test test_email command respects custom from address"""
def test_test_email_command_uses_stage_settings(self):
"""Test test_email command uses the stage's settings, not the global ones"""
EmailStage.objects.create(
name="test-stage",
use_global_settings=False,
from_address="custom@authentik.local",
host="localhost",
port=25,
host="stage.authentik.local",
port=587,
username="stage-user",
password="stage-password", # nosec
use_tls=True,
)
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
backend_class = MagicMock(wraps=EmailBackend)
with patch("authentik.stages.email.models.EmailStage.backend_class", backend_class):
call_command("test_email", "test@example.com", stage="test-stage")
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, "custom@authentik.local")
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].from_email, "custom@authentik.local")
self.assertEqual(
backend_class.call_args.kwargs,
{
"host": "stage.authentik.local",
"port": 587,
"username": "stage-user",
"password": "stage-password",
"use_tls": True,
"use_ssl": False,
"timeout": 10,
},
)

View File

@@ -1,6 +1,7 @@
"""Identification stage logic"""
from dataclasses import asdict
from functools import cache
from typing import Any
from django.contrib.auth.hashers import make_password
@@ -69,6 +70,22 @@ def get_login_serializers():
return mapping
@cache
def login_capable_source_subclasses() -> list[type[Source]]:
"""Concrete Source subclasses that can render a UI login button.
``Source.ui_login_button`` returns None, so a source only reaches the
challenge below if its subclass overrides it. Abstract subclasses are skipped
because they have no table to join against.
"""
return [
source_type
for source_type in all_subclasses(Source)
if not source_type._meta.abstract
and source_type.ui_login_button is not Source.ui_login_button
]
@extend_schema_field(
PolymorphicProxySerializer(
component_name="LoginChallengeTypes",
@@ -386,7 +403,9 @@ class IdentificationStageView(ChallengeStageView):
# Check all enabled source, add them if they have a UI Login button.
ui_sources = []
sources: list[Source] = (
current_stage.sources.filter(enabled=True).order_by("name").select_subclasses()
current_stage.sources.filter(enabled=True)
.order_by("name")
.select_subclasses(*login_capable_source_subclasses())
)
for source in sources:
ui_login_button = source.ui_login_button(self.request)

View File

@@ -19,7 +19,6 @@ from authentik.tenants.utils import normalize_base_url
class FlagJSONField(JSONDictField):
def to_internal_value(self, data: str):
flags = super().to_internal_value(data)
for flag in Flag.available(visibility="system", exclude_system=False):
@@ -61,6 +60,7 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
def map_serializer_field(self, auto_schema, direction):
props = {}
required = []
for flag in Flag.available():
_flag = flag()
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
@@ -68,7 +68,9 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
props[_flag.key]["description"] = _flag.description
if _flag.deprecated:
props[_flag.key]["deprecated"] = _flag.deprecated
return build_object_type(props, required=props.keys())
if not _flag.deprecated:
required.append(_flag.key)
return build_object_type(props, required=required)
class SettingsSerializer(ModelSerializer):

View File

@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
model_name="tenant",
name="default_token_duration",
field=models.TextField(
default=CONFIG.get("default_token_duration", "minutes=30"),
default=CONFIG.get("default_token_duration", "days=1"),
help_text="Default token duration",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),

View File

@@ -33,7 +33,7 @@ entries:
!Find [authentik_flows.flow, [slug, default-request]]
identifiers:
domain: authentik-default
default: true
default: !Env [AUTHENTIK_DEFAULT_BRAND_ENABLED, True]
state: created
conditions:
# Only create default brand if no other default brand exists

View File

@@ -1,45 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"goauthentik.io/internal/common"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint"
"goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/proxyv2"
)
const helpMessage = `authentik proxy
Required environment variables:
- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company")
- AUTHENTIK_TOKEN: Token to authenticate with
- AUTHENTIK_INSECURE: Skip SSL Certificate verification
Optionally, you can set these:
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
var rootCmd = &cobra.Command{
Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: common.PreRun,
RunE: func(cmd *cobra.Command, args []string) error {
err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer)
if err != nil {
fmt.Println(helpMessage)
}
return err
},
}
func main() {
rootCmd.AddCommand(healthcheck.Command)
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View File

@@ -1,10 +0,0 @@
package main
import "os"
func main() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View File

@@ -1,111 +0,0 @@
package main
import (
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/getsentry/sentry-go"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"goauthentik.io/internal/common"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2"
sentryutils "goauthentik.io/internal/utils/sentry"
webutils "goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web"
)
var rootCmd = &cobra.Command{
Use: "authentik",
Short: "Start authentik instance",
Version: constants.FullVersion(),
PersistentPreRun: common.PreRun,
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer("authentik.core")
l := log.WithField("logger", "authentik.root")
if config.Get().ErrorReporting.Enabled {
err := sentry.Init(sentry.ClientOptions{
Dsn: config.Get().ErrorReporting.SentryDSN,
AttachStacktrace: true,
EnableTracing: true,
TracesSampler: sentryutils.SamplerFunc(config.Get().ErrorReporting.SampleRate),
Release: fmt.Sprintf("authentik@%s", constants.VERSION()),
Environment: config.Get().ErrorReporting.Environment,
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgent(), http.DefaultTransport),
IgnoreErrors: []string{
http.ErrAbortHandler.Error(),
},
})
if err != nil {
l.WithError(err).Warning("failed to init sentry")
}
}
ex := common.Init()
defer common.Defer()
u := url.URL{
Scheme: "unix",
Host: fmt.Sprintf("%s/%s", os.TempDir(), web.SocketName),
Path: config.Get().Web.Path,
}
ws := web.NewWebServer()
ws.Core().AddHealthyCallback(func() {
if config.Get().Outposts.DisableEmbeddedOutpost {
return
}
go attemptProxyStart(ws, u)
})
ws.Start()
<-ex
l.Info("shutting down webserver")
go ws.Shutdown()
},
}
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)
if ac == nil {
attempt += 1
time.Sleep(1 * time.Second)
if attempt > maxTries {
break
}
continue
}
ac.AddRefreshHandler(func() {
ws.BrandTLS.Check()
})
srv := proxyv2.NewProxyServer(ac)
ws.ProxyServer = srv.(*proxyv2.ProxyServer)
ac.Server = srv
l.Debug("attempting to start outpost")
err := ac.StartBackgroundTasks()
if err != nil {
l.WithError(err).Warning("outpost failed to start")
attempt += 1
time.Sleep(15 * time.Second)
if attempt > maxTries {
break
}
continue
} else {
select {}
}
}
}

69
go.mod
View File

@@ -6,36 +6,27 @@ require (
beryju.io/ldap v0.2.2
beryju.io/radius-eap v0.1.1
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/getsentry/sentry-go v0.48.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-openapi/runtime v0.33.0
github.com/go-openapi/runtime v0.33.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.3
github.com/grafana/pyroscope-go v1.4.2
github.com/jackc/pgx/v5 v5.10.0
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/mitchellh/mapstructure v1.5.0
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
github.com/pires/go-proxyproto v0.15.0
github.com/prometheus/client_golang v1.24.1
github.com/sethvargo/go-envconfig v1.4.3
github.com/sirupsen/logrus v1.9.4
github.com/sirupsen/logrus v1.10.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/wwt/guac v1.3.2
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
layeh.com/radius v0.0.0-20231213012653-1006025d24f8
)
@@ -43,57 +34,45 @@ require (
github.com/Azure/go-ntlmssp v0.1.1 // 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.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.25.5 // indirect
github.com/go-openapi/analysis v0.26.0 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/loads v0.25.0 // indirect
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
github.com/go-openapi/loads v0.25.1 // indirect
github.com/go-openapi/runtime/server-middleware v0.33.1 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/strfmt v0.27.0 // indirect
github.com/go-openapi/swag/conv v0.27.3 // indirect
github.com/go-openapi/swag/fileutils v0.27.3 // indirect
github.com/go-openapi/swag/jsonutils v0.27.3 // indirect
github.com/go-openapi/swag/loading v0.27.3 // indirect
github.com/go-openapi/swag/mangling v0.27.3 // indirect
github.com/go-openapi/swag/pools v0.27.3 // indirect
github.com/go-openapi/swag/stringutils v0.27.3 // indirect
github.com/go-openapi/swag/typeutils v0.27.3 // indirect
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
github.com/go-openapi/validate v0.26.1 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/fileutils v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/mangling v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-openapi/validate v0.26.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

162
go.sum
View File

@@ -12,29 +12,14 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg=
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1/go.mod h1:PumS+5d59wmAGsZo6IfRpVNaJUq+6xjC4Utt/k8GO6Q=
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 h1:O6yi4xa9b2DMosGsXzlMe2E9qXgXCVkRLCoRX+5amxI=
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27/go.mod h1:AYvN8omj7nKLmbcXS2dyABYU6JB1Lz1bHmkkq1kf4I4=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno=
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -42,50 +27,50 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI=
github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
github.com/go-openapi/analysis v0.26.0 h1:1xECln1iMMmQnTjgcknC1vi1hA4KISt6IHpSwnqcuwI=
github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU=
github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog=
github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs=
github.com/go-openapi/loads v0.25.1 h1:toKQdIDLxlqfKLLGUUmUsiTd5/X0Chzvde9EGYQP/Ac=
github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc=
github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk=
github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4=
github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0=
github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM=
github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc=
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ=
github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE=
github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc=
github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0=
github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE=
github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg=
github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk=
github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI=
github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4=
github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs=
github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us=
github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/validate v0.26.3 h1:OkfZgLvLDnGP2hrRGD+42WBiPWWkoHomTJ+IVI+KaDc=
github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -97,14 +82,10 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -116,14 +97,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/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=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -138,10 +111,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -151,16 +120,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s=
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
@@ -169,8 +136,6 @@ github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -185,47 +150,43 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/sethvargo/go-envconfig v1.4.3 h1:9RJrW9aiy3SJVRJ1svntpZvBw3ghj941u/BseS/TokY=
github.com/sethvargo/go-envconfig v1.4.3/go.mod h1:ebe6rgj7KzrRZPzDXU4W6WZWDEirQwvcgmS0bmC3Sjg=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
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=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab h1:628ME69lBm9C6JY2wXhAph/yjN3jezx1z7BIDLUwxjo=
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -233,10 +194,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -264,8 +223,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -278,14 +237,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
layeh.com/radius v0.0.0-20231213012653-1006025d24f8 h1:orYXpi6BJZdvgytfHH4ybOe4wHnLbbS71Cmd8mWdZjs=
layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o=

View File

@@ -191,44 +191,6 @@ func (c *Config) parseScheme(rawVal string) string {
return rawVal
}
// RefreshPostgreSQLConfig re-reads PostgreSQL configuration from file:// and env:// URIs
// This enables hot-reloading when credentials are rotated by updating the referenced files.
// Note: Plain environment variables (without file:// or env:// prefixes) are read from the
// process environment and will not change unless the process is restarted or os.Setenv is called.
func (c *Config) RefreshPostgreSQLConfig() PostgreSQLConfig {
// Start with current config as base
refreshed := c.PostgreSQL
// Manually read from environment variables with proper prefix
// We can't use env.Process directly on PostgreSQLConfig because it loses the AUTHENTIK_POSTGRESQL__ prefix
// Map of environment variable suffix to config field pointer
envVars := map[string]*string{
"HOST": &refreshed.Host,
"PORT": &refreshed.Port,
"USER": &refreshed.User,
"PASSWORD": &refreshed.Password,
"NAME": &refreshed.Name,
"SSLMODE": &refreshed.SSLMode,
"SSLROOTCERT": &refreshed.SSLRootCert,
"SSLCERT": &refreshed.SSLCert,
"SSLKEY": &refreshed.SSLKey,
"DEFAULT_SCHEMA": &refreshed.DefaultSchema,
"CONN_OPTIONS": &refreshed.ConnOptions,
}
// Read each environment variable if it exists
for suffix, field := range envVars {
if val, ok := os.LookupEnv("AUTHENTIK_POSTGRESQL__" + suffix); ok {
*field = val
}
}
// Process file:// and env:// URI schemes
c.walkScheme(&refreshed)
return refreshed
}
func (c *Config) configureLogger() {
switch strings.ToLower(c.LogLevel) {
case "trace":

View File

@@ -10,22 +10,22 @@ import (
)
func TestConfigEnv(t *testing.T) {
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "bar"))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "bar"))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}
func TestConfigEnv_Scheme(t *testing.T) {
assert.NoError(t, os.Setenv("foo", "bar"))
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "env://foo"))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "env://foo"))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}
func TestConfigEnv_File(t *testing.T) {
@@ -41,10 +41,10 @@ func TestConfigEnv_File(t *testing.T) {
panic(err)
}
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", fmt.Sprintf("file://%s", file.Name())))
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", fmt.Sprintf("file://%s", file.Name())))
cfg = nil
if err := Get().fromEnv(); err != nil {
panic(err)
}
assert.Equal(t, "bar", Get().SecretKey)
assert.Equal(t, "bar", Get().LogLevel)
}

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