mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 14:42:22 +02:00
Compare commits
168 Commits
remove-bas
...
version-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b46b5338 | ||
|
|
cfcdc70542 | ||
|
|
471f0d65e0 | ||
|
|
3c0731ab6d | ||
|
|
bce6ba2afa | ||
|
|
1b490c1b5a | ||
|
|
9a038aa0fe | ||
|
|
c80112e5f8 | ||
|
|
65ae736fe2 | ||
|
|
271e7eae1c | ||
|
|
8bf66b8987 | ||
|
|
6f9369283c | ||
|
|
859ef3a722 | ||
|
|
b47ed8894c | ||
|
|
8c1f08fecb | ||
|
|
cc676793b0 | ||
|
|
76815cd5b9 | ||
|
|
57a378bbd0 | ||
|
|
f542a0d415 | ||
|
|
f69bbbe85e | ||
|
|
0845e23337 | ||
|
|
de6d2a02b2 | ||
|
|
73be5321a9 | ||
|
|
ff8ad31a6b | ||
|
|
ad2cedac98 | ||
|
|
7a0bd0d0c1 | ||
|
|
269551cf4d | ||
|
|
23a6ab915b | ||
|
|
0c77e5c33e | ||
|
|
93aeae5405 | ||
|
|
cd3ff47221 | ||
|
|
a370a54db8 | ||
|
|
1d0e45abe8 | ||
|
|
a501b627eb | ||
|
|
78a4c08fc8 | ||
|
|
8d3a289d12 | ||
|
|
99e92bf998 | ||
|
|
1df7b22d29 | ||
|
|
af484738f6 | ||
|
|
19ba3c8453 | ||
|
|
9d1b384baa | ||
|
|
8a702b148f | ||
|
|
2db42a3a04 | ||
|
|
4403baaa28 | ||
|
|
8030d4d734 | ||
|
|
4b7a78453c | ||
|
|
b9746106a0 | ||
|
|
faa47670ef | ||
|
|
add5f4e0cb | ||
|
|
7636c038d9 | ||
|
|
557f781f5c | ||
|
|
1997011328 | ||
|
|
dfdd5ebc8c | ||
|
|
42977caa6a | ||
|
|
bce46c880d | ||
|
|
9c987c5694 | ||
|
|
f33e50c28c | ||
|
|
280d6febf7 | ||
|
|
3a32918ceb | ||
|
|
723bd4bbbc | ||
|
|
7b130e1832 | ||
|
|
3aa969d64e | ||
|
|
04688c86b3 | ||
|
|
4f8dbb5efb | ||
|
|
3b382199eb | ||
|
|
db50991e9c | ||
|
|
88036ebe14 | ||
|
|
680feaefa1 | ||
|
|
8676cd3a43 | ||
|
|
53e0f6b734 | ||
|
|
eaee475662 | ||
|
|
19b672b3bc | ||
|
|
bf0a31ce86 | ||
|
|
28ff561400 | ||
|
|
ff2472a551 | ||
|
|
dac302e8be | ||
|
|
930a6f7c6f | ||
|
|
b661b0bb39 | ||
|
|
5203713ca0 | ||
|
|
94794b106e | ||
|
|
eb8c21cf04 | ||
|
|
8a501377f2 | ||
|
|
5c67a0fecd | ||
|
|
145e6a3a4f | ||
|
|
0219ed73f5 | ||
|
|
88ccb7857b | ||
|
|
e33d6becba | ||
|
|
41417affc0 | ||
|
|
1b4a6c3f6d | ||
|
|
aa98edd661 | ||
|
|
cb70331c82 | ||
|
|
31e105b190 | ||
|
|
08d2615f71 | ||
|
|
bb9e8b1c42 | ||
|
|
6b8d7376a6 | ||
|
|
4f680d8c06 | ||
|
|
3eeb741975 | ||
|
|
39cb638132 | ||
|
|
be8f5d21cb | ||
|
|
acf18836e8 | ||
|
|
d688621de4 | ||
|
|
5173d09191 | ||
|
|
45bab4d32f | ||
|
|
f383e54c72 | ||
|
|
ab6b9b27cc | ||
|
|
db3fb0bf2e | ||
|
|
5859e6a5e5 | ||
|
|
1d3271fec7 | ||
|
|
673c8ef62c | ||
|
|
4614ae320f | ||
|
|
10b103c0bf | ||
|
|
361e64a8a1 | ||
|
|
e56081b863 | ||
|
|
01a44b281b | ||
|
|
7a6631c6e8 | ||
|
|
ada973dd44 | ||
|
|
3a7e962bde | ||
|
|
ae297e2f60 | ||
|
|
2bc2b6bd41 | ||
|
|
8199371172 | ||
|
|
dac1879de5 | ||
|
|
dd7c6b29d9 | ||
|
|
41b7e05f59 | ||
|
|
c5f5714e02 | ||
|
|
d20d8322af | ||
|
|
288f5d5015 | ||
|
|
a640eb9180 | ||
|
|
7d48baab3e | ||
|
|
b1cd6d34fc | ||
|
|
f14d033cef | ||
|
|
ec75e161e2 | ||
|
|
2e8fb8f2c6 | ||
|
|
362bf22139 | ||
|
|
890da9b287 | ||
|
|
7b8dadf945 | ||
|
|
8f70dbb963 | ||
|
|
15505f5caf | ||
|
|
b2d770c0a4 | ||
|
|
f7e25fff1a | ||
|
|
688b3a9f8b | ||
|
|
8f48e18854 | ||
|
|
306f75be59 | ||
|
|
982c3cf4dc | ||
|
|
156cda6cb6 | ||
|
|
9f5125cf6b | ||
|
|
a84411363e | ||
|
|
9f3bb0210b | ||
|
|
d1271502ef | ||
|
|
d1065b2d49 | ||
|
|
aa19227e30 | ||
|
|
d7a2861bbe | ||
|
|
5aae9c9afa | ||
|
|
93cb48c928 | ||
|
|
55f7f93a24 | ||
|
|
5a608a4235 | ||
|
|
77d023758f | ||
|
|
f9edafd374 | ||
|
|
d94219eb0e | ||
|
|
0871aa2cf3 | ||
|
|
e0fe99d0b8 | ||
|
|
c1acf53585 | ||
|
|
baf4eed0d9 | ||
|
|
60e1192a7a | ||
|
|
2608e02d6e | ||
|
|
0a5928fbcb | ||
|
|
7b99b02b4a | ||
|
|
dfeadc9ebe | ||
|
|
7ff64fbd09 |
@@ -1,10 +1,11 @@
|
|||||||
"""Helper script to get the actual branch name, docker safe"""
|
"""Helper script to get the actual branch name, docker safe"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from importlib.metadata import version as package_version
|
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
|
from authentik import authentik_version
|
||||||
|
|
||||||
# Decide if we should push the image or not
|
# Decide if we should push the image or not
|
||||||
should_push = True
|
should_push = True
|
||||||
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
|
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
|
||||||
@@ -28,7 +29,7 @@ is_release = "dev" not in image_names[0]
|
|||||||
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
|
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
|
||||||
|
|
||||||
# 2042.1.0 or 2042.1.0-rc1
|
# 2042.1.0 or 2042.1.0-rc1
|
||||||
version = package_version("authentik")
|
version = authentik_version()
|
||||||
# 2042.1
|
# 2042.1
|
||||||
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
|
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
|
||||||
prerelease = "-" in version
|
prerelease = "-" in version
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ${{ inputs.image_name }}
|
image-name: ${{ inputs.image_name }}
|
||||||
image-arch: ${{ inputs.image_arch }}
|
image-arch: ${{ inputs.image_arch }}
|
||||||
@@ -58,8 +58,8 @@ jobs:
|
|||||||
if: ${{ inputs.registry_dockerhub }}
|
if: ${{ inputs.registry_dockerhub }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: ${{ inputs.registry_ghcr }}
|
if: ${{ inputs.registry_ghcr }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -67,14 +67,20 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: make empty clients
|
- name: Setup node
|
||||||
if: ${{ inputs.release }}
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: web/package.json
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: Setup go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
- name: Generate API Clients
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./gen-ts-api
|
make gen-client-ts
|
||||||
mkdir -p ./gen-go-api
|
make gen-client-go
|
||||||
- name: generate ts client
|
|
||||||
if: ${{ !inputs.release }}
|
|
||||||
run: make gen-client-ts
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
id: push
|
id: push
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ${{ inputs.image_name }}
|
image-name: ${{ inputs.image_name }}
|
||||||
merge-server:
|
merge-server:
|
||||||
@@ -74,15 +74,15 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ${{ inputs.image_name }}
|
image-name: ${{ inputs.image_name }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ inputs.registry_dockerhub }}
|
if: ${{ inputs.registry_dockerhub }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: ${{ inputs.registry_ghcr }}
|
if: ${{ inputs.registry_ghcr }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
1
.github/workflows/api-ts-publish.yml
vendored
1
.github/workflows/api-ts-publish.yml
vendored
@@ -10,7 +10,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
|
|||||||
1
.github/workflows/ci-docs-source.yml
vendored
1
.github/workflows/ci-docs-source.yml
vendored
@@ -13,7 +13,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-source-docs:
|
publish-source-docs:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -61,7 +61,6 @@ jobs:
|
|||||||
working-directory: website/
|
working-directory: website/
|
||||||
run: npm run build -w integrations
|
run: npm run build -w integrations
|
||||||
build-container:
|
build-container:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
# Needed to upload container images to ghcr.io
|
# Needed to upload container images to ghcr.io
|
||||||
@@ -81,7 +80,7 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/dev-docs
|
image-name: ghcr.io/goauthentik/dev-docs
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
@@ -121,4 +120,3 @@ jobs:
|
|||||||
- uses: re-actors/alls-green@release/v1
|
- uses: re-actors/alls-green@release/v1
|
||||||
with:
|
with:
|
||||||
jobs: ${{ toJSON(needs) }}
|
jobs: ${{ toJSON(needs) }}
|
||||||
allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }}
|
|
||||||
|
|||||||
1
.github/workflows/ci-main-daily.yml
vendored
1
.github/workflows/ci-main-daily.yml
vendored
@@ -9,7 +9,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-container:
|
test-container:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
12
.github/workflows/ci-main.yml
vendored
12
.github/workflows/ci-main.yml
vendored
@@ -80,7 +80,15 @@ jobs:
|
|||||||
cp authentik/lib/default.yml local.env.yml
|
cp authentik/lib/default.yml local.env.yml
|
||||||
cp -R .github ..
|
cp -R .github ..
|
||||||
cp -R scripts ..
|
cp -R scripts ..
|
||||||
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
# Previous stable tag
|
||||||
|
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||||
|
# Current version family based on
|
||||||
|
current_version_family=$(python -c "from authentik import VERSION; print(VERSION)" | grep -vE -- 'rc[0-9]+$')
|
||||||
|
if [[ -n $current_version_family ]]; then
|
||||||
|
prev_stable=$current_version_family
|
||||||
|
fi
|
||||||
|
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||||
|
git checkout $(prev_stable)
|
||||||
rm -rf .github/ scripts/
|
rm -rf .github/ scripts/
|
||||||
mv ../.github ../scripts .
|
mv ../.github ../scripts .
|
||||||
- name: Setup authentik env (stable)
|
- name: Setup authentik env (stable)
|
||||||
@@ -278,7 +286,7 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/dev-server
|
image-name: ghcr.io/goauthentik/dev-server
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
|
|||||||
3
.github/workflows/ci-outpost.yml
vendored
3
.github/workflows/ci-outpost.yml
vendored
@@ -59,7 +59,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
jobs: ${{ toJSON(needs) }}
|
jobs: ${{ toJSON(needs) }}
|
||||||
build-container:
|
build-container:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
needs:
|
needs:
|
||||||
- ci-outpost-mark
|
- ci-outpost-mark
|
||||||
@@ -90,7 +89,7 @@ jobs:
|
|||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
|
|||||||
15
.github/workflows/gh-ghcr-retention.yml
vendored
15
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -5,10 +5,13 @@ on:
|
|||||||
# schedule:
|
# schedule:
|
||||||
# - cron: "0 0 * * *" # every day at midnight
|
# - cron: "0 0 * * *" # every day at midnight
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry-run:
|
||||||
|
type: boolean
|
||||||
|
description: Enable dry-run mode
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
clean-ghcr:
|
clean-ghcr:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
name: Delete old unused container images
|
name: Delete old unused container images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -18,12 +21,12 @@ jobs:
|
|||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
- name: Delete 'dev' containers older than a week
|
- name: Delete 'dev' containers older than a week
|
||||||
uses: snok/container-retention-policy@v2
|
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
|
||||||
with:
|
with:
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
image-names: dev-server,dev-ldap,dev-proxy
|
||||||
|
image-tags: "!gh-next,!gh-main"
|
||||||
cut-off: One week ago UTC
|
cut-off: One week ago UTC
|
||||||
account-type: org
|
account: goauthentik
|
||||||
org-name: goauthentik
|
tag-selection: untagged
|
||||||
untagged-only: false
|
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
skip-tags: gh-next,gh-main
|
dry-run: ${{ inputs.dry-run }}
|
||||||
|
|||||||
1
.github/workflows/packages-npm-publish.yml
vendored
1
.github/workflows/packages-npm-publish.yml
vendored
@@ -14,7 +14,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
168
.github/workflows/release-bump-version.yml
vendored
168
.github/workflows/release-bump-version.yml
vendored
@@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
name: Release - Bump version
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Version
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
release_reason:
|
|
||||||
description: Release reason
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- bugfix
|
|
||||||
- feature
|
|
||||||
- security
|
|
||||||
- other
|
|
||||||
- prerelease
|
|
||||||
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: authentik
|
|
||||||
POSTGRES_USER: authentik
|
|
||||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-inputs:
|
|
||||||
name: Check inputs validity
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: check
|
|
||||||
run: |
|
|
||||||
echo "${{ inputs.version }}" | grep -E "^[0-9]{4}\.[0-9]{1,2}\.[0-9]+(-rc[0-9]+)?$"
|
|
||||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
|
||||||
outputs:
|
|
||||||
major_version: "${{ steps.check.outputs.major_version }}"
|
|
||||||
bump-authentik:
|
|
||||||
name: Bump authentik version
|
|
||||||
needs:
|
|
||||||
- check-inputs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: app-token
|
|
||||||
name: Generate app token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
- id: get-user-id
|
|
||||||
name: Get GitHub app user ID
|
|
||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- name: Run migrations
|
|
||||||
run: make migrate
|
|
||||||
- name: Bump version
|
|
||||||
run: "make bump version=${{ inputs.version }}"
|
|
||||||
- name: Commit and push
|
|
||||||
run: |
|
|
||||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
|
||||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
|
||||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
|
||||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
|
||||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
|
||||||
git push --follow-tags
|
|
||||||
bump-helm:
|
|
||||||
name: Bump Helm version
|
|
||||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
|
||||||
needs:
|
|
||||||
- bump-authentik
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: app-token
|
|
||||||
name: Generate app token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
repositories: helm
|
|
||||||
- id: get-user-id
|
|
||||||
name: Get GitHub app user ID
|
|
||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
repository: "${{ github.repository_owner }}/helm"
|
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- name: Bump version
|
|
||||||
run: |
|
|
||||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
|
||||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
|
||||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
|
||||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
|
||||||
./scripts/helm-docs.sh
|
|
||||||
- name: Create pull request
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
|
||||||
branch: bump-${{ inputs.version }}
|
|
||||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
|
||||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
|
||||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
|
||||||
delete-branch: true
|
|
||||||
signoff: true
|
|
||||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] ${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
|
|
||||||
bump-version:
|
|
||||||
name: Bump version repository
|
|
||||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
|
||||||
needs:
|
|
||||||
- check-inputs
|
|
||||||
- bump-authentik
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: app-token
|
|
||||||
name: Generate app token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.GH_APP_ID }}
|
|
||||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
repositories: version
|
|
||||||
- id: get-user-id
|
|
||||||
name: Get GitHub app user ID
|
|
||||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
repository: "${{ github.repository_owner }}/version"
|
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
|
||||||
- name: Bump feature version
|
|
||||||
if: "${{ inputs.release_reason == 'feature' }}"
|
|
||||||
run: |
|
|
||||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
|
||||||
jq \
|
|
||||||
--arg version "${{ inputs.version }}" \
|
|
||||||
--arg changelog "See ${changelog_url}" \
|
|
||||||
--arg changelog_url "${changelog_url}" \
|
|
||||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
|
||||||
mv version.new.json version.json
|
|
||||||
- name: Bump feature version
|
|
||||||
if: "${{ inputs.release_reason != 'feature' }}"
|
|
||||||
run: |
|
|
||||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
|
||||||
jq \
|
|
||||||
--arg version "${{ inputs.version }}" \
|
|
||||||
--arg changelog "See ${changelog_url}" \
|
|
||||||
--arg changelog_url "${changelog_url}" \
|
|
||||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
|
||||||
mv version.new.json version.json
|
|
||||||
- name: Create pull request
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
token: "${{ steps.app-token.outputs.token }}"
|
|
||||||
branch: bump-${{ inputs.version }}
|
|
||||||
commit-message: "version: bump to ${{ inputs.version }}"
|
|
||||||
title: "version: bump to ${{ inputs.version }}"
|
|
||||||
body: "version: bump to ${{ inputs.version }}"
|
|
||||||
delete-branch: true
|
|
||||||
signoff: true
|
|
||||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
|
||||||
1
.github/workflows/release-next-branch.yml
vendored
1
.github/workflows/release-next-branch.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-next:
|
update-next:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: internal-production
|
environment: internal-production
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
23
.github/workflows/release-publish.yml
vendored
23
.github/workflows/release-publish.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/_reusable-docker-build.yaml
|
uses: ./.github/workflows/_reusable-docker-build.yaml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
# Needed to upload container images to ghcr.io
|
# Needed to upload container images to ghcr.io
|
||||||
packages: write
|
packages: write
|
||||||
# Needed for attestation
|
# Needed for attestation
|
||||||
@@ -23,6 +24,7 @@ jobs:
|
|||||||
build-docs:
|
build-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
# Needed to upload container images to ghcr.io
|
# Needed to upload container images to ghcr.io
|
||||||
packages: write
|
packages: write
|
||||||
# Needed for attestation
|
# Needed for attestation
|
||||||
@@ -66,6 +68,7 @@ jobs:
|
|||||||
build-outpost:
|
build-outpost:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
# Needed to upload container images to ghcr.io
|
# Needed to upload container images to ghcr.io
|
||||||
packages: write
|
packages: write
|
||||||
# Needed for attestation
|
# Needed for attestation
|
||||||
@@ -84,6 +87,11 @@ jobs:
|
|||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version-file: web/package.json
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.6.0
|
uses: docker/setup-qemu-action@v3.6.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -95,10 +103,10 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||||
- name: make empty clients
|
- name: Generate API Clients
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./gen-ts-api
|
make gen-client-ts
|
||||||
mkdir -p ./gen-go-api
|
make gen-client-go
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -152,10 +160,17 @@ jobs:
|
|||||||
node-version-file: web/package.json
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build web
|
- name: Install web dependencies
|
||||||
working-directory: web/
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
|
- name: Generate API Clients
|
||||||
|
run: |
|
||||||
|
make gen-client-ts
|
||||||
|
make gen-client-go
|
||||||
|
- name: Build web
|
||||||
|
working-directory: web/
|
||||||
|
run: |
|
||||||
npm run build-proxy
|
npm run build-proxy
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
213
.github/workflows/release-tag.yml
vendored
213
.github/workflows/release-tag.yml
vendored
@@ -1,39 +1,202 @@
|
|||||||
---
|
---
|
||||||
name: Release - On tag
|
name: Release - Tag new version
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
tags:
|
inputs:
|
||||||
- "version/*"
|
version:
|
||||||
|
description: Version
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_reason:
|
||||||
|
description: Release reason
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- bugfix
|
||||||
|
- feature
|
||||||
|
- security
|
||||||
|
- other
|
||||||
|
- prerelease
|
||||||
|
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: authentik
|
||||||
|
POSTGRES_USER: authentik
|
||||||
|
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
check-inputs:
|
||||||
name: Create Release from Tag
|
name: Check inputs validity
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- id: check
|
||||||
- name: Pre-release test
|
|
||||||
run: |
|
run: |
|
||||||
make test-docker
|
echo "${{ inputs.version }}" | grep -E '^[0-9]{4}\.(0?[1-9]|1[0-2])\.[0-9]+(-rc[0-9]+)?$'
|
||||||
- id: generate_token
|
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||||
uses: tibdex/github-app-token@v2
|
- id: changelog-url
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.release_reason }}" = "feature" ] || [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||||
|
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||||
|
else
|
||||||
|
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version }} | sed 's/\.//g')"
|
||||||
|
fi
|
||||||
|
echo "changelog_url=${changelog_url}" >> "$GITHUB_OUTPUT"
|
||||||
|
outputs:
|
||||||
|
major_version: "${{ steps.check.outputs.major_version }}"
|
||||||
|
changelog_url: "${{ steps.changelog-url.outputs.changelog_url }}"
|
||||||
|
test:
|
||||||
|
name: Pre-release test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- check-inputs
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
- name: Setup authentik env
|
||||||
- name: prepare variables
|
uses: ./.github/actions/setup
|
||||||
uses: ./.github/actions/docker-push-variables
|
- run: make test-docker
|
||||||
id: ev
|
bump-authentik:
|
||||||
|
name: Bump authentik version
|
||||||
|
needs:
|
||||||
|
- check-inputs
|
||||||
|
- test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- id: app-token
|
||||||
|
name: Generate app token
|
||||||
|
uses: actions/create-github-app-token@v2
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
- id: get-user-id
|
||||||
|
name: Get GitHub app user ID
|
||||||
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
image-name: ghcr.io/goauthentik/server
|
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||||
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- name: Setup authentik env
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
- name: Run migrations
|
||||||
|
run: make migrate
|
||||||
|
- name: Bump version
|
||||||
|
run: "make bump version=${{ inputs.version }}"
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||||
|
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||||
|
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||||
|
git pull
|
||||||
|
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||||
|
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||||
|
git push --follow-tags
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
uses: softprops/action-gh-release@v2
|
||||||
uses: actions/create-release@v1.1.4
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref }}
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
release_name: Release ${{ steps.ev.outputs.version }}
|
tag_name: "version/${{ inputs.version }}"
|
||||||
|
name: Release ${{ inputs.version }}
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: ${{ steps.ev.outputs.prerelease == 'true' }}
|
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||||
|
generate_release_notes: true
|
||||||
|
body: |
|
||||||
|
See ${{ needs.check-inputs.outputs.changelog_url }}
|
||||||
|
bump-helm:
|
||||||
|
name: Bump Helm version
|
||||||
|
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||||
|
needs:
|
||||||
|
- bump-authentik
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- id: app-token
|
||||||
|
name: Generate app token
|
||||||
|
uses: actions/create-github-app-token@v2
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
repositories: helm
|
||||||
|
- id: get-user-id
|
||||||
|
name: Get GitHub app user ID
|
||||||
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: "${{ github.repository_owner }}/helm"
|
||||||
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- name: Bump version
|
||||||
|
run: |
|
||||||
|
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||||
|
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||||
|
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||||
|
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||||
|
./scripts/helm-docs.sh
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
with:
|
||||||
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
|
branch: bump-${{ inputs.version }}
|
||||||
|
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||||
|
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||||
|
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||||
|
delete-branch: true
|
||||||
|
signoff: true
|
||||||
|
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||||
|
bump-version:
|
||||||
|
name: Bump version repository
|
||||||
|
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||||
|
needs:
|
||||||
|
- check-inputs
|
||||||
|
- bump-authentik
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- id: app-token
|
||||||
|
name: Generate app token
|
||||||
|
uses: actions/create-github-app-token@v2
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.GH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
repositories: version
|
||||||
|
- id: get-user-id
|
||||||
|
name: Get GitHub app user ID
|
||||||
|
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: "${{ github.repository_owner }}/version"
|
||||||
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
|
- name: Bump version
|
||||||
|
if: "${{ inputs.release_reason == 'feature' }}"
|
||||||
|
run: |
|
||||||
|
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||||
|
jq \
|
||||||
|
--arg version "${{ inputs.version }}" \
|
||||||
|
--arg changelog "See ${changelog_url}" \
|
||||||
|
--arg changelog_url "${changelog_url}" \
|
||||||
|
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||||
|
mv version.new.json version.json
|
||||||
|
- name: Bump version
|
||||||
|
if: "${{ inputs.release_reason != 'feature' }}"
|
||||||
|
run: |
|
||||||
|
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||||
|
jq \
|
||||||
|
--arg version "${{ inputs.version }}" \
|
||||||
|
--arg changelog "See ${changelog_url}" \
|
||||||
|
--arg changelog_url "${changelog_url}" \
|
||||||
|
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||||
|
mv version.new.json version.json
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
with:
|
||||||
|
token: "${{ steps.app-token.outputs.token }}"
|
||||||
|
branch: bump-${{ inputs.version }}
|
||||||
|
commit-message: "version: bump to ${{ inputs.version }}"
|
||||||
|
title: "version: bump to ${{ inputs.version }}"
|
||||||
|
body: "version: bump to ${{ inputs.version }}"
|
||||||
|
delete-branch: true
|
||||||
|
signoff: true
|
||||||
|
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||||
|
|||||||
22
.github/workflows/repo-mirror-cleanup.yml
vendored
22
.github/workflows/repo-mirror-cleanup.yml
vendored
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
name: Repo - Cleanup internal mirror
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
to_internal:
|
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- if: ${{ env.MIRROR_KEY != '' }}
|
|
||||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
|
||||||
with:
|
|
||||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
|
||||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
|
||||||
args: --tags --force --prune
|
|
||||||
env:
|
|
||||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
|
||||||
21
.github/workflows/repo-mirror.yml
vendored
21
.github/workflows/repo-mirror.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
name: Repo - Mirror to internal
|
|
||||||
|
|
||||||
on: [push, delete]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
to_internal:
|
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- if: ${{ env.MIRROR_KEY != '' }}
|
|
||||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
|
|
||||||
with:
|
|
||||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
|
||||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
|
||||||
args: --tags --force
|
|
||||||
env:
|
|
||||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
|
||||||
1
.github/workflows/repo-stale.yml
vendored
1
.github/workflows/repo-stale.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
compile:
|
compile:
|
||||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
website/docs/developer-docs/index.md
|
|
||||||
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Contributing to authentik
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! Please see our [contributing guide](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github) for more information.
|
||||||
|
|
||||||
11
Dockerfile
11
Dockerfile
@@ -26,7 +26,7 @@ RUN npm run build && \
|
|||||||
npm run build:sfe
|
npm run build:sfe
|
||||||
|
|
||||||
# Stage 2: Build go proxy
|
# Stage 2: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25-bookworm AS go-builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -44,6 +44,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
|||||||
|
|
||||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||||
|
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||||
--mount=type=cache,target=/go/pkg/mod \
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ COPY ./go.mod /go/src/goauthentik.io/go.mod
|
|||||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||||
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
|
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
@@ -119,7 +121,11 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
|||||||
libltdl-dev && \
|
libltdl-dev && \
|
||||||
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||||
|
|
||||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
||||||
|
# https://github.com/rust-lang/rustup/issues/2949
|
||||||
|
# Fixes issues where the rust version in the build cache is older than latest
|
||||||
|
# and rustup tries to update it, which fails
|
||||||
|
RUSTUP_PERMIT_COPY_RENAME="true"
|
||||||
|
|
||||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||||
@@ -175,6 +181,7 @@ COPY ./lifecycle/ /lifecycle
|
|||||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||||
COPY --from=go-builder /go/authentik /bin/authentik
|
COPY --from=go-builder /go/authentik /bin/authentik
|
||||||
COPY ./packages/ /ak-root/packages
|
COPY ./packages/ /ak-root/packages
|
||||||
|
RUN ln -s /ak-root/packages /packages
|
||||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||||
COPY --from=node-builder /work/web/dist/ /web/dist/
|
COPY --from=node-builder /work/web/dist/ /web/dist/
|
||||||
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
||||||
|
|||||||
15
Makefile
15
Makefile
@@ -98,11 +98,11 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
|||||||
ifndef version
|
ifndef version
|
||||||
$(error Usage: make bump version=20xx.xx.xx )
|
$(error Usage: make bump version=20xx.xx.xx )
|
||||||
endif
|
endif
|
||||||
|
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
|
||||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||||
$(MAKE) gen-build gen-compose aws-cfn
|
$(MAKE) gen-build gen-compose aws-cfn
|
||||||
npm version --no-git-tag-version --allow-same-version $(version)
|
sed -i "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/package-lock.json ${PWD}/web/package.json ${PWD}/web/package-lock.json
|
||||||
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
|
|
||||||
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
@@ -144,12 +144,7 @@ gen-clean-ts: ## Remove generated API client for TypeScript
|
|||||||
rm -rf ${PWD}/web/node_modules/@goauthentik/api/
|
rm -rf ${PWD}/web/node_modules/@goauthentik/api/
|
||||||
|
|
||||||
gen-clean-go: ## Remove generated API client for Go
|
gen-clean-go: ## Remove generated API client for Go
|
||||||
mkdir -p ${PWD}/${GEN_API_GO}
|
|
||||||
ifneq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
|
||||||
make -C ${PWD}/${GEN_API_GO} clean
|
|
||||||
else
|
|
||||||
rm -rf ${PWD}/${GEN_API_GO}
|
rm -rf ${PWD}/${GEN_API_GO}
|
||||||
endif
|
|
||||||
|
|
||||||
gen-clean-py: ## Remove generated API client for Python
|
gen-clean-py: ## Remove generated API client for Python
|
||||||
rm -rf ${PWD}/${GEN_API_PY}/
|
rm -rf ${PWD}/${GEN_API_PY}/
|
||||||
@@ -187,13 +182,9 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
|||||||
|
|
||||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||||
mkdir -p ${PWD}/${GEN_API_GO}
|
mkdir -p ${PWD}/${GEN_API_GO}
|
||||||
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
|
||||||
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||||
else
|
|
||||||
cd ${PWD}/${GEN_API_GO} && git pull
|
|
||||||
endif
|
|
||||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
||||||
make -C ${PWD}/${GEN_API_GO} build
|
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
|
||||||
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
||||||
|
|
||||||
gen-dev-config: ## Generate a local development config file
|
gen-dev-config: ## Generate a local development config file
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -15,15 +15,16 @@
|
|||||||
|
|
||||||
## What is authentik?
|
## What is authentik?
|
||||||
|
|
||||||
authentik is an open-source Identity Provider that emphasizes flexibility and versatility, with support for a wide set of protocols.
|
authentik is an open-source Identity Provider (IdP) for modern SSO. It supports SAML, OAuth2/OIDC, LDAP, RADIUS, and more, designed for self-hosting from small labs to large production clusters.
|
||||||
|
|
||||||
Our [enterprise offer](https://goauthentik.io/pricing) can also be used as a self-hosted replacement for large-scale deployments of Okta/Auth0, Entra ID, Ping Identity, or other legacy IdPs for employees and B2B2C use.
|
Our [enterprise offering](https://goauthentik.io/pricing) is available for organizations to securely replace existing IdPs such as Okta, Auth0, Entra ID, and Ping Identity for robust, large-scale identity management.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
For small/test setups it is recommended to use Docker Compose; refer to the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github).
|
- Docker Compose: recommended for small/test setups. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/docker-compose/).
|
||||||
|
- Kubernetes (Helm Chart): recommended for larger setups. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/kubernetes/) and the Helm chart [repository](https://github.com/goauthentik/helm).
|
||||||
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github).
|
- AWS CloudFormation: deploy on AWS using our official templates. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/aws/).
|
||||||
|
- DigitalOcean Marketplace: one-click deployment via the official Marketplace app. See the [app listing](https://marketplace.digitalocean.com/apps/authentik).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -32,14 +33,20 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
|
|||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Development
|
## Development and contributions
|
||||||
|
|
||||||
See [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github)
|
See the [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/) for information about setting up local build environments, testing your contributions, and our contribution process.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md)
|
Please see [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
## Adoption and Contributions
|
## Adoption
|
||||||
|
|
||||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [contribution guide](https://docs.goauthentik.io/docs/developer-docs?utm_source=github).
|
Using authentik? We'd love to hear your story and feature your logo. Email us at [hello@goauthentik.io](mailto:hello@goauthentik.io) or open a GitHub Issue/PR!
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](website/LICENSE)
|
||||||
|
[](authentik/enterprise/LICENSE)
|
||||||
|
|||||||
25
SECURITY.md
25
SECURITY.md
@@ -20,12 +20,33 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | --------- |
|
| --------- | --------- |
|
||||||
| 2025.4.x | ✅ |
|
|
||||||
| 2025.6.x | ✅ |
|
| 2025.6.x | ✅ |
|
||||||
|
| 2025.8.x | ✅ |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io). Be sure to include relevant information like which version you've found the issue in, instructions on how to reproduce the issue, and anything else that might make it easier for us to find the issue.
|
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 [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)
|
||||||
|
|
||||||
|
When submitting a report, please include as much detail as possible, such as:
|
||||||
|
|
||||||
|
- **Affected version(s)**: The version of authentik where the issue was identified.
|
||||||
|
- **Steps to reproduce**: A clear description or proof of concept to help us verify the issue.
|
||||||
|
- **Impact assessment**: How the vulnerability could be exploited and its potential effect.
|
||||||
|
- **Additional information**: Logs, configuration details (if relevant), or any suggested mitigations.
|
||||||
|
|
||||||
|
We kindly ask that you do not disclose the vulnerability publicly until we have confirmed and addressed the issue.
|
||||||
|
|
||||||
|
Our team will:
|
||||||
|
|
||||||
|
- Acknowledge receipt of your report as quickly as possible.
|
||||||
|
- Keep you updated on the investigation and resolution progress.
|
||||||
|
|
||||||
|
## Researcher Recognition
|
||||||
|
|
||||||
|
We value contributions from the security community. For each valid report, we will publish a dedicated entry on our Security Advisory page that optionally includes the reporter’s name (or preferred alias). Please note that while we do not currently offer monetary bounties, we are committed to giving researchers appropriate credit for their efforts in keeping authentik secure.
|
||||||
|
|
||||||
## Severity levels
|
## Severity levels
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
VERSION = "2025.8.0-rc1"
|
VERSION = "2025.8.6"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from authentik.blueprints.v1.oci import OCI_PREFIX
|
|||||||
from authentik.events.logs import capture_logs
|
from authentik.events.logs import capture_logs
|
||||||
from authentik.events.utils import sanitize_dict
|
from authentik.events.utils import sanitize_dict
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.tasks.apps import PRIORITY_HIGH
|
||||||
from authentik.tasks.models import Task
|
from authentik.tasks.models import Task
|
||||||
from authentik.tasks.schedules.models import Schedule
|
from authentik.tasks.schedules.models import Schedule
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@@ -110,7 +111,7 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
|||||||
|
|
||||||
@actor(
|
@actor(
|
||||||
description=_("Find blueprints as `blueprints_find` does, but return a safe dict."),
|
description=_("Find blueprints as `blueprints_find` does, but return a safe dict."),
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
priority=PRIORITY_HIGH,
|
||||||
)
|
)
|
||||||
def blueprints_find_dict():
|
def blueprints_find_dict():
|
||||||
blueprints = []
|
blueprints = []
|
||||||
@@ -148,10 +149,7 @@ def blueprints_find() -> list[BlueprintFile]:
|
|||||||
return blueprints
|
return blueprints
|
||||||
|
|
||||||
|
|
||||||
@actor(
|
@actor(description=_("Find blueprints and check if they need to be created in the database."))
|
||||||
description=_("Find blueprints and check if they need to be created in the database."),
|
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
|
||||||
)
|
|
||||||
def blueprints_discovery(path: str | None = None):
|
def blueprints_discovery(path: str | None = None):
|
||||||
self: Task = CurrentTask.get_task()
|
self: Task = CurrentTask.get_task()
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
@@ -63,28 +63,6 @@ class TestBrands(APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_brand_subdomain_same_suffix(self):
|
|
||||||
"""Test Current brand API"""
|
|
||||||
Brand.objects.all().delete()
|
|
||||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
|
||||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom")
|
|
||||||
self.assertJSONEqual(
|
|
||||||
self.client.get(
|
|
||||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
|
||||||
).content.decode(),
|
|
||||||
{
|
|
||||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
|
||||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
|
||||||
"branding_title": "custom",
|
|
||||||
"branding_custom_css": "",
|
|
||||||
"matched_domain": "foo.bar.baz",
|
|
||||||
"ui_footer_links": [],
|
|
||||||
"ui_theme": Themes.AUTOMATIC,
|
|
||||||
"default_locale": "",
|
|
||||||
"flags": self.default_flags,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fallback(self):
|
def test_fallback(self):
|
||||||
"""Test fallback brand"""
|
"""Test fallback brand"""
|
||||||
Brand.objects.all().delete()
|
Brand.objects.all().delete()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.db.models import Value as V
|
from django.db.models import Value as V
|
||||||
from django.db.models.functions import Length
|
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from django.utils.html import _json_script_escapes
|
from django.utils.html import _json_script_escapes
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@@ -21,9 +20,9 @@ DEFAULT_BRAND = Brand(domain="fallback")
|
|||||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||||
"""Get brand object for current request"""
|
"""Get brand object for current request"""
|
||||||
db_brands = (
|
db_brands = (
|
||||||
Brand.objects.annotate(host_domain=V(request.get_host()), match_length=Length("domain"))
|
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||||
.order_by("-match_length", "default")
|
.order_by("default")
|
||||||
)
|
)
|
||||||
brands = list(db_brands.all())
|
brands = list(db_brands.all())
|
||||||
if len(brands) < 1:
|
if len(brands) < 1:
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ from authentik.core.models import Provider
|
|||||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
"""Provider Serializer"""
|
"""Provider Serializer"""
|
||||||
|
|
||||||
assigned_application_slug = ReadOnlyField(source="application.slug")
|
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||||
assigned_application_name = ReadOnlyField(source="application.name")
|
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||||
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
assigned_backchannel_application_slug = ReadOnlyField(
|
||||||
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
source="backchannel_application.slug", allow_null=True
|
||||||
|
)
|
||||||
|
assigned_backchannel_application_name = ReadOnlyField(
|
||||||
|
source="backchannel_application.name", allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
component = SerializerMethodField()
|
component = SerializerMethodField()
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,12 @@ class SessionUserSerializer(PassiveSerializer):
|
|||||||
original = UserSelfSerializer(required=False)
|
original = UserSelfSerializer(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordSetSerializer(PassiveSerializer):
|
||||||
|
"""Payload to set a users' password directly"""
|
||||||
|
|
||||||
|
password = CharField(required=True)
|
||||||
|
|
||||||
|
|
||||||
class UsersFilter(FilterSet):
|
class UsersFilter(FilterSet):
|
||||||
"""Filter for users"""
|
"""Filter for users"""
|
||||||
|
|
||||||
@@ -585,12 +591,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@permission_required("authentik_core.reset_user_password")
|
@permission_required("authentik_core.reset_user_password")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=inline_serializer(
|
request=UserPasswordSetSerializer,
|
||||||
"UserPasswordSetSerializer",
|
|
||||||
{
|
|
||||||
"password": CharField(required=True),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
responses={
|
responses={
|
||||||
204: OpenApiResponse(description="Successfully changed password"),
|
204: OpenApiResponse(description="Successfully changed password"),
|
||||||
400: OpenApiResponse(description="Bad request"),
|
400: OpenApiResponse(description="Bad request"),
|
||||||
@@ -599,9 +600,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||||
def set_password(self, request: Request, pk: int) -> Response:
|
def set_password(self, request: Request, pk: int) -> Response:
|
||||||
"""Set password for user"""
|
"""Set password for user"""
|
||||||
|
data = UserPasswordSetSerializer(data=request.data)
|
||||||
|
data.is_valid(raise_exception=True)
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
try:
|
try:
|
||||||
user.set_password(request.data.get("password"), request=request)
|
user.set_password(data.validated_data["password"], request=request)
|
||||||
user.save()
|
user.save()
|
||||||
except (ValidationError, IntegrityError) as exc:
|
except (ValidationError, IntegrityError) as exc:
|
||||||
LOGGER.debug("Failed to set password", exc=exc)
|
LOGGER.debug("Failed to set password", exc=exc)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.12 on 2025-09-25 13:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0050_user_last_updated_and_more"),
|
||||||
|
("authentik_rbac", "0006_alter_role_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="group",
|
||||||
|
index=models.Index(fields=["is_superuser"], name="authentik_c_is_supe_1e5a97_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -29,6 +29,7 @@ from authentik.blueprints.models import ManagedModel
|
|||||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||||
from authentik.lib.avatars import get_avatar
|
from authentik.lib.avatars import get_avatar
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.expression.exceptions import ControlFlowException
|
from authentik.lib.expression.exceptions import ControlFlowException
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||||
@@ -200,7 +201,10 @@ class Group(SerializerModel, AttributesMixin):
|
|||||||
"parent",
|
"parent",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
indexes = [models.Index(fields=["name"])]
|
indexes = (
|
||||||
|
models.Index(fields=["name"]),
|
||||||
|
models.Index(fields=["is_superuser"]),
|
||||||
|
)
|
||||||
verbose_name = _("Group")
|
verbose_name = _("Group")
|
||||||
verbose_name_plural = _("Groups")
|
verbose_name_plural = _("Groups")
|
||||||
permissions = [
|
permissions = [
|
||||||
@@ -563,8 +567,10 @@ class Application(SerializerModel, PolicyBindingModel):
|
|||||||
it is returned as-is"""
|
it is returned as-is"""
|
||||||
if not self.meta_icon:
|
if not self.meta_icon:
|
||||||
return None
|
return None
|
||||||
if "://" in self.meta_icon.name or self.meta_icon.name.startswith("/static"):
|
if self.meta_icon.name.startswith("http"):
|
||||||
return self.meta_icon.name
|
return self.meta_icon.name
|
||||||
|
if self.meta_icon.name.startswith("/"):
|
||||||
|
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
|
||||||
return self.meta_icon.url
|
return self.meta_icon.url
|
||||||
|
|
||||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||||
@@ -766,8 +772,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
starts with http it is returned as-is"""
|
starts with http it is returned as-is"""
|
||||||
if not self.icon:
|
if not self.icon:
|
||||||
return None
|
return None
|
||||||
if "://" in self.icon.name or self.icon.name.startswith("/static"):
|
if self.icon.name.startswith("http"):
|
||||||
return self.icon.name
|
return self.icon.name
|
||||||
|
if self.icon.name.startswith("/"):
|
||||||
|
return CONFIG.get("web.path", "/")[:-1] + self.icon.name
|
||||||
return self.icon.url
|
return self.icon.url
|
||||||
|
|
||||||
def get_user_path(self) -> str:
|
def get_user_path(self) -> str:
|
||||||
|
|||||||
@@ -82,6 +82,51 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
|
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
|
||||||
self.assertEqual(self.allowed.meta_icon.read(), b"text")
|
self.assertEqual(self.allowed.meta_icon.read(), b"text")
|
||||||
|
|
||||||
|
def test_set_icon_relative(self):
|
||||||
|
"""Test set_icon (relative path)"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"authentik_api:application-set-icon-url",
|
||||||
|
kwargs={"slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
data={"url": "relative/path"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.allowed.refresh_from_db()
|
||||||
|
self.assertEqual(self.allowed.get_meta_icon, "/media/public/relative/path")
|
||||||
|
|
||||||
|
def test_set_icon_absolute(self):
|
||||||
|
"""Test set_icon (absolute path)"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"authentik_api:application-set-icon-url",
|
||||||
|
kwargs={"slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
data={"url": "/relative/path"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.allowed.refresh_from_db()
|
||||||
|
self.assertEqual(self.allowed.get_meta_icon, "/relative/path")
|
||||||
|
|
||||||
|
def test_set_icon_url(self):
|
||||||
|
"""Test set_icon (url)"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"authentik_api:application-set-icon-url",
|
||||||
|
kwargs={"slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
data={"url": "https://authentik.company/img.png"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.allowed.refresh_from_db()
|
||||||
|
self.assertEqual(self.allowed.get_meta_icon, "https://authentik.company/img.png")
|
||||||
|
|
||||||
def test_check_access(self):
|
def test_check_access(self):
|
||||||
"""Test check_access operation"""
|
"""Test check_access operation"""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -134,6 +179,8 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"provider_obj": {
|
"provider_obj": {
|
||||||
"assigned_application_name": "allowed",
|
"assigned_application_name": "allowed",
|
||||||
"assigned_application_slug": "allowed",
|
"assigned_application_slug": "allowed",
|
||||||
|
"assigned_backchannel_application_name": None,
|
||||||
|
"assigned_backchannel_application_slug": None,
|
||||||
"authentication_flow": None,
|
"authentication_flow": None,
|
||||||
"invalidation_flow": None,
|
"invalidation_flow": None,
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||||
@@ -188,6 +235,8 @@ class TestApplicationsAPI(APITestCase):
|
|||||||
"provider_obj": {
|
"provider_obj": {
|
||||||
"assigned_application_name": "allowed",
|
"assigned_application_name": "allowed",
|
||||||
"assigned_application_slug": "allowed",
|
"assigned_application_slug": "allowed",
|
||||||
|
"assigned_backchannel_application_name": None,
|
||||||
|
"assigned_backchannel_application_slug": None,
|
||||||
"authentication_flow": None,
|
"authentication_flow": None,
|
||||||
"invalidation_flow": None,
|
"invalidation_flow": None,
|
||||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ class TestUsersAPI(APITestCase):
|
|||||||
self.admin.refresh_from_db()
|
self.admin.refresh_from_db()
|
||||||
self.assertTrue(self.admin.check_password(new_pw))
|
self.assertTrue(self.admin.check_password(new_pw))
|
||||||
|
|
||||||
|
def test_set_password_blank(self):
|
||||||
|
"""Test Direct password set"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
|
||||||
|
data={"password": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
|
||||||
|
|
||||||
def test_recovery(self):
|
def test_recovery(self):
|
||||||
"""Test user recovery link"""
|
"""Test user recovery link"""
|
||||||
flow = create_test_flow(
|
flow = create_test_flow(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class GoogleWorkspaceGroupClient(
|
|||||||
"""Google client for groups"""
|
"""Google client for groups"""
|
||||||
|
|
||||||
connection_type = GoogleWorkspaceProviderGroup
|
connection_type = GoogleWorkspaceProviderGroup
|
||||||
connection_attr = "googleworkspaceprovidergroup_set"
|
connection_type_query = "group"
|
||||||
can_discover = True
|
can_discover = True
|
||||||
|
|
||||||
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
|
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
|
|||||||
"""Sync authentik users into google workspace"""
|
"""Sync authentik users into google workspace"""
|
||||||
|
|
||||||
connection_type = GoogleWorkspaceProviderUser
|
connection_type = GoogleWorkspaceProviderUser
|
||||||
connection_attr = "googleworkspaceprovideruser_set"
|
connection_type_query = "user"
|
||||||
can_discover = True
|
can_discover = True
|
||||||
|
|
||||||
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
|
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
|
||||||
|
|||||||
@@ -139,11 +139,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
if type == User:
|
if type == User:
|
||||||
# Get queryset of all users with consistent ordering
|
# Get queryset of all users with consistent ordering
|
||||||
# according to the provider's settings
|
# according to the provider's settings
|
||||||
base = (
|
base = User.objects.all().exclude_anonymous()
|
||||||
User.objects.prefetch_related("googleworkspaceprovideruser_set")
|
|
||||||
.all()
|
|
||||||
.exclude_anonymous()
|
|
||||||
)
|
|
||||||
if self.exclude_users_service_account:
|
if self.exclude_users_service_account:
|
||||||
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
||||||
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||||
@@ -153,11 +149,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
return base.order_by("pk")
|
return base.order_by("pk")
|
||||||
if type == Group:
|
if type == Group:
|
||||||
# Get queryset of all groups with consistent ordering
|
# Get queryset of all groups with consistent ordering
|
||||||
return (
|
return Group.objects.all().order_by("pk")
|
||||||
Group.objects.prefetch_related("googleworkspaceprovidergroup_set")
|
|
||||||
.all()
|
|
||||||
.order_by("pk")
|
|
||||||
)
|
|
||||||
raise ValueError(f"Invalid type {type}")
|
raise ValueError(f"Invalid type {type}")
|
||||||
|
|
||||||
def google_credentials(self):
|
def google_credentials(self):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class MicrosoftEntraGroupClient(
|
|||||||
"""Microsoft client for groups"""
|
"""Microsoft client for groups"""
|
||||||
|
|
||||||
connection_type = MicrosoftEntraProviderGroup
|
connection_type = MicrosoftEntraProviderGroup
|
||||||
connection_attr = "microsoftentraprovidergroup_set"
|
connection_type_query = "group"
|
||||||
can_discover = True
|
can_discover = True
|
||||||
|
|
||||||
def __init__(self, provider: MicrosoftEntraProvider) -> None:
|
def __init__(self, provider: MicrosoftEntraProvider) -> None:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
|
|||||||
"""Sync authentik users into microsoft entra"""
|
"""Sync authentik users into microsoft entra"""
|
||||||
|
|
||||||
connection_type = MicrosoftEntraProviderUser
|
connection_type = MicrosoftEntraProviderUser
|
||||||
connection_attr = "microsoftentraprovideruser_set"
|
connection_type_query = "user"
|
||||||
can_discover = True
|
can_discover = True
|
||||||
|
|
||||||
def __init__(self, provider: MicrosoftEntraProvider) -> None:
|
def __init__(self, provider: MicrosoftEntraProvider) -> None:
|
||||||
|
|||||||
@@ -128,11 +128,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
if type == User:
|
if type == User:
|
||||||
# Get queryset of all users with consistent ordering
|
# Get queryset of all users with consistent ordering
|
||||||
# according to the provider's settings
|
# according to the provider's settings
|
||||||
base = (
|
base = User.objects.all().exclude_anonymous()
|
||||||
User.objects.prefetch_related("microsoftentraprovideruser_set")
|
|
||||||
.all()
|
|
||||||
.exclude_anonymous()
|
|
||||||
)
|
|
||||||
if self.exclude_users_service_account:
|
if self.exclude_users_service_account:
|
||||||
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
||||||
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||||
@@ -142,11 +138,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
return base.order_by("pk")
|
return base.order_by("pk")
|
||||||
if type == Group:
|
if type == Group:
|
||||||
# Get queryset of all groups with consistent ordering
|
# Get queryset of all groups with consistent ordering
|
||||||
return (
|
return Group.objects.all().order_by("pk")
|
||||||
Group.objects.prefetch_related("microsoftentraprovidergroup_set")
|
|
||||||
.all()
|
|
||||||
.order_by("pk")
|
|
||||||
)
|
|
||||||
raise ValueError(f"Invalid type {type}")
|
raise ValueError(f"Invalid type {type}")
|
||||||
|
|
||||||
def microsoft_credentials(self):
|
def microsoft_credentials(self):
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from authentik.events.models import (
|
|||||||
)
|
)
|
||||||
from authentik.events.utils import get_user
|
from authentik.events.utils import get_user
|
||||||
from authentik.rbac.decorators import permission_required
|
from authentik.rbac.decorators import permission_required
|
||||||
|
from authentik.stages.email.models import get_template_choices
|
||||||
|
|
||||||
|
|
||||||
class NotificationTransportSerializer(ModelSerializer):
|
class NotificationTransportSerializer(ModelSerializer):
|
||||||
@@ -30,6 +31,18 @@ class NotificationTransportSerializer(ModelSerializer):
|
|||||||
|
|
||||||
mode_verbose = SerializerMethodField()
|
mode_verbose = SerializerMethodField()
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["email_template"].choices = get_template_choices()
|
||||||
|
|
||||||
|
def validate_email_template(self, value: str) -> str:
|
||||||
|
"""Check validity of email template"""
|
||||||
|
choices = get_template_choices()
|
||||||
|
for path, _ in choices:
|
||||||
|
if path == value:
|
||||||
|
return value
|
||||||
|
raise ValidationError(f"Invalid template '{value}' specified.")
|
||||||
|
|
||||||
def get_mode_verbose(self, instance: NotificationTransport) -> str:
|
def get_mode_verbose(self, instance: NotificationTransport) -> str:
|
||||||
"""Return selected mode with a UI Label"""
|
"""Return selected mode with a UI Label"""
|
||||||
return TransportMode(instance.mode).label
|
return TransportMode(instance.mode).label
|
||||||
@@ -52,6 +65,8 @@ class NotificationTransportSerializer(ModelSerializer):
|
|||||||
"webhook_url",
|
"webhook_url",
|
||||||
"webhook_mapping_body",
|
"webhook_mapping_body",
|
||||||
"webhook_mapping_headers",
|
"webhook_mapping_headers",
|
||||||
|
"email_subject_prefix",
|
||||||
|
"email_template",
|
||||||
"send_once",
|
"send_once",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-08-14 13:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_events", "0011_alter_systemtask_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="notificationtransport",
|
||||||
|
name="email_subject_prefix",
|
||||||
|
field=models.TextField(blank=True, default="authentik Notification: "),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="notificationtransport",
|
||||||
|
name="email_template",
|
||||||
|
field=models.TextField(default="email/event_notification.html"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -41,6 +41,7 @@ from authentik.lib.utils.http import get_http_session
|
|||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.policies.models import PolicyBindingModel
|
from authentik.policies.models import PolicyBindingModel
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
|
from authentik.stages.email.models import EmailTemplates
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
from authentik.tasks.models import TasksModel
|
from authentik.tasks.models import TasksModel
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@@ -295,6 +296,15 @@ class NotificationTransport(TasksModel, SerializerModel):
|
|||||||
|
|
||||||
name = models.TextField(unique=True)
|
name = models.TextField(unique=True)
|
||||||
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
||||||
|
send_once = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
email_subject_prefix = models.TextField(default="authentik Notification: ", blank=True)
|
||||||
|
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
|
||||||
|
|
||||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||||
webhook_mapping_body = models.ForeignKey(
|
webhook_mapping_body = models.ForeignKey(
|
||||||
@@ -319,12 +329,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
|||||||
"Mapping should return a dictionary of key-value pairs"
|
"Mapping should return a dictionary of key-value pairs"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
send_once = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def send(self, notification: "Notification") -> list[str]:
|
def send(self, notification: "Notification") -> list[str]:
|
||||||
"""Send notification to user, called from async task"""
|
"""Send notification to user, called from async task"""
|
||||||
@@ -462,7 +466,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
|||||||
notification=notification,
|
notification=notification,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
subject_prefix = "authentik Notification: "
|
|
||||||
context = {
|
context = {
|
||||||
"key_value": {
|
"key_value": {
|
||||||
"user_email": notification.user.email,
|
"user_email": notification.user.email,
|
||||||
@@ -490,10 +493,10 @@ class NotificationTransport(TasksModel, SerializerModel):
|
|||||||
"from": self.name,
|
"from": self.name,
|
||||||
}
|
}
|
||||||
mail = TemplateEmailMessage(
|
mail = TemplateEmailMessage(
|
||||||
subject=subject_prefix + context["title"],
|
subject=self.email_subject_prefix + context["title"],
|
||||||
to=[(notification.user.name, notification.user.email)],
|
to=[(notification.user.name, notification.user.email)],
|
||||||
language=notification.user.locale(),
|
language=notification.user.locale(),
|
||||||
template_name="email/event_notification.html",
|
template_name=self.email_template,
|
||||||
template_context=context,
|
template_context=context,
|
||||||
)
|
)
|
||||||
send_mail.send_with_options(args=(mail.__dict__,), rel_obj=self)
|
send_mail.send_with_options(args=(mail.__dict__,), rel_obj=self)
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from unittest.mock import PropertyMock, patch
|
|||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.mail.backends.locmem import EmailBackend
|
from django.core.mail.backends.locmem import EmailBackend
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik import authentik_full_version
|
from authentik import authentik_full_version
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
from authentik.events.api.notification_transports import NotificationTransportSerializer
|
||||||
from authentik.events.models import (
|
from authentik.events.models import (
|
||||||
Event,
|
Event,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -18,6 +20,7 @@ from authentik.events.models import (
|
|||||||
TransportMode,
|
TransportMode,
|
||||||
)
|
)
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.stages.email.models import get_template_choices
|
||||||
|
|
||||||
|
|
||||||
class TestEventTransports(TestCase):
|
class TestEventTransports(TestCase):
|
||||||
@@ -138,3 +141,76 @@ class TestEventTransports(TestCase):
|
|||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
|
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
|
||||||
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||||
|
|
||||||
|
def test_transport_email_custom_template(self):
|
||||||
|
"""Test email transport with custom template"""
|
||||||
|
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
mode=TransportMode.EMAIL,
|
||||||
|
email_template="email/event_notification.html",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
transport.send(self.notification)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||||
|
|
||||||
|
def test_transport_email_custom_subject_prefix(self):
|
||||||
|
"""Test email transport with custom subject prefix"""
|
||||||
|
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
mode=TransportMode.EMAIL,
|
||||||
|
email_subject_prefix="[CUSTOM] ",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
transport.send(self.notification)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "[CUSTOM] custom_foo")
|
||||||
|
|
||||||
|
def test_transport_email_validation(self):
|
||||||
|
"""Test email transport template validation"""
|
||||||
|
|
||||||
|
# Test valid template
|
||||||
|
serializer = NotificationTransportSerializer(
|
||||||
|
data={
|
||||||
|
"name": generate_id(),
|
||||||
|
"mode": TransportMode.EMAIL,
|
||||||
|
"email_template": "email/event_notification.html",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid())
|
||||||
|
|
||||||
|
# Test invalid template - should fail due to choices validation
|
||||||
|
serializer = NotificationTransportSerializer(
|
||||||
|
data={
|
||||||
|
"name": generate_id(),
|
||||||
|
"mode": TransportMode.EMAIL,
|
||||||
|
"email_template": "invalid/template.html",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertFalse(serializer.is_valid())
|
||||||
|
self.assertIn("email_template", serializer.errors)
|
||||||
|
|
||||||
|
def test_templates_api_endpoint(self):
|
||||||
|
"""Test templates API endpoint returns valid templates"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("authentik_api:emailstage-templates"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
self.assertIsInstance(data, list)
|
||||||
|
|
||||||
|
# Check that we have at least the default templates
|
||||||
|
template_names = [item["name"] for item in data]
|
||||||
|
self.assertIn("email/event_notification.html", template_names)
|
||||||
|
|
||||||
|
# Verify all templates are valid choices
|
||||||
|
valid_choices = dict(get_template_choices())
|
||||||
|
for template in data:
|
||||||
|
self.assertIn(template["name"], valid_choices)
|
||||||
|
self.assertEqual(template["description"], valid_choices[template["name"]])
|
||||||
|
|||||||
@@ -46,5 +46,5 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = FlowStageBindingSerializer
|
serializer_class = FlowStageBindingSerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
search_fields = ["stage__name"]
|
search_fields = ["stage__name"]
|
||||||
ordering = ["order"]
|
ordering = ["order", "pk"]
|
||||||
ordering_fields = ["order", "stage__name"]
|
ordering_fields = ["order", "stage__name", "target__uuid", "pk"]
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class Flow(SerializerModel, PolicyBindingModel):
|
|||||||
)
|
)
|
||||||
if self.background.name.startswith("http"):
|
if self.background.name.startswith("http"):
|
||||||
return self.background.name
|
return self.background.name
|
||||||
if self.background.name.startswith("/static"):
|
if self.background.name.startswith("/"):
|
||||||
return CONFIG.get("web.path", "/")[:-1] + self.background.name
|
return CONFIG.get("web.path", "/")[:-1] + self.background.name
|
||||||
return self.background.url
|
return self.background.url
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def start_debug_server(**kwargs) -> bool:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
listen: str = CONFIG.get("listen.listen_debug_py", "127.0.0.1:9901")
|
listen: str = CONFIG.get("listen.debug_py", "127.0.0.1:9901")
|
||||||
host, _, port = listen.rpartition(":")
|
host, _, port = listen.rpartition(":")
|
||||||
try:
|
try:
|
||||||
debugpy.listen((host, int(port)), **kwargs) # nosec
|
debugpy.listen((host, int(port)), **kwargs) # nosec
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ postgresql:
|
|||||||
# host: replica1.example.com
|
# host: replica1.example.com
|
||||||
|
|
||||||
listen:
|
listen:
|
||||||
listen_http: 0.0.0.0:9000
|
http: 0.0.0.0:9000
|
||||||
listen_https: 0.0.0.0:9443
|
https: 0.0.0.0:9443
|
||||||
listen_ldap: 0.0.0.0:3389
|
ldap: 0.0.0.0:3389
|
||||||
listen_ldaps: 0.0.0.0:6636
|
ldaps: 0.0.0.0:6636
|
||||||
listen_radius: 0.0.0.0:1812
|
radius: 0.0.0.0:1812
|
||||||
listen_metrics: 0.0.0.0:9300
|
metrics: 0.0.0.0:9300
|
||||||
listen_debug: 0.0.0.0:9900
|
debug: 0.0.0.0:9900
|
||||||
listen_debug_py: 0.0.0.0:9901
|
debug_py: 0.0.0.0:9901
|
||||||
trusted_proxy_cidrs:
|
trusted_proxy_cidrs:
|
||||||
- 127.0.0.0/8
|
- 127.0.0.0/8
|
||||||
- 10.0.0.0/8
|
- 10.0.0.0/8
|
||||||
@@ -152,8 +152,9 @@ worker:
|
|||||||
processes: 1
|
processes: 1
|
||||||
threads: 2
|
threads: 2
|
||||||
consumer_listen_timeout: "seconds=30"
|
consumer_listen_timeout: "seconds=30"
|
||||||
task_max_retries: 20
|
task_max_retries: 5
|
||||||
task_default_time_limit: "minutes=10"
|
task_default_time_limit: "minutes=10"
|
||||||
|
lock_purge_interval: "minutes=1"
|
||||||
task_purge_interval: "days=1"
|
task_purge_interval: "days=1"
|
||||||
task_expiration: "days=30"
|
task_expiration: "days=30"
|
||||||
scheduler_interval: "seconds=60"
|
scheduler_interval: "seconds=60"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ ARG_SANITIZE = re.compile(r"[:.-]")
|
|||||||
|
|
||||||
|
|
||||||
def sanitize_arg(arg_name: str) -> str:
|
def sanitize_arg(arg_name: str) -> str:
|
||||||
return re.sub(ARG_SANITIZE, "_", arg_name)
|
return re.sub(ARG_SANITIZE, "_", slugify(arg_name))
|
||||||
|
|
||||||
|
|
||||||
class BaseEvaluator:
|
class BaseEvaluator:
|
||||||
@@ -218,7 +218,9 @@ class BaseEvaluator:
|
|||||||
|
|
||||||
def wrap_expression(self, expression: str) -> str:
|
def wrap_expression(self, expression: str) -> str:
|
||||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||||
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
|
handler_signature = ",".join(
|
||||||
|
[x for x in [sanitize_arg(x) for x in self._context.keys()] if x]
|
||||||
|
)
|
||||||
full_expression = ""
|
full_expression = ""
|
||||||
full_expression += f"def handler({handler_signature}):\n"
|
full_expression += f"def handler({handler_signature}):\n"
|
||||||
full_expression += indent(expression, " ")
|
full_expression += indent(expression, " ")
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ def structlog_configure():
|
|||||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
structlog.processors.TimeStamper(fmt="iso", utc=False),
|
structlog.processors.TimeStamper(fmt="iso", utc=False),
|
||||||
structlog.processors.StackInfoRenderer(),
|
structlog.processors.StackInfoRenderer(),
|
||||||
structlog.processors.dict_tracebacks,
|
structlog.processors.ExceptionRenderer(
|
||||||
|
structlog.processors.ExceptionDictTransformer(show_locals=CONFIG.get_bool("debug"))
|
||||||
|
),
|
||||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
],
|
],
|
||||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
@@ -65,7 +67,14 @@ def get_logger_config():
|
|||||||
"json": {
|
"json": {
|
||||||
"()": structlog.stdlib.ProcessorFormatter,
|
"()": structlog.stdlib.ProcessorFormatter,
|
||||||
"processor": structlog.processors.JSONRenderer(sort_keys=True),
|
"processor": structlog.processors.JSONRenderer(sort_keys=True),
|
||||||
"foreign_pre_chain": LOG_PRE_CHAIN + [structlog.processors.dict_tracebacks],
|
"foreign_pre_chain": LOG_PRE_CHAIN
|
||||||
|
+ [
|
||||||
|
structlog.processors.ExceptionRenderer(
|
||||||
|
structlog.processors.ExceptionDictTransformer(
|
||||||
|
show_locals=CONFIG.get_bool("debug")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"()": structlog.stdlib.ProcessorFormatter,
|
"()": structlog.stdlib.ProcessorFormatter,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sentry_sdk import init as sentry_sdk_init
|
|||||||
from sentry_sdk.api import set_tag
|
from sentry_sdk.api import set_tag
|
||||||
from sentry_sdk.integrations.argv import ArgvIntegration
|
from sentry_sdk.integrations.argv import ArgvIntegration
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
from sentry_sdk.integrations.dramatiq import DramatiqIntegration
|
||||||
from sentry_sdk.integrations.redis import RedisIntegration
|
from sentry_sdk.integrations.redis import RedisIntegration
|
||||||
from sentry_sdk.integrations.socket import SocketIntegration
|
from sentry_sdk.integrations.socket import SocketIntegration
|
||||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||||
@@ -109,11 +110,12 @@ def sentry_init(**sentry_init_kwargs):
|
|||||||
dsn=CONFIG.get("error_reporting.sentry_dsn"),
|
dsn=CONFIG.get("error_reporting.sentry_dsn"),
|
||||||
integrations=[
|
integrations=[
|
||||||
ArgvIntegration(),
|
ArgvIntegration(),
|
||||||
StdlibIntegration(),
|
|
||||||
DjangoIntegration(transaction_style="function_name", cache_spans=True),
|
DjangoIntegration(transaction_style="function_name", cache_spans=True),
|
||||||
|
DramatiqIntegration(),
|
||||||
RedisIntegration(),
|
RedisIntegration(),
|
||||||
ThreadingIntegration(propagate_hub=True),
|
|
||||||
SocketIntegration(),
|
SocketIntegration(),
|
||||||
|
StdlibIntegration(),
|
||||||
|
ThreadingIntegration(propagate_hub=True),
|
||||||
],
|
],
|
||||||
before_send=before_send,
|
before_send=before_send,
|
||||||
traces_sampler=traces_sampler,
|
traces_sampler=traces_sampler,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from dramatiq.actor import Actor
|
from dramatiq.actor import Actor
|
||||||
|
from dramatiq.results.errors import ResultFailure
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import BooleanField, CharField, ChoiceField
|
from rest_framework.fields import BooleanField, CharField, ChoiceField
|
||||||
@@ -110,9 +111,13 @@ class OutgoingSyncProviderStatusMixin:
|
|||||||
"override_dry_run": params.validated_data["override_dry_run"],
|
"override_dry_run": params.validated_data["override_dry_run"],
|
||||||
"pk": params.validated_data["sync_object_id"],
|
"pk": params.validated_data["sync_object_id"],
|
||||||
},
|
},
|
||||||
|
retries=0,
|
||||||
rel_obj=provider,
|
rel_obj=provider,
|
||||||
)
|
)
|
||||||
msg.get_result(block=True)
|
try:
|
||||||
|
msg.get_result(block=True)
|
||||||
|
except ResultFailure:
|
||||||
|
pass
|
||||||
task: Task = msg.options["task"]
|
task: Task = msg.options["task"]
|
||||||
task.refresh_from_db()
|
task.refresh_from_db()
|
||||||
return Response(SyncObjectResultSerializer(instance={"messages": task._messages}).data)
|
return Response(SyncObjectResultSerializer(instance={"messages": task._messages}).data)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class Direction(StrEnum):
|
class Direction(StrEnum):
|
||||||
|
|
||||||
add = "add"
|
add = "add"
|
||||||
remove = "remove"
|
remove = "remove"
|
||||||
|
|
||||||
@@ -35,16 +36,13 @@ SAFE_METHODS = [
|
|||||||
|
|
||||||
|
|
||||||
class BaseOutgoingSyncClient[
|
class BaseOutgoingSyncClient[
|
||||||
TModel: "Model",
|
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
|
||||||
TConnection: "Model",
|
|
||||||
TSchema: dict,
|
|
||||||
TProvider: "OutgoingSyncProvider",
|
|
||||||
]:
|
]:
|
||||||
"""Basic Outgoing sync client Client"""
|
"""Basic Outgoing sync client Client"""
|
||||||
|
|
||||||
provider: TProvider
|
provider: TProvider
|
||||||
connection_type: type[TConnection]
|
connection_type: type[TConnection]
|
||||||
connection_attr: str
|
connection_type_query: str
|
||||||
mapper: PropertyMappingManager
|
mapper: PropertyMappingManager
|
||||||
|
|
||||||
can_discover = False
|
can_discover = False
|
||||||
@@ -64,7 +62,9 @@ class BaseOutgoingSyncClient[
|
|||||||
def write(self, obj: TModel) -> tuple[TConnection, bool]:
|
def write(self, obj: TModel) -> tuple[TConnection, bool]:
|
||||||
"""Write object to destination. Uses self.create and self.update, but
|
"""Write object to destination. Uses self.create and self.update, but
|
||||||
can be overwritten for further logic"""
|
can be overwritten for further logic"""
|
||||||
connection = getattr(obj, self.connection_attr).filter(provider=self.provider).first()
|
connection = self.connection_type.objects.filter(
|
||||||
|
provider=self.provider, **{self.connection_type_query: obj}
|
||||||
|
).first()
|
||||||
try:
|
try:
|
||||||
if not connection:
|
if not connection:
|
||||||
connection = self.create(obj)
|
connection = self.create(obj)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from authentik.lib.sync.outgoing.exceptions import (
|
|||||||
TransientSyncException,
|
TransientSyncException,
|
||||||
)
|
)
|
||||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||||
|
from authentik.lib.utils.errors import exception_to_dict
|
||||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||||
from authentik.tasks.models import Task
|
from authentik.tasks.models import Task
|
||||||
|
|
||||||
@@ -164,16 +165,17 @@ class SyncTasks:
|
|||||||
except BadRequestSyncException as exc:
|
except BadRequestSyncException as exc:
|
||||||
self.logger.warning("failed to sync object", exc=exc, obj=obj)
|
self.logger.warning("failed to sync object", exc=exc, obj=obj)
|
||||||
task.warning(
|
task.warning(
|
||||||
f"Failed to sync {obj._meta.verbose_name} {str(obj)} due to error: {str(exc)}",
|
f"Failed to sync {str(obj)} due to error: {str(exc)}",
|
||||||
arguments=exc.args[1:],
|
arguments=exc.args[1:],
|
||||||
obj=sanitize_item(obj),
|
obj=sanitize_item(obj),
|
||||||
|
exception=exception_to_dict(exc),
|
||||||
)
|
)
|
||||||
except TransientSyncException as exc:
|
except TransientSyncException as exc:
|
||||||
self.logger.warning("failed to sync object", exc=exc, user=obj)
|
self.logger.warning("failed to sync object", exc=exc, user=obj)
|
||||||
task.warning(
|
task.warning(
|
||||||
f"Failed to sync {obj._meta.verbose_name} {str(obj)} due to "
|
f"Failed to sync {str(obj)} due to " f"transient error: {str(exc)}",
|
||||||
"transient error: {str(exc)}",
|
|
||||||
obj=sanitize_item(obj),
|
obj=sanitize_item(obj),
|
||||||
|
exception=exception_to_dict(exc),
|
||||||
)
|
)
|
||||||
except StopSync as exc:
|
except StopSync as exc:
|
||||||
self.logger.warning("Stopping sync", exc=exc)
|
self.logger.warning("Stopping sync", exc=exc)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Test Evaluator base functions"""
|
"""Test Evaluator base functions"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from jwt import decode
|
from jwt import decode
|
||||||
@@ -77,3 +79,18 @@ class TestEvaluator(TestCase):
|
|||||||
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
||||||
)
|
)
|
||||||
self.assertEqual(decoded["preferred_username"], user.username)
|
self.assertEqual(decoded["preferred_username"], user.username)
|
||||||
|
|
||||||
|
def test_expr_arg_escape(self):
|
||||||
|
"""Test escaping of arguments"""
|
||||||
|
eval = BaseEvaluator()
|
||||||
|
eval._context = {
|
||||||
|
'z=getattr(getattr(__import__("os"), "popen")("id > /tmp/test"), "read")()': "bar",
|
||||||
|
"@@": "baz",
|
||||||
|
"{{": "baz",
|
||||||
|
"aa@@": "baz",
|
||||||
|
}
|
||||||
|
res = eval.evaluate("return locals()")
|
||||||
|
self.assertEqual(
|
||||||
|
res, {"zgetattrgetattr__import__os_popenid_tmptest_read": "bar", "aa": "baz"}
|
||||||
|
)
|
||||||
|
self.assertFalse(Path("/tmp/test").exists()) # nosec
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from traceback import extract_tb
|
|||||||
|
|
||||||
from structlog.tracebacks import ExceptionDictTransformer
|
from structlog.tracebacks import ExceptionDictTransformer
|
||||||
|
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.utils.reflection import class_to_path
|
from authentik.lib.utils.reflection import class_to_path
|
||||||
|
|
||||||
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
||||||
|
_exception_transformer = ExceptionDictTransformer(show_locals=CONFIG.get_bool("debug"))
|
||||||
|
|
||||||
|
|
||||||
def exception_to_string(exc: Exception) -> str:
|
def exception_to_string(exc: Exception) -> str:
|
||||||
@@ -23,4 +25,4 @@ def exception_to_string(exc: Exception) -> str:
|
|||||||
|
|
||||||
def exception_to_dict(exc: Exception) -> dict:
|
def exception_to_dict(exc: Exception) -> dict:
|
||||||
"""Format exception as a dictionary"""
|
"""Format exception as a dictionary"""
|
||||||
return ExceptionDictTransformer()((type(exc), exc, exc.__traceback__))
|
return _exception_transformer((type(exc), exc, exc.__traceback__))
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class OutpostSerializer(ModelSerializer):
|
|||||||
)
|
)
|
||||||
providers_obj = ProviderSerializer(source="providers", many=True, read_only=True)
|
providers_obj = ProviderSerializer(source="providers", many=True, read_only=True)
|
||||||
service_connection_obj = ServiceConnectionSerializer(
|
service_connection_obj = ServiceConnectionSerializer(
|
||||||
source="service_connection", read_only=True
|
source="service_connection",
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True,
|
||||||
)
|
)
|
||||||
refresh_interval_s = SerializerMethodField()
|
refresh_interval_s = SerializerMethodField()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from urllib3.exceptions import HTTPError
|
|||||||
from yaml import dump_all
|
from yaml import dump_all
|
||||||
|
|
||||||
from authentik.events.logs import LogEvent, capture_logs
|
from authentik.events.logs import LogEvent, capture_logs
|
||||||
|
from authentik.lib.utils.reflection import class_to_path
|
||||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
||||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||||
@@ -105,7 +106,7 @@ class KubernetesController(BaseController):
|
|||||||
LogEvent(
|
LogEvent(
|
||||||
log_level="info",
|
log_level="info",
|
||||||
event=f"{reconcile_key.title()}: Disabled",
|
event=f"{reconcile_key.title()}: Disabled",
|
||||||
logger=str(type(self)),
|
logger=class_to_path(self.__class__),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -144,7 +145,7 @@ class KubernetesController(BaseController):
|
|||||||
LogEvent(
|
LogEvent(
|
||||||
log_level="info",
|
log_level="info",
|
||||||
event=f"{reconcile_key.title()}: Disabled",
|
event=f"{reconcile_key.title()}: Disabled",
|
||||||
logger=str(type(self)),
|
logger=class_to_path(self.__class__),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class OutpostConfig:
|
|||||||
kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict)
|
kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict)
|
||||||
kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls")
|
kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls")
|
||||||
kubernetes_ingress_class_name: str | None = field(default=None)
|
kubernetes_ingress_class_name: str | None = field(default=None)
|
||||||
|
kubernetes_ingress_path_type: str | None = field(default=None)
|
||||||
kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict)
|
kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict)
|
||||||
kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list)
|
kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list)
|
||||||
kubernetes_service_type: str = field(default="ClusterIP")
|
kubernetes_service_type: str = field(default="ClusterIP")
|
||||||
@@ -151,7 +152,7 @@ class OutpostServiceConnection(ScheduledModel, models.Model):
|
|||||||
|
|
||||||
state = cache.get(self.state_key, None)
|
state = cache.get(self.state_key, None)
|
||||||
if not state:
|
if not state:
|
||||||
outpost_service_connection_monitor.send_with_options(args=(self.pk), rel_obj=self)
|
outpost_service_connection_monitor.send_with_options(args=(self.pk,), rel_obj=self)
|
||||||
return OutpostServiceConnectionState("", False)
|
return OutpostServiceConnectionState("", False)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|||||||
@@ -124,4 +124,5 @@ class PolicyBindingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = PolicyBindingSerializer
|
serializer_class = PolicyBindingSerializer
|
||||||
search_fields = ["policy__name"]
|
search_fields = ["policy__name"]
|
||||||
filterset_class = PolicyBindingFilter
|
filterset_class = PolicyBindingFilter
|
||||||
ordering = ["target", "order"]
|
ordering = ["order", "pk"]
|
||||||
|
ordering_fields = ["order", "target__uuid", "pk"]
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ HIST_POLICIES_EXECUTION_TIME = Histogram(
|
|||||||
"binding_order",
|
"binding_order",
|
||||||
"binding_target_type",
|
"binding_target_type",
|
||||||
"binding_target_name",
|
"binding_target_name",
|
||||||
"object_pk",
|
|
||||||
"object_type",
|
"object_type",
|
||||||
"mode",
|
"mode",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ class PolicyEngine:
|
|||||||
binding_order=binding.order,
|
binding_order=binding.order,
|
||||||
binding_target_type=binding.target_type,
|
binding_target_type=binding.target_type,
|
||||||
binding_target_name=binding.target_name,
|
binding_target_name=binding.target_name,
|
||||||
object_pk=str(self.request.obj.pk),
|
|
||||||
object_type=class_to_path(self.request.obj.__class__),
|
object_type=class_to_path(self.request.obj.__class__),
|
||||||
mode="cache_retrieve",
|
mode="cache_retrieve",
|
||||||
).time():
|
).time():
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class PasswordPolicy(Policy):
|
|||||||
if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase:
|
if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase:
|
||||||
LOGGER.debug("password failed", check="static", reason="amount_lowercase")
|
LOGGER.debug("password failed", check="static", reason="amount_lowercase")
|
||||||
return PolicyResult(False, self.error_message)
|
return PolicyResult(False, self.error_message)
|
||||||
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase:
|
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_uppercase:
|
||||||
LOGGER.debug("password failed", check="static", reason="amount_uppercase")
|
LOGGER.debug("password failed", check="static", reason="amount_uppercase")
|
||||||
return PolicyResult(False, self.error_message)
|
return PolicyResult(False, self.error_message)
|
||||||
if self.amount_symbols > 0:
|
if self.amount_symbols > 0:
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ class PolicyProcess(PROCESS_CLASS):
|
|||||||
binding_order=self.binding.order,
|
binding_order=self.binding.order,
|
||||||
binding_target_type=self.binding.target_type,
|
binding_target_type=self.binding.target_type,
|
||||||
binding_target_name=self.binding.target_name,
|
binding_target_name=self.binding.target_name,
|
||||||
object_pk=str(self.request.obj.pk) if self.request.obj else "",
|
|
||||||
object_type=class_to_path(self.request.obj.__class__) if self.request.obj else "",
|
object_type=class_to_path(self.request.obj.__class__) if self.request.obj else "",
|
||||||
mode="execute_process",
|
mode="execute_process",
|
||||||
).time(),
|
).time(),
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ def migrate_sessions(apps, schema_editor, model):
|
|||||||
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
for obj in Model.objects.using(db_alias).all():
|
objs = list(Model.objects.using(db_alias).select_related("old_session").all())
|
||||||
|
for obj in objs:
|
||||||
if not obj.old_session:
|
if not obj.old_session:
|
||||||
continue
|
continue
|
||||||
obj.session = (
|
obj.session = (
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
|||||||
|
|
||||||
backchannel_logout_notification_dispatch.send(
|
backchannel_logout_notification_dispatch.send(
|
||||||
revocations=[
|
revocations=[
|
||||||
(token.provider_id, token.id_token.iss, token.session.user.uid)
|
(
|
||||||
|
token.provider_id,
|
||||||
|
token.id_token.iss,
|
||||||
|
token.id_token.sub,
|
||||||
|
instance.session.session_key,
|
||||||
|
)
|
||||||
for token in access_tokens
|
for token in access_tokens
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
|
|
||||||
@actor(description=_("Send a back-channel logout request to the registered client"))
|
@actor(description=_("Send a back-channel logout request to the registered client"))
|
||||||
def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None) -> bool:
|
def send_backchannel_logout_request(
|
||||||
|
provider_pk: int,
|
||||||
|
iss: str,
|
||||||
|
sub: str | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
|
) -> bool:
|
||||||
"""Send a back-channel logout request to the registered client
|
"""Send a back-channel logout request to the registered client
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider_pk: The OAuth2 provider's primary key
|
provider_pk: The OAuth2 provider's primary key
|
||||||
iss: The issuer URL for the logout token
|
iss: The issuer URL for the logout token
|
||||||
sub: The subject identifier to include in the logout token
|
sub: The subject identifier to include in the logout token
|
||||||
|
session_key: The authentik session key to hash and include in the logout token
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the request was sent successfully, False otherwise
|
bool: True if the request was sent successfully, False otherwise
|
||||||
@@ -33,11 +39,10 @@ def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None)
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Generate the logout token
|
# Generate the logout token
|
||||||
logout_token = create_logout_token(iss, provider, None, sub)
|
logout_token = create_logout_token(provider, iss, sub, session_key)
|
||||||
|
|
||||||
# Get the back-channel logout URI from the provider's dedicated backchannel_logout_uri field
|
# Get the back-channel logout URI from the provider's dedicated backchannel_logout_uri field
|
||||||
# Back-channel logout requires explicit configuration - no fallback to redirect URIs
|
# Back-channel logout requires explicit configuration - no fallback to redirect URIs
|
||||||
|
|
||||||
backchannel_logout_uri = provider.backchannel_logout_uri
|
backchannel_logout_uri = provider.backchannel_logout_uri
|
||||||
if not backchannel_logout_uri:
|
if not backchannel_logout_uri:
|
||||||
self.info("No back-channel logout URI found for provider")
|
self.info("No back-channel logout URI found for provider")
|
||||||
@@ -60,9 +65,9 @@ def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None)
|
|||||||
def backchannel_logout_notification_dispatch(revocations: list, **kwargs):
|
def backchannel_logout_notification_dispatch(revocations: list, **kwargs):
|
||||||
"""Handle backchannel logout notifications dispatched via signal"""
|
"""Handle backchannel logout notifications dispatched via signal"""
|
||||||
for revocation in revocations:
|
for revocation in revocations:
|
||||||
provider_pk, iss, sub = revocation
|
provider_pk, iss, sub, session_key = revocation
|
||||||
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
|
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
|
||||||
send_backchannel_logout_request.send_with_options(
|
send_backchannel_logout_request.send_with_options(
|
||||||
args=(provider_pk, iss, sub),
|
args=(provider_pk, iss, sub, session_key),
|
||||||
rel_obj=provider,
|
rel_obj=provider,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from jwt import decode
|
|||||||
|
|
||||||
from authentik.blueprints.tests import apply_blueprint
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
from authentik.core.models import Application, Group, Token, TokenIntents, UserTypes
|
from authentik.core.models import Application, Group, Token, TokenIntents, UserTypes
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
from authentik.core.tests.utils import (
|
||||||
|
create_test_admin_user,
|
||||||
|
create_test_cert,
|
||||||
|
create_test_flow,
|
||||||
|
create_test_user,
|
||||||
|
)
|
||||||
from authentik.policies.models import PolicyBinding
|
from authentik.policies.models import PolicyBinding
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
@@ -121,6 +126,30 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_deactivate(self):
|
||||||
|
"""test deactivated user"""
|
||||||
|
self.user.is_active = False
|
||||||
|
self.user.save()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": SCOPE_OPENID,
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": TokenError.errors["invalid_grant"],
|
||||||
|
"request_id": response.headers["X-authentik-id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_permission_denied(self):
|
def test_permission_denied(self):
|
||||||
"""test permission denied"""
|
"""test permission denied"""
|
||||||
group = Group.objects.create(name="foo")
|
group = Group.objects.create(name="foo")
|
||||||
@@ -182,6 +211,47 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
|||||||
self.assertEqual(jwt["given_name"], self.user.name)
|
self.assertEqual(jwt["given_name"], self.user.name)
|
||||||
self.assertEqual(jwt["preferred_username"], self.user.username)
|
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||||
|
|
||||||
|
def test_successful_two_tokens(self):
|
||||||
|
"""test successful when two app passwords with the same key exist"""
|
||||||
|
Token.objects.create(
|
||||||
|
identifier="sa-token-two",
|
||||||
|
user=create_test_user(),
|
||||||
|
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||||
|
expiring=False,
|
||||||
|
key=self.token.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
{
|
||||||
|
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||||
|
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||||
|
"client_id": self.provider.client_id,
|
||||||
|
"username": "sa",
|
||||||
|
"password": self.token.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content.decode())
|
||||||
|
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||||
|
_, alg = self.provider.jwt_key
|
||||||
|
jwt = decode(
|
||||||
|
body["access_token"],
|
||||||
|
key=self.provider.signing_key.public_key,
|
||||||
|
algorithms=[alg],
|
||||||
|
audience=self.provider.client_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(jwt["given_name"], self.user.name)
|
||||||
|
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||||
|
jwt = decode(
|
||||||
|
body["id_token"],
|
||||||
|
key=self.provider.signing_key.public_key,
|
||||||
|
algorithms=[alg],
|
||||||
|
audience=self.provider.client_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(jwt["given_name"], self.user.name)
|
||||||
|
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||||
|
|
||||||
def test_successful_password(self):
|
def test_successful_password(self):
|
||||||
"""test successful (password grant)"""
|
"""test successful (password grant)"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from binascii import Error
|
from binascii import Error
|
||||||
from time import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.http.response import HttpResponseRedirect
|
from django.http.response import HttpResponseRedirect
|
||||||
from django.utils.cache import patch_vary_headers
|
from django.utils.cache import patch_vary_headers
|
||||||
|
from django.utils.timezone import now
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.providers.oauth2.errors import BearerTokenError
|
from authentik.providers.oauth2.errors import BearerTokenError
|
||||||
from authentik.providers.oauth2.id_token import hash_session_key
|
from authentik.providers.oauth2.id_token import hash_session_key
|
||||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||||
@@ -217,23 +218,25 @@ class HttpResponseRedirectScheme(HttpResponseRedirect):
|
|||||||
|
|
||||||
|
|
||||||
def create_logout_token(
|
def create_logout_token(
|
||||||
iss: str,
|
|
||||||
provider: OAuth2Provider,
|
provider: OAuth2Provider,
|
||||||
session_key: str | None = None,
|
iss: str,
|
||||||
sub: str | None = None,
|
sub: str | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a logout token for Back-Channel Logout
|
"""Create a logout token for Back-Channel Logout
|
||||||
|
|
||||||
As per https://openid.net/specs/openid-connect-backchannel-1_0.html
|
As per https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOGGER.debug("Creating logout token", provider=provider, session_key=session_key, sub=sub)
|
LOGGER.debug("Creating logout token", provider=provider, sub=sub)
|
||||||
|
|
||||||
|
_now = now()
|
||||||
# Create the logout token payload
|
# Create the logout token payload
|
||||||
payload = {
|
payload = {
|
||||||
"iss": str(iss),
|
"iss": str(iss),
|
||||||
"aud": provider.client_id,
|
"aud": provider.client_id,
|
||||||
"iat": int(time()),
|
"iat": int(_now.timestamp()),
|
||||||
|
"exp": int((_now + timedelta_from_string(provider.access_token_validity)).timestamp()),
|
||||||
"jti": str(uuid.uuid4()),
|
"jti": str(uuid.uuid4()),
|
||||||
"events": {
|
"events": {
|
||||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||||
|
|||||||
@@ -336,11 +336,11 @@ class TokenParams:
|
|||||||
self, request: HttpRequest, username: str, password: str
|
self, request: HttpRequest, username: str, password: str
|
||||||
):
|
):
|
||||||
# Authenticate user based on credentials
|
# Authenticate user based on credentials
|
||||||
user = User.objects.filter(username=username).first()
|
user = User.objects.filter(username=username, is_active=True).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
token: Token = Token.filter_not_expired(
|
token: Token = Token.filter_not_expired(
|
||||||
key=password, intent=TokenIntents.INTENT_APP_PASSWORD
|
key=password, intent=TokenIntents.INTENT_APP_PASSWORD, user=user
|
||||||
).first()
|
).first()
|
||||||
if not token or token.user.uid != user.uid:
|
if not token or token.user.uid != user.uid:
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
and self.controller.outpost.config.kubernetes_ingress_secret_name
|
and self.controller.outpost.config.kubernetes_ingress_secret_name
|
||||||
):
|
):
|
||||||
tls_hosts.append(external_host_name.hostname)
|
tls_hosts.append(external_host_name.hostname)
|
||||||
|
path_type = "Prefix"
|
||||||
|
if self.controller.outpost.config.kubernetes_ingress_path_type:
|
||||||
|
path_type = self.controller.outpost.config.kubernetes_ingress_path_type
|
||||||
if proxy_provider.mode in [
|
if proxy_provider.mode in [
|
||||||
ProxyMode.FORWARD_SINGLE,
|
ProxyMode.FORWARD_SINGLE,
|
||||||
ProxyMode.FORWARD_DOMAIN,
|
ProxyMode.FORWARD_DOMAIN,
|
||||||
@@ -143,7 +146,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
path="/outpost.goauthentik.io",
|
path="/outpost.goauthentik.io",
|
||||||
path_type="Prefix",
|
path_type=path_type,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -161,7 +164,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
path="/",
|
path="/",
|
||||||
path_type="Prefix",
|
path_type=path_type,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def migrate_sessions(apps, schema_editor):
|
|||||||
for token in ConnectionToken.objects.using(db_alias).all():
|
for token in ConnectionToken.objects.using(db_alias).all():
|
||||||
token.session = (
|
token.session = (
|
||||||
AuthenticatedSession.objects.using(db_alias)
|
AuthenticatedSession.objects.using(db_alias)
|
||||||
.filter(session_key=token.old_session.session_key)
|
.filter(session__session_key=token.old_session.session_key)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if token.session:
|
if token.session:
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
|
"assigned_backchannel_application_name": None,
|
||||||
|
"assigned_backchannel_application_slug": None,
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
"verbose_name_plural": "RAC Providers",
|
"verbose_name_plural": "RAC Providers",
|
||||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
"meta_model_name": "authentik_providers_rac.racprovider",
|
||||||
@@ -126,6 +128,8 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
|
"assigned_backchannel_application_name": None,
|
||||||
|
"assigned_backchannel_application_slug": None,
|
||||||
"connection_expiry": "hours=8",
|
"connection_expiry": "hours=8",
|
||||||
"delete_token_on_disconnect": False,
|
"delete_token_on_disconnect": False,
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
@@ -155,6 +159,8 @@ class TestEndpointsAPI(APITestCase):
|
|||||||
"component": "ak-provider-rac-form",
|
"component": "ak-provider-rac-form",
|
||||||
"assigned_application_slug": self.app.slug,
|
"assigned_application_slug": self.app.slug,
|
||||||
"assigned_application_name": self.app.name,
|
"assigned_application_name": self.app.name,
|
||||||
|
"assigned_backchannel_application_name": None,
|
||||||
|
"assigned_backchannel_application_slug": None,
|
||||||
"connection_expiry": "hours=8",
|
"connection_expiry": "hours=8",
|
||||||
"delete_token_on_disconnect": False,
|
"delete_token_on_disconnect": False,
|
||||||
"verbose_name": "RAC Provider",
|
"verbose_name": "RAC Provider",
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ class SCIMRequestException(TransientSyncException):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
pass
|
pass
|
||||||
return self._message
|
return self._message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self._response:
|
||||||
|
return self._response.text
|
||||||
|
return super().__str__()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
|||||||
"""SCIM client for groups"""
|
"""SCIM client for groups"""
|
||||||
|
|
||||||
connection_type = SCIMProviderGroup
|
connection_type = SCIMProviderGroup
|
||||||
connection_attr = "scimprovidergroup_set"
|
connection_type_query = "group"
|
||||||
mapper: PropertyMappingManager
|
mapper: PropertyMappingManager
|
||||||
|
|
||||||
def __init__(self, provider: SCIMProvider):
|
def __init__(self, provider: SCIMProvider):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
"""SCIM client for users"""
|
"""SCIM client for users"""
|
||||||
|
|
||||||
connection_type = SCIMProviderUser
|
connection_type = SCIMProviderUser
|
||||||
connection_attr = "scimprovideruser_set"
|
connection_type_query = "user"
|
||||||
mapper: PropertyMappingManager
|
mapper: PropertyMappingManager
|
||||||
|
|
||||||
def __init__(self, provider: SCIMProvider):
|
def __init__(self, provider: SCIMProvider):
|
||||||
@@ -72,7 +72,8 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
if not self._config.filter.supported:
|
if not self._config.filter.supported:
|
||||||
raise exc
|
raise exc
|
||||||
users = self._request(
|
users = self._request(
|
||||||
"GET", f"/Users?{urlencode({'filter': f'userName eq {scim_user.userName}'})}"
|
"GET",
|
||||||
|
f"/Users?{urlencode({'filter': f'userName eq \"{scim_user.userName}\"'})}",
|
||||||
)
|
)
|
||||||
users_res = users.get("Resources", [])
|
users_res = users.get("Resources", [])
|
||||||
if len(users_res) < 1:
|
if len(users_res) < 1:
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
if type == User:
|
if type == User:
|
||||||
# Get queryset of all users with consistent ordering
|
# Get queryset of all users with consistent ordering
|
||||||
# according to the provider's settings
|
# according to the provider's settings
|
||||||
base = User.objects.prefetch_related("scimprovideruser_set").all().exclude_anonymous()
|
base = User.objects.all().exclude_anonymous()
|
||||||
if self.exclude_users_service_account:
|
if self.exclude_users_service_account:
|
||||||
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
|
||||||
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||||
@@ -133,7 +133,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
return base.order_by("pk")
|
return base.order_by("pk")
|
||||||
if type == Group:
|
if type == Group:
|
||||||
# Get queryset of all groups with consistent ordering
|
# Get queryset of all groups with consistent ordering
|
||||||
return Group.objects.prefetch_related("scimprovidergroup_set").all().order_by("pk")
|
return Group.objects.all().order_by("pk")
|
||||||
raise ValueError(f"Invalid type {type}")
|
raise ValueError(f"Invalid type {type}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""common RBAC serializers"""
|
"""common RBAC serializers"""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.db.models import Q, QuerySet
|
from django.db.models import Q, QuerySet
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django_filters.filters import CharFilter, ChoiceFilter
|
from django_filters.filters import CharFilter, ChoiceFilter
|
||||||
@@ -17,7 +18,7 @@ from rest_framework.viewsets import GenericViewSet
|
|||||||
|
|
||||||
from authentik.core.api.groups import GroupMemberSerializer
|
from authentik.core.api.groups import GroupMemberSerializer
|
||||||
from authentik.core.api.utils import ModelSerializer
|
from authentik.core.api.utils import ModelSerializer
|
||||||
from authentik.core.models import User, UserTypes
|
from authentik.core.models import Group, User, UserTypes
|
||||||
from authentik.policies.event_matcher.models import model_choices
|
from authentik.policies.event_matcher.models import model_choices
|
||||||
from authentik.rbac.api.rbac import PermissionAssignResultSerializer, PermissionAssignSerializer
|
from authentik.rbac.api.rbac import PermissionAssignResultSerializer, PermissionAssignSerializer
|
||||||
from authentik.rbac.decorators import permission_required
|
from authentik.rbac.decorators import permission_required
|
||||||
@@ -54,26 +55,56 @@ class UserAssignedPermissionFilter(FilterSet):
|
|||||||
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
|
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
|
||||||
object_pk = CharFilter(method="filter_object_pk")
|
object_pk = CharFilter(method="filter_object_pk")
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
data = self.form.cleaned_data
|
||||||
|
model: str = data["model"]
|
||||||
|
object_pk: str | None = data.get("object_pk", None)
|
||||||
|
app, _, model = model.partition(".")
|
||||||
|
|
||||||
|
superuser_pks = (
|
||||||
|
Group.objects.filter(is_superuser=True).values_list("users", flat=True).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
permissions = Permission.objects.filter(
|
||||||
|
content_type__app_label=app,
|
||||||
|
content_type__model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_pks_with_model_permission = (
|
||||||
|
permissions.order_by().values_list("user", flat=True).distinct()
|
||||||
|
)
|
||||||
|
user_pks_with_object_permission = []
|
||||||
|
if object_pk:
|
||||||
|
user_pks_with_object_permission = (
|
||||||
|
UserObjectPermission.objects.filter(
|
||||||
|
permission__in=permissions,
|
||||||
|
object_pk=object_pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.values_list("user", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.filter(
|
||||||
|
Q(pk__in=superuser_pks)
|
||||||
|
| Q(pk__in=user_pks_with_model_permission)
|
||||||
|
| Q(pk__in=user_pks_with_object_permission)
|
||||||
|
)
|
||||||
|
|
||||||
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
||||||
"""Filter by object type"""
|
"""Filter by object type"""
|
||||||
app, _, model = value.partition(".")
|
# Actual filtering is handled by the above method where both `model` and `object_pk` are
|
||||||
return queryset.filter(
|
# available. Don't do anything here, this method is only left here to avoid overriding too
|
||||||
Q(
|
# much of filter_queryset.
|
||||||
user_permissions__content_type__app_label=app,
|
return queryset
|
||||||
user_permissions__content_type__model=model,
|
|
||||||
)
|
|
||||||
| Q(
|
|
||||||
userobjectpermission__permission__content_type__app_label=app,
|
|
||||||
userobjectpermission__permission__content_type__model=model,
|
|
||||||
)
|
|
||||||
| Q(ak_groups__is_superuser=True)
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
||||||
"""Filter by object primary key"""
|
"""Filter by object primary key"""
|
||||||
return queryset.filter(
|
# Actual filtering is handled by the above method where both `model` and `object_pk` are
|
||||||
Q(userobjectpermission__object_pk=value) | Q(ak_groups__is_superuser=True),
|
# available. Don't do anything here, this method is only left here to avoid overriding too
|
||||||
).distinct()
|
# much of filter_queryset.
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
|
class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
|
||||||
@@ -83,7 +114,7 @@ class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
|
|||||||
ordering = ["username"]
|
ordering = ["username"]
|
||||||
# The filtering is done in the filterset,
|
# The filtering is done in the filterset,
|
||||||
# which has a required filter that does the heavy lifting
|
# which has a required filter that does the heavy lifting
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all().prefetch_related("userobjectpermission_set")
|
||||||
filterset_class = UserAssignedPermissionFilter
|
filterset_class = UserAssignedPermissionFilter
|
||||||
|
|
||||||
@permission_required("authentik_core.assign_user_permissions")
|
@permission_required("authentik_core.assign_user_permissions")
|
||||||
|
|||||||
24
authentik/rbac/migrations/0006_alter_role_options.py
Normal file
24
authentik/rbac/migrations/0006_alter_role_options.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-08-29 14:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_rbac", "0005_initialpermissions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="role",
|
||||||
|
options={
|
||||||
|
"permissions": [
|
||||||
|
("assign_role_permissions", "Can assign permissions to roles"),
|
||||||
|
("unassign_role_permissions", "Can unassign permissions from roles"),
|
||||||
|
],
|
||||||
|
"verbose_name": "Role",
|
||||||
|
"verbose_name_plural": "Roles",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -71,8 +71,8 @@ class Role(SerializerModel):
|
|||||||
verbose_name = _("Role")
|
verbose_name = _("Role")
|
||||||
verbose_name_plural = _("Roles")
|
verbose_name_plural = _("Roles")
|
||||||
permissions = [
|
permissions = [
|
||||||
("assign_role_permissions", _("Can assign permissions to users")),
|
("assign_role_permissions", _("Can assign permissions to roles")),
|
||||||
("unassign_role_permissions", _("Can unassign permissions from users")),
|
("unassign_role_permissions", _("Can unassign permissions from roles")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import importlib
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from hashlib import sha512
|
from hashlib import sha512
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import gettempdir
|
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
|
from django.http import response as http_response
|
||||||
from sentry_sdk import set_tag
|
from sentry_sdk import set_tag
|
||||||
from xmlsec import enable_debug_trace
|
from xmlsec import enable_debug_trace
|
||||||
|
|
||||||
@@ -368,6 +368,9 @@ DRAMATIQ = {
|
|||||||
"broker_class": "authentik.tasks.broker.Broker",
|
"broker_class": "authentik.tasks.broker.Broker",
|
||||||
"channel_prefix": "authentik",
|
"channel_prefix": "authentik",
|
||||||
"task_model": "authentik.tasks.models.Task",
|
"task_model": "authentik.tasks.models.Task",
|
||||||
|
"lock_purge_interval": timedelta_from_string(
|
||||||
|
CONFIG.get("worker.lock_purge_interval")
|
||||||
|
).total_seconds(),
|
||||||
"task_purge_interval": timedelta_from_string(
|
"task_purge_interval": timedelta_from_string(
|
||||||
CONFIG.get("worker.task_purge_interval")
|
CONFIG.get("worker.task_purge_interval")
|
||||||
).total_seconds(),
|
).total_seconds(),
|
||||||
@@ -410,7 +413,10 @@ DRAMATIQ = {
|
|||||||
("dramatiq.middleware.pipelines.Pipelines", {}),
|
("dramatiq.middleware.pipelines.Pipelines", {}),
|
||||||
(
|
(
|
||||||
"dramatiq.middleware.retries.Retries",
|
"dramatiq.middleware.retries.Retries",
|
||||||
{"max_retries": CONFIG.get_int("worker.task_max_retries") if not TEST else 0},
|
{
|
||||||
|
"max_retries": CONFIG.get_int("worker.task_max_retries") if not TEST else 0,
|
||||||
|
"max_backoff": 60 * 60 * 1000, # 1 hour
|
||||||
|
},
|
||||||
),
|
),
|
||||||
("dramatiq.results.middleware.Results", {"store_results": True}),
|
("dramatiq.results.middleware.Results", {"store_results": True}),
|
||||||
("django_dramatiq_postgres.middleware.CurrentTask", {}),
|
("django_dramatiq_postgres.middleware.CurrentTask", {}),
|
||||||
@@ -424,7 +430,6 @@ DRAMATIQ = {
|
|||||||
(
|
(
|
||||||
"authentik.tasks.middleware.MetricsMiddleware",
|
"authentik.tasks.middleware.MetricsMiddleware",
|
||||||
{
|
{
|
||||||
"multiproc_dir": str(Path(gettempdir()) / "authentik_prometheus_tmp"),
|
|
||||||
"prefix": "authentik",
|
"prefix": "authentik",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -456,6 +461,13 @@ STORAGES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Django 5.2.8 and CVE-2025-64458 added a strong enforcement of 2048 characters
|
||||||
|
# as the maximum for a URL to redirect to, mostly for running on windows.
|
||||||
|
# However our URLs can easily exceed that with OAuth/SAML Query parameters or hash values
|
||||||
|
# 8192 should cover most cases..
|
||||||
|
http_response.MAX_URL_LENGTH = http_response.MAX_URL_LENGTH * 4
|
||||||
|
|
||||||
|
|
||||||
# Media files
|
# Media files
|
||||||
if CONFIG.get("storage.media.backend", "file") == "s3":
|
if CONFIG.get("storage.media.backend", "file") == "s3":
|
||||||
STORAGES["default"] = {
|
STORAGES["default"] = {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from ldap3 import SUBTREE
|
from ldap3 import SUBTREE
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
from authentik.sources.ldap.models import LDAP_DISTINGUISHED_NAME, LDAP_UNIQUENESS, LDAPSource
|
from authentik.sources.ldap.models import LDAP_DISTINGUISHED_NAME, LDAP_UNIQUENESS, LDAPSource
|
||||||
@@ -52,7 +53,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
for group in page_data:
|
for group in page_data:
|
||||||
if self._source.lookup_groups_from_user:
|
if self._source.lookup_groups_from_user:
|
||||||
group_dn = group.get("dn", {})
|
group_dn = group.get("dn", {})
|
||||||
group_filter = f"({self._source.group_membership_field}={group_dn})"
|
escaped_dn = escape_filter_chars(group_dn)
|
||||||
|
group_filter = f"({self._source.group_membership_field}={escaped_dn})"
|
||||||
group_members = self._source.connection().extend.standard.paged_search(
|
group_members = self._source.connection().extend.standard.paged_search(
|
||||||
search_base=self.base_dn_users,
|
search_base=self.base_dn_users,
|
||||||
search_filter=group_filter,
|
search_filter=group_filter,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from ldap3.core.exceptions import LDAPInvalidFilterError
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
|
||||||
from authentik.blueprints.tests import apply_blueprint
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
@@ -519,3 +521,89 @@ class LDAPSyncTests(TestCase):
|
|||||||
|
|
||||||
self.assertFalse(User.objects.filter(username__startswith="not-in-the-source").exists())
|
self.assertFalse(User.objects.filter(username__startswith="not-in-the-source").exists())
|
||||||
self.assertFalse(Group.objects.filter(name__startswith="not-in-the-source").exists())
|
self.assertFalse(Group.objects.filter(name__startswith="not-in-the-source").exists())
|
||||||
|
|
||||||
|
def test_membership_sync_special_chars_in_group_dn(self):
|
||||||
|
"""Test membership synchronization with special characters in group DN"""
|
||||||
|
self.source.object_uniqueness_field = "uid"
|
||||||
|
self.source.group_object_filter = "(objectClass=groupOfNames)"
|
||||||
|
self.source.lookup_groups_from_user = True
|
||||||
|
self.source.group_membership_field = "memberOf"
|
||||||
|
|
||||||
|
# Mock connection with group DN containing special characters
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
|
||||||
|
# Simulate group with special characters in DN: parentheses, backslashes, asterisks
|
||||||
|
special_group_dn = "cn=test(group),ou=groups,dc=example,dc=com"
|
||||||
|
backslash_group_dn = "cn=test\\group,ou=groups,dc=example,dc=com"
|
||||||
|
asterisk_group_dn = "cn=test*group,ou=groups,dc=example,dc=com"
|
||||||
|
|
||||||
|
# Mock the paged_search method that would be called with the filter
|
||||||
|
mock_standard = MagicMock()
|
||||||
|
mock_conn.extend.standard = mock_standard
|
||||||
|
|
||||||
|
# Test case 1: Group DN with parentheses
|
||||||
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||||
|
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||||
|
|
||||||
|
# Simulate group data with special characters in DN
|
||||||
|
page_data = [{"dn": special_group_dn}]
|
||||||
|
|
||||||
|
# This should not raise LDAPInvalidFilterError anymore
|
||||||
|
try:
|
||||||
|
membership_sync.sync(page_data)
|
||||||
|
# Verify that the filter was properly escaped
|
||||||
|
# The call should have been made with escaped characters
|
||||||
|
mock_standard.paged_search.assert_called()
|
||||||
|
call_args = mock_standard.paged_search.call_args
|
||||||
|
search_filter = call_args[1]["search_filter"]
|
||||||
|
# The parentheses should be escaped as \28 and \29
|
||||||
|
self.assertIn("\\28", search_filter) # Escaped (
|
||||||
|
self.assertIn("\\29", search_filter) # Escaped )
|
||||||
|
except LDAPInvalidFilterError:
|
||||||
|
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||||
|
|
||||||
|
# Test case 2: Group DN with backslashes
|
||||||
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||||
|
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||||
|
page_data = [{"dn": backslash_group_dn}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
membership_sync.sync(page_data)
|
||||||
|
call_args = mock_standard.paged_search.call_args
|
||||||
|
search_filter = call_args[1]["search_filter"]
|
||||||
|
# The backslash should be escaped as \5c
|
||||||
|
self.assertIn("\\5c", search_filter) # Escaped \
|
||||||
|
except LDAPInvalidFilterError:
|
||||||
|
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||||
|
|
||||||
|
# Test case 3: Group DN with asterisks
|
||||||
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||||
|
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||||
|
page_data = [{"dn": asterisk_group_dn}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
membership_sync.sync(page_data)
|
||||||
|
call_args = mock_standard.paged_search.call_args
|
||||||
|
search_filter = call_args[1]["search_filter"]
|
||||||
|
# The asterisk should be escaped as \2a
|
||||||
|
self.assertIn("\\2a", search_filter) # Escaped *
|
||||||
|
except LDAPInvalidFilterError:
|
||||||
|
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||||
|
|
||||||
|
def test_escape_filter_chars_function(self):
|
||||||
|
"""Test the escape_filter_chars function directly"""
|
||||||
|
|
||||||
|
# Test various special characters that need escaping
|
||||||
|
test_cases = [
|
||||||
|
("test(group)", "test\\28group\\29"), # parentheses
|
||||||
|
("test\\group", "test\\5cgroup"), # backslash
|
||||||
|
("test*group", "test\\2agroup"), # asterisk
|
||||||
|
("test(*)group", "test\\28\\2a\\29group"), # multiple special chars
|
||||||
|
("normalgroup", "normalgroup"), # no special chars
|
||||||
|
("", ""), # empty string
|
||||||
|
]
|
||||||
|
|
||||||
|
for input_str, expected in test_cases:
|
||||||
|
with self.subTest(input_str=input_str):
|
||||||
|
result = escape_filter_chars(input_str)
|
||||||
|
self.assertEqual(result, expected)
|
||||||
|
|||||||
@@ -96,7 +96,11 @@ class EntraIDType(SourceType):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_base_group_properties(self, source, group_id, **kwargs):
|
def get_base_group_properties(self, source, group_id, **kwargs):
|
||||||
raw_group = kwargs["info"]["raw_groups"][group_id]
|
raw_groups = kwargs["info"]["raw_groups"]
|
||||||
|
if group_id in raw_groups:
|
||||||
|
name = raw_groups[group_id]["displayName"]
|
||||||
|
else:
|
||||||
|
name = group_id
|
||||||
return {
|
return {
|
||||||
"name": raw_group["displayName"],
|
"name": name,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.http import HttpRequest
|
|||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from lxml.etree import _Element # nosec
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
@@ -214,9 +215,8 @@ class SAMLSource(Source):
|
|||||||
def property_mapping_type(self) -> type[PropertyMapping]:
|
def property_mapping_type(self) -> type[PropertyMapping]:
|
||||||
return SAMLSourcePropertyMapping
|
return SAMLSourcePropertyMapping
|
||||||
|
|
||||||
def get_base_user_properties(self, root: Any, name_id: Any, **kwargs):
|
def get_base_user_properties(self, root: _Element, assertion: _Element, name_id: Any, **kwargs):
|
||||||
attributes = {}
|
attributes = {}
|
||||||
assertion = root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
|
||||||
if assertion is None:
|
if assertion is None:
|
||||||
raise ValueError("Assertion element not found")
|
raise ValueError("Assertion element not found")
|
||||||
attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class ResponseProcessor:
|
|||||||
|
|
||||||
_http_request: HttpRequest
|
_http_request: HttpRequest
|
||||||
|
|
||||||
|
_assertion: "Element | None" = None
|
||||||
|
|
||||||
def __init__(self, source: SAMLSource, request: HttpRequest):
|
def __init__(self, source: SAMLSource, request: HttpRequest):
|
||||||
self._source = source
|
self._source = source
|
||||||
self._http_request = request
|
self._http_request = request
|
||||||
@@ -113,6 +115,7 @@ class ResponseProcessor:
|
|||||||
index_of,
|
index_of,
|
||||||
decrypted_assertion,
|
decrypted_assertion,
|
||||||
)
|
)
|
||||||
|
self._assertion = decrypted_assertion
|
||||||
|
|
||||||
def _verify_signed(self):
|
def _verify_signed(self):
|
||||||
"""Verify SAML Response's Signature"""
|
"""Verify SAML Response's Signature"""
|
||||||
@@ -137,6 +140,10 @@ class ResponseProcessor:
|
|||||||
except xmlsec.Error as exc:
|
except xmlsec.Error as exc:
|
||||||
raise InvalidSignature() from exc
|
raise InvalidSignature() from exc
|
||||||
LOGGER.debug("Successfully verified signature")
|
LOGGER.debug("Successfully verified signature")
|
||||||
|
parent = signature_nodes[0].getparent()
|
||||||
|
if parent is None or parent.tag != f"{{{NS_SAML_ASSERTION}}}Assertion":
|
||||||
|
raise InvalidSignature("No Signature exists in the Assertion element.")
|
||||||
|
self._assertion = parent
|
||||||
|
|
||||||
def _verify_request_id(self):
|
def _verify_request_id(self):
|
||||||
if self._source.allow_idp_initiated:
|
if self._source.allow_idp_initiated:
|
||||||
@@ -201,14 +208,21 @@ class ResponseProcessor:
|
|||||||
identifier=str(name_id.text),
|
identifier=str(name_id.text),
|
||||||
user_info={
|
user_info={
|
||||||
"root": self._root,
|
"root": self._root,
|
||||||
|
"assertion": self.get_assertion(),
|
||||||
"name_id": name_id,
|
"name_id": name_id,
|
||||||
},
|
},
|
||||||
policy_context={},
|
policy_context={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_assertion(self) -> "Element | None":
|
||||||
|
"""Get assertion element, if we have a signed assertion"""
|
||||||
|
if self._assertion is not None:
|
||||||
|
return self._assertion
|
||||||
|
return self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||||
|
|
||||||
def _get_name_id(self) -> "Element":
|
def _get_name_id(self) -> "Element":
|
||||||
"""Get NameID Element"""
|
"""Get NameID Element"""
|
||||||
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
assertion = self.get_assertion()
|
||||||
if assertion is None:
|
if assertion is None:
|
||||||
raise ValueError("Assertion element not found")
|
raise ValueError("Assertion element not found")
|
||||||
subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject")
|
subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject")
|
||||||
@@ -261,6 +275,7 @@ class ResponseProcessor:
|
|||||||
identifier=str(name_id.text),
|
identifier=str(name_id.text),
|
||||||
user_info={
|
user_info={
|
||||||
"root": self._root,
|
"root": self._root,
|
||||||
|
"assertion": self.get_assertion(),
|
||||||
"name_id": name_id,
|
"name_id": name_id,
|
||||||
},
|
},
|
||||||
policy_context={
|
policy_context={
|
||||||
|
|||||||
41
authentik/sources/saml/tests/fixtures/response_signed_assertion.xml
vendored
Normal file
41
authentik/sources/saml/tests/fixtures/response_signed_assertion.xml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_8e8dc5f69a98cc4c1ff3427e5ce34606fd672f91e6" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685">
|
||||||
|
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer>
|
||||||
|
<samlp:Status>
|
||||||
|
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||||
|
</samlp:Status>
|
||||||
|
<saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
|
||||||
|
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<ds:Reference URI="#pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>zNDuGxwP4gVkv/Dzt7kiKo/4gzk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>GLP/vE8uxerB0uDpPslUgLPBL6ePQB619MoQ0I2Y5lAtFE6CB1zh8BnzChRx/bFjNy4byfOe8mFfM0r7WUi1PJOFWyUPoatdLl7wHHBIRTnPpYmu3Tb2Gz0sOP0F8wW7JkBft5gJfVw49nk5si9/3Q3o52jnJZ7dPtqfIOh8uNeopikK0HLF6sU05qCCtjcXfniEnLQFNBFMo9uY5GQqmR5n3nqPz1wYyyfFOAbVmGgBIoO2PfGX2GVLQhltc9qf2JMhks4jgZsZ8iLUIiH1lcLGWZEEs94k8k0P6gSv1uZ7Vbhksd/N9Jq9pCVuEJ/jRPcAdVjzbxqKQAj6ELwr8O6fepTzA+CAdwEolBnx/C6TmSbVZ+IWk6QUGe4x4+IAukC+0hkKENlO0ELOScksvyhpgHbxNA4rp+DhGupCaO/I2RrsQkmvavbqm+wSEspK7scK112SDunjDvqPHsPYgukD33T/97PxTLorg2kKP9HHJwPJKoXXeyOGcA6vwK+RqrAlZ2dLGAgcXo+sJcdCLuvxDNz9VXofBjBZIKVKdmYhm0QJaPYHtuQsAyFavQhdOBOmGHb7QX3YE3Xy4dX4LymtT+Jlb1I4FJSht/9HUIHW1FdhfDak4f7gUgjuMamMddLD0jVgeESupSREzFv/gj2IrctkbgjAO0iuuiBgKMg=</ds:SignatureValue>
|
||||||
|
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFUzCCAzugAwIBAgIRAL6tbNcE9Ej9gNlbGKswfFMwDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjUuNi4zMB4XDTI1MDcxNTE4MDQzNloXDTI2MDcxNjE4MDQzNlowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjmut/+bBRLlyrbf+WIfg8ZTw9t6VnsiU1n04nPTulpRAz4nBOoOHNRIruSpZyFeFa6x9jwn4Ma5EFUH7HqnRvhoujm8U17OglXWZt0DLCZ6S5xPmdMogFXjJDmg9okIcI/cb9VbR6I8uvm1oiaOWCr36RTiqZ6rmdjQcuUPLr1+V/LxWQI463S+5QA2HZxAGalp45MJAz2sa9iczktKMgyYlfjj1cruFARxxeheu5qIK7aQWfyPj1QlMb9mi4VQaxUwGrAui4Tq614ivRJY2SkZb0Aq/LLSQoQWYHtYyQIasrOXJm0JuPDqhINPBDowyhu8DihC3uzOpmTXLKc5UoIQk+Q1h5iH74A3/kxOJUw13FXzRiDxC/yGthPYLyFHsDiJolscMKSCqlDvEMcpM4mxFeud9sKUb71SZr8sqmJl3qtvZmKpkR4y8pN2c00p10t0htqONmr5kyPxmhz0HCrosiPYB4olNjaydKviNTtPJ7TtnPyeA3iXGzCP1e80XzUoJrDqON5/GcpYgqsP/kGj8Qvqesa4Fez+1+5pAGHN2VzQbkHAgK3s4YRXrGLTs7wg27F9T0RE28Mm0RYBkYpdp4/5PuTTulthB9mkUBSJMgENmQAYkapvonFDsJkTi39qnsddbZusOLT4z3hsA38eFEwRqnbNZVUGPIp/O1SsCAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDRUZ4QXVLRzV6SlVUTWpWNTJoMkRJMUQ5MXdLblZKaXFwNmpwRTRTTy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAYLThxDVpA1OIAVK/buueRJExIWr6y4s6NtpuR8UQEcfq5hfoc4zMFGHR5+u1WFIb5siK25xh/OnS7bLdLic6AkjZSrx91+0v2Jn9gfUqbs5AJ040XzAAdx/Mb4s0+537yhB+/JXPylR1QxhGbO7koXQ5JDhAXWKCw2O1C+80mN8dbhQvDkEtsXrHrtXclcqf2TT89XAzc5HAC8NmP4SF+FafAREQB1KdaG4QAbc/gnjsX2YJD89SDL+3jMp6F7R1Ym+bWt5oWqx2tkm6HGXd3fbpfQlnfrRN60tMjjLmw1cDMhOhpdragY5zokniEUL2pKVtrxFp7V1ZpoMI0Kt5MKkOXrezi542NWSgkGehlsDLD9wtuCNem2arR0mNnMLdYkMG7G0dpAq3Tl32dgfMfyKnNyE2O/6/EeEuzUH2NfTU1p7AUQfLrf4rtNcJEs9OAPuC9vy7w9YEpF997T+FhR2Ub1C423NQj4bwlS/9f7MIBkSi1EgnQuiSGB5epxAKI3oOVrmzOpTuvr6wZXV9pM3zdfbcoGuFWP6Ix7W8G5vg+0WvoSjc2fwGXYlidEK3xlQSMAaQ4CMClpPsKLScRq1nrQGzPYoiL1DYubsOWx9ohll6+jNjKI6f79WwbHYrW4EeRIOz38+m46EDjAWZBMgrE7J/3DhgeLEVJYBA5K0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
|
||||||
|
<saml:Subject>
|
||||||
|
<saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7</saml:NameID>
|
||||||
|
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||||
|
<saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
|
||||||
|
</saml:SubjectConfirmation>
|
||||||
|
</saml:Subject>
|
||||||
|
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
|
||||||
|
<saml:AudienceRestriction>
|
||||||
|
<saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
|
||||||
|
</saml:AudienceRestriction>
|
||||||
|
</saml:Conditions>
|
||||||
|
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
|
||||||
|
<saml:AuthnContext>
|
||||||
|
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||||
|
</saml:AuthnContext>
|
||||||
|
</saml:AuthnStatement>
|
||||||
|
<saml:AttributeStatement>
|
||||||
|
<saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
<saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
<saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue>
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
</saml:AttributeStatement>
|
||||||
|
</saml:Assertion>
|
||||||
|
</samlp:Response>
|
||||||
68
authentik/sources/saml/tests/fixtures/response_signed_assertion_dup.xml
vendored
Normal file
68
authentik/sources/saml/tests/fixtures/response_signed_assertion_dup.xml
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_8e8dc5f69a98cc4c1ff3427e5ce34606fd672f91e6" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685">
|
||||||
|
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer>
|
||||||
|
<samlp:Status>
|
||||||
|
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||||
|
</samlp:Status>
|
||||||
|
<saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="_other_id_pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
|
||||||
|
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer>
|
||||||
|
<saml:Subject>
|
||||||
|
<saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">bad</saml:NameID>
|
||||||
|
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||||
|
<saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
|
||||||
|
</saml:SubjectConfirmation>
|
||||||
|
</saml:Subject>
|
||||||
|
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
|
||||||
|
<saml:AudienceRestriction>
|
||||||
|
<saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
|
||||||
|
</saml:AudienceRestriction>
|
||||||
|
</saml:Conditions>
|
||||||
|
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
|
||||||
|
<saml:AuthnContext>
|
||||||
|
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||||
|
</saml:AuthnContext>
|
||||||
|
</saml:AuthnStatement>
|
||||||
|
<saml:AttributeStatement>
|
||||||
|
<saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">bad</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
<saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">bad</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
</saml:AttributeStatement>
|
||||||
|
</saml:Assertion>
|
||||||
|
<saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
|
||||||
|
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||||
|
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||||
|
<ds:Reference URI="#pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>zNDuGxwP4gVkv/Dzt7kiKo/4gzk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>GLP/vE8uxerB0uDpPslUgLPBL6ePQB619MoQ0I2Y5lAtFE6CB1zh8BnzChRx/bFjNy4byfOe8mFfM0r7WUi1PJOFWyUPoatdLl7wHHBIRTnPpYmu3Tb2Gz0sOP0F8wW7JkBft5gJfVw49nk5si9/3Q3o52jnJZ7dPtqfIOh8uNeopikK0HLF6sU05qCCtjcXfniEnLQFNBFMo9uY5GQqmR5n3nqPz1wYyyfFOAbVmGgBIoO2PfGX2GVLQhltc9qf2JMhks4jgZsZ8iLUIiH1lcLGWZEEs94k8k0P6gSv1uZ7Vbhksd/N9Jq9pCVuEJ/jRPcAdVjzbxqKQAj6ELwr8O6fepTzA+CAdwEolBnx/C6TmSbVZ+IWk6QUGe4x4+IAukC+0hkKENlO0ELOScksvyhpgHbxNA4rp+DhGupCaO/I2RrsQkmvavbqm+wSEspK7scK112SDunjDvqPHsPYgukD33T/97PxTLorg2kKP9HHJwPJKoXXeyOGcA6vwK+RqrAlZ2dLGAgcXo+sJcdCLuvxDNz9VXofBjBZIKVKdmYhm0QJaPYHtuQsAyFavQhdOBOmGHb7QX3YE3Xy4dX4LymtT+Jlb1I4FJSht/9HUIHW1FdhfDak4f7gUgjuMamMddLD0jVgeESupSREzFv/gj2IrctkbgjAO0iuuiBgKMg=</ds:SignatureValue>
|
||||||
|
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFUzCCAzugAwIBAgIRAL6tbNcE9Ej9gNlbGKswfFMwDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjUuNi4zMB4XDTI1MDcxNTE4MDQzNloXDTI2MDcxNjE4MDQzNlowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjmut/+bBRLlyrbf+WIfg8ZTw9t6VnsiU1n04nPTulpRAz4nBOoOHNRIruSpZyFeFa6x9jwn4Ma5EFUH7HqnRvhoujm8U17OglXWZt0DLCZ6S5xPmdMogFXjJDmg9okIcI/cb9VbR6I8uvm1oiaOWCr36RTiqZ6rmdjQcuUPLr1+V/LxWQI463S+5QA2HZxAGalp45MJAz2sa9iczktKMgyYlfjj1cruFARxxeheu5qIK7aQWfyPj1QlMb9mi4VQaxUwGrAui4Tq614ivRJY2SkZb0Aq/LLSQoQWYHtYyQIasrOXJm0JuPDqhINPBDowyhu8DihC3uzOpmTXLKc5UoIQk+Q1h5iH74A3/kxOJUw13FXzRiDxC/yGthPYLyFHsDiJolscMKSCqlDvEMcpM4mxFeud9sKUb71SZr8sqmJl3qtvZmKpkR4y8pN2c00p10t0htqONmr5kyPxmhz0HCrosiPYB4olNjaydKviNTtPJ7TtnPyeA3iXGzCP1e80XzUoJrDqON5/GcpYgqsP/kGj8Qvqesa4Fez+1+5pAGHN2VzQbkHAgK3s4YRXrGLTs7wg27F9T0RE28Mm0RYBkYpdp4/5PuTTulthB9mkUBSJMgENmQAYkapvonFDsJkTi39qnsddbZusOLT4z3hsA38eFEwRqnbNZVUGPIp/O1SsCAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDRUZ4QXVLRzV6SlVUTWpWNTJoMkRJMUQ5MXdLblZKaXFwNmpwRTRTTy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAYLThxDVpA1OIAVK/buueRJExIWr6y4s6NtpuR8UQEcfq5hfoc4zMFGHR5+u1WFIb5siK25xh/OnS7bLdLic6AkjZSrx91+0v2Jn9gfUqbs5AJ040XzAAdx/Mb4s0+537yhB+/JXPylR1QxhGbO7koXQ5JDhAXWKCw2O1C+80mN8dbhQvDkEtsXrHrtXclcqf2TT89XAzc5HAC8NmP4SF+FafAREQB1KdaG4QAbc/gnjsX2YJD89SDL+3jMp6F7R1Ym+bWt5oWqx2tkm6HGXd3fbpfQlnfrRN60tMjjLmw1cDMhOhpdragY5zokniEUL2pKVtrxFp7V1ZpoMI0Kt5MKkOXrezi542NWSgkGehlsDLD9wtuCNem2arR0mNnMLdYkMG7G0dpAq3Tl32dgfMfyKnNyE2O/6/EeEuzUH2NfTU1p7AUQfLrf4rtNcJEs9OAPuC9vy7w9YEpF997T+FhR2Ub1C423NQj4bwlS/9f7MIBkSi1EgnQuiSGB5epxAKI3oOVrmzOpTuvr6wZXV9pM3zdfbcoGuFWP6Ix7W8G5vg+0WvoSjc2fwGXYlidEK3xlQSMAaQ4CMClpPsKLScRq1nrQGzPYoiL1DYubsOWx9ohll6+jNjKI6f79WwbHYrW4EeRIOz38+m46EDjAWZBMgrE7J/3DhgeLEVJYBA5K0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
|
||||||
|
<saml:Subject>
|
||||||
|
<saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7</saml:NameID>
|
||||||
|
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||||
|
<saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
|
||||||
|
</saml:SubjectConfirmation>
|
||||||
|
</saml:Subject>
|
||||||
|
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
|
||||||
|
<saml:AudienceRestriction>
|
||||||
|
<saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
|
||||||
|
</saml:AudienceRestriction>
|
||||||
|
</saml:Conditions>
|
||||||
|
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
|
||||||
|
<saml:AuthnContext>
|
||||||
|
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||||
|
</saml:AuthnContext>
|
||||||
|
</saml:AuthnStatement>
|
||||||
|
<saml:AttributeStatement>
|
||||||
|
<saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
<saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
<saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue>
|
||||||
|
<saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
</saml:AttributeStatement>
|
||||||
|
</saml:Assertion>
|
||||||
|
</samlp:Response>
|
||||||
31
authentik/sources/saml/tests/fixtures/signature_cert.pem
vendored
Normal file
31
authentik/sources/saml/tests/fixtures/signature_cert.pem
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFUzCCAzugAwIBAgIRAL6tbNcE9Ej9gNlbGKswfFMwDQYJKoZIhvcNAQELBQAw
|
||||||
|
HTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjUuNi4zMB4XDTI1MDcxNTE4MDQzNloX
|
||||||
|
DTI2MDcxNjE4MDQzNlowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVk
|
||||||
|
IENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYt
|
||||||
|
c2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjmut/+bBRLly
|
||||||
|
rbf+WIfg8ZTw9t6VnsiU1n04nPTulpRAz4nBOoOHNRIruSpZyFeFa6x9jwn4Ma5E
|
||||||
|
FUH7HqnRvhoujm8U17OglXWZt0DLCZ6S5xPmdMogFXjJDmg9okIcI/cb9VbR6I8u
|
||||||
|
vm1oiaOWCr36RTiqZ6rmdjQcuUPLr1+V/LxWQI463S+5QA2HZxAGalp45MJAz2sa
|
||||||
|
9iczktKMgyYlfjj1cruFARxxeheu5qIK7aQWfyPj1QlMb9mi4VQaxUwGrAui4Tq6
|
||||||
|
14ivRJY2SkZb0Aq/LLSQoQWYHtYyQIasrOXJm0JuPDqhINPBDowyhu8DihC3uzOp
|
||||||
|
mTXLKc5UoIQk+Q1h5iH74A3/kxOJUw13FXzRiDxC/yGthPYLyFHsDiJolscMKSCq
|
||||||
|
lDvEMcpM4mxFeud9sKUb71SZr8sqmJl3qtvZmKpkR4y8pN2c00p10t0htqONmr5k
|
||||||
|
yPxmhz0HCrosiPYB4olNjaydKviNTtPJ7TtnPyeA3iXGzCP1e80XzUoJrDqON5/G
|
||||||
|
cpYgqsP/kGj8Qvqesa4Fez+1+5pAGHN2VzQbkHAgK3s4YRXrGLTs7wg27F9T0RE2
|
||||||
|
8Mm0RYBkYpdp4/5PuTTulthB9mkUBSJMgENmQAYkapvonFDsJkTi39qnsddbZusO
|
||||||
|
LT4z3hsA38eFEwRqnbNZVUGPIp/O1SsCAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJD
|
||||||
|
RUZ4QXVLRzV6SlVUTWpWNTJoMkRJMUQ5MXdLblZKaXFwNmpwRTRTTy5zZWxmLXNp
|
||||||
|
Z25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAYLThxDVpA1OI
|
||||||
|
AVK/buueRJExIWr6y4s6NtpuR8UQEcfq5hfoc4zMFGHR5+u1WFIb5siK25xh/own
|
||||||
|
7bLdLic6AkjZSrx91+0v2Jn9gfUqbs5AJ040XzAAdx/Mb4s0+537yhB+/JXPylR1
|
||||||
|
QxhGbO7koXQ5JDhAXWKCw2O1C+80mN8dbhQvDkEtsXrHrtXclcqf2TT89XAzc5HA
|
||||||
|
C8NmP4SF+FafAREQB1KdaG4QAbc/gnjsX2YJD89SDL+3jMp6F7R1Ym+bWt5oWqx2
|
||||||
|
tkm6HGXd3fbpfQlnfrRN60tMjjLmw1cDMhOhpdragY5zokniEUL2pKVtrxFp7V1Z
|
||||||
|
poMI0Kt5MKkOXrezi542NWSgkGehlsDLD9wtuCNem2arR0mNnMLdYkMG7G0dpAq3
|
||||||
|
Tl32dgfMfyKnNyE2O/6/EeEuzUH2NfTU1p7AUQfLrf4rtNcJEs9OAPuC9vy7w9YE
|
||||||
|
pF997T+FhR2Ub1C423NQj4bwlS/9f7MIBkSi1EgnQuiSGB5epxAKI3oOVrmzOpTu
|
||||||
|
vr6wZXV9pM3zdfbcoGuFWP6Ix7W8G5vg+0WvoSjc2fwGXYlidEK3xlQSMAaQ4CMC
|
||||||
|
lpPsKLScRq1nrQGzPYoiL1DYubsOWx9ohll6+jNjKI6f79WwbHYrW4EeRIOz38+m
|
||||||
|
46EDjAWZBMgrE7J/3DhgeLEVJYBA5K0=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -37,7 +37,9 @@ class TestPropertyMappings(TestCase):
|
|||||||
|
|
||||||
def test_user_base_properties(self):
|
def test_user_base_properties(self):
|
||||||
"""Test user base properties"""
|
"""Test user base properties"""
|
||||||
properties = self.source.get_base_user_properties(root=ROOT, name_id=NAME_ID)
|
properties = self.source.get_base_user_properties(
|
||||||
|
root=ROOT, assertion=ROOT.find(f"{{{NS_SAML_ASSERTION}}}Assertion"), name_id=NAME_ID
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
properties,
|
properties,
|
||||||
{
|
{
|
||||||
@@ -50,7 +52,11 @@ class TestPropertyMappings(TestCase):
|
|||||||
|
|
||||||
def test_group_base_properties(self):
|
def test_group_base_properties(self):
|
||||||
"""Test group base properties"""
|
"""Test group base properties"""
|
||||||
properties = self.source.get_base_user_properties(root=ROOT_GROUPS, name_id=NAME_ID)
|
properties = self.source.get_base_user_properties(
|
||||||
|
root=ROOT_GROUPS,
|
||||||
|
assertion=ROOT_GROUPS.find(f"{{{NS_SAML_ASSERTION}}}Assertion"),
|
||||||
|
name_id=NAME_ID,
|
||||||
|
)
|
||||||
self.assertEqual(properties["groups"], ["group 1", "group 2"])
|
self.assertEqual(properties["groups"], ["group 1", "group 2"])
|
||||||
for group_id in ["group 1", "group 2"]:
|
for group_id in ["group 1", "group 2"]:
|
||||||
properties = self.source.get_base_group_properties(root=ROOT, group_id=group_id)
|
properties = self.source.get_base_group_properties(root=ROOT, group_id=group_id)
|
||||||
|
|||||||
@@ -125,3 +125,50 @@ class TestResponseProcessor(TestCase):
|
|||||||
parser = ResponseProcessor(self.source, request)
|
parser = ResponseProcessor(self.source, request)
|
||||||
with self.assertRaises(InvalidEncryption):
|
with self.assertRaises(InvalidEncryption):
|
||||||
parser.parse()
|
parser.parse()
|
||||||
|
|
||||||
|
def test_verification_assertion(self):
|
||||||
|
"""Test verifying signature inside assertion"""
|
||||||
|
key = load_fixture("fixtures/signature_cert.pem")
|
||||||
|
kp = CertificateKeyPair.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
certificate_data=key,
|
||||||
|
)
|
||||||
|
self.source.verification_kp = kp
|
||||||
|
self.source.signed_assertion = True
|
||||||
|
self.source.signed_response = False
|
||||||
|
request = self.factory.post(
|
||||||
|
"/",
|
||||||
|
data={
|
||||||
|
"SAMLResponse": b64encode(
|
||||||
|
load_fixture("fixtures/response_signed_assertion.xml").encode()
|
||||||
|
).decode()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ResponseProcessor(self.source, request)
|
||||||
|
parser.parse()
|
||||||
|
|
||||||
|
def test_verification_assertion_duplicate(self):
|
||||||
|
"""Test verifying signature inside assertion, where the response has another assertion
|
||||||
|
before our signed assertion"""
|
||||||
|
key = load_fixture("fixtures/signature_cert.pem")
|
||||||
|
kp = CertificateKeyPair.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
certificate_data=key,
|
||||||
|
)
|
||||||
|
self.source.verification_kp = kp
|
||||||
|
self.source.signed_assertion = True
|
||||||
|
self.source.signed_response = False
|
||||||
|
request = self.factory.post(
|
||||||
|
"/",
|
||||||
|
data={
|
||||||
|
"SAMLResponse": b64encode(
|
||||||
|
load_fixture("fixtures/response_signed_assertion_dup.xml").encode()
|
||||||
|
).decode()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ResponseProcessor(self.source, request)
|
||||||
|
parser.parse()
|
||||||
|
self.assertNotEqual(parser._get_name_id().text, "bad")
|
||||||
|
self.assertEqual(parser._get_name_id().text, "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
|
||||||
|
|||||||
@@ -15,18 +15,10 @@ from authentik.lib.config import CONFIG
|
|||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.models import SerializerModel
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.stages.authenticator.models import SideChannelDevice
|
from authentik.stages.authenticator.models import SideChannelDevice
|
||||||
|
from authentik.stages.email.models import EmailTemplates
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
|
||||||
|
|
||||||
class EmailTemplates(models.TextChoices):
|
|
||||||
"""Templates used for rendering the Email"""
|
|
||||||
|
|
||||||
EMAIL_OTP = (
|
|
||||||
"email/email_otp.html",
|
|
||||||
_("Email OTP"),
|
|
||||||
) # nosec
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
"""Use Email-based authentication instead of authenticator-based."""
|
"""Use Email-based authentication instead of authenticator-based."""
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
|||||||
user = self.get_pending_user()
|
user = self.get_pending_user()
|
||||||
|
|
||||||
stage: AuthenticatorEmailStage = self.executor.current_stage
|
stage: AuthenticatorEmailStage = self.executor.current_stage
|
||||||
|
# For the moment we only allow one email device per user
|
||||||
|
if EmailDevice.objects.filter(Q(user=user), stage=stage.pk).exists():
|
||||||
|
return self.executor.stage_invalid(
|
||||||
|
_("The user already has an email address registered for MFA.")
|
||||||
|
)
|
||||||
if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
|
if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
|
||||||
device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
|
device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
|
||||||
valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds()
|
valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds()
|
||||||
|
|||||||
@@ -108,6 +108,17 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
|||||||
)
|
)
|
||||||
def test_stage_submit(self):
|
def test_stage_submit(self):
|
||||||
"""Test stage email submission"""
|
"""Test stage email submission"""
|
||||||
|
# test fail because of existing device
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
|
)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
self.user,
|
||||||
|
component="ak-stage-access-denied",
|
||||||
|
)
|
||||||
|
self.device.delete()
|
||||||
# Initialize the flow
|
# Initialize the flow
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
@@ -232,6 +243,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
|||||||
def test_challenge_generation(self):
|
def test_challenge_generation(self):
|
||||||
"""Test challenge generation"""
|
"""Test challenge generation"""
|
||||||
# Test with masked email
|
# Test with masked email
|
||||||
|
self.device.delete()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -35,7 +35,12 @@ class Command(TenantCommand):
|
|||||||
template_context={},
|
template_context={},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
send_mail(message.__dict__, stage.pk)
|
if not stage.use_global_settings:
|
||||||
|
message.from_email = stage.from_address
|
||||||
|
|
||||||
|
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:
|
finally:
|
||||||
if delete_stage:
|
if delete_stage:
|
||||||
stage.delete()
|
stage.delete()
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ class EmailTemplates(models.TextChoices):
|
|||||||
"email/account_confirmation.html",
|
"email/account_confirmation.html",
|
||||||
_("Account Confirmation"),
|
_("Account Confirmation"),
|
||||||
)
|
)
|
||||||
|
EMAIL_OTP = (
|
||||||
|
"email/email_otp.html",
|
||||||
|
_("Email OTP"),
|
||||||
|
) # nosec
|
||||||
|
EVENT_NOTIFICATION = (
|
||||||
|
"email/event_notification.html",
|
||||||
|
_("Event Notification"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_template_choices():
|
def get_template_choices():
|
||||||
|
|||||||
66
authentik/stages/email/tests/test_management_commands.py
Normal file
66
authentik/stages/email/tests/test_management_commands.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Test email management commands"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.mail.backends.locmem import EmailBackend
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
from authentik.stages.email.models import EmailStage
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailManagementCommands(TestCase):
|
||||||
|
"""Test email management commands"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
|
||||||
|
def test_test_email_command_with_stage(self):
|
||||||
|
"""Test test_email command with specified stage"""
|
||||||
|
EmailStage.objects.create(
|
||||||
|
name="test-stage",
|
||||||
|
from_address="test@authentik.local",
|
||||||
|
host="localhost",
|
||||||
|
port=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||||
|
call_command("test_email", "test@example.com", stage="test-stage")
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "authentik Test-Email")
|
||||||
|
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||||
|
|
||||||
|
def test_test_email_command_with_global_settings(self):
|
||||||
|
"""Test test_email command with global settings"""
|
||||||
|
# Mock the backend to use Django's locmem backend
|
||||||
|
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||||
|
call_command("test_email", "test@example.com")
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "authentik Test-Email")
|
||||||
|
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||||
|
|
||||||
|
def test_test_email_command_invalid_stage(self):
|
||||||
|
"""Test test_email command with invalid stage"""
|
||||||
|
call_command("test_email", "test@example.com", stage="nonexistent")
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_test_email_command_with_custom_from(self):
|
||||||
|
"""Test test_email command respects custom from address"""
|
||||||
|
EmailStage.objects.create(
|
||||||
|
name="test-stage",
|
||||||
|
from_address="custom@authentik.local",
|
||||||
|
host="localhost",
|
||||||
|
port=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||||
|
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"])
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user