Compare commits
2 Commits
flows/conc
...
enterprise
Author | SHA1 | Date | |
---|---|---|---|
74deedc9d4 | |||
3c8232b9a5 |
@ -1,16 +1,16 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2025.2.2
|
current_version = 2024.12.3
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||||
serialize =
|
serialize =
|
||||||
{major}.{minor}.{patch}-{rc_t}{rc_n}
|
{major}.{minor}.{patch}-{rc_t}{rc_n}
|
||||||
{major}.{minor}.{patch}
|
{major}.{minor}.{patch}
|
||||||
message = release: {new_version}
|
message = release: {new_version}
|
||||||
tag_name = version/{new_version}
|
tag_name = version/{new_version}
|
||||||
|
|
||||||
[bumpversion:part:rc_t]
|
[bumpversion:part:rc_t]
|
||||||
values =
|
values =
|
||||||
rc
|
rc
|
||||||
final
|
final
|
||||||
optional_value = final
|
optional_value = final
|
||||||
|
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -28,11 +28,7 @@ Output of docker-compose logs or kubectl logs respectively
|
|||||||
|
|
||||||
**Version and Deployment (please complete the following information):**
|
**Version and Deployment (please complete the following information):**
|
||||||
|
|
||||||
<!--
|
- authentik version: [e.g. 2021.8.5]
|
||||||
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
- authentik version: [e.g. 2025.2.0]
|
|
||||||
- Deployment: [e.g. docker-compose, helm]
|
- Deployment: [e.g. docker-compose, helm]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
7
.github/ISSUE_TEMPLATE/question.md
vendored
7
.github/ISSUE_TEMPLATE/question.md
vendored
@ -20,12 +20,7 @@ Output of docker-compose logs or kubectl logs respectively
|
|||||||
|
|
||||||
**Version and Deployment (please complete the following information):**
|
**Version and Deployment (please complete the following information):**
|
||||||
|
|
||||||
<!--
|
- authentik version: [e.g. 2021.8.5]
|
||||||
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
|
|
||||||
- authentik version: [e.g. 2025.2.0]
|
|
||||||
- Deployment: [e.g. docker-compose, helm]
|
- Deployment: [e.g. docker-compose, helm]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
20
.github/actions/setup/action.yml
vendored
20
.github/actions/setup/action.yml
vendored
@ -9,22 +9,17 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt deps
|
- name: Install poetry & deps
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
pipx install poetry || true
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||||
- name: Install uv
|
- name: Setup python and restore poetry
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: "pyproject.toml"
|
||||||
- name: Install Python deps
|
cache: "poetry"
|
||||||
shell: bash
|
|
||||||
run: uv sync --all-extras --dev --frozen
|
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@ -35,18 +30,15 @@ runs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup docker cache
|
|
||||||
uses: ScribeMD/docker-cache@0.5.0
|
|
||||||
with:
|
|
||||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||||
|
poetry sync
|
||||||
cd web && npm ci
|
cd web && npm ci
|
||||||
- name: Generate config
|
- name: Generate config
|
||||||
shell: uv run python {0}
|
shell: poetry run python {0}
|
||||||
run: |
|
run: |
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from yaml import safe_dump
|
from yaml import safe_dump
|
||||||
|
2
.github/actions/setup/docker-compose.yml
vendored
2
.github/actions/setup/docker-compose.yml
vendored
@ -11,7 +11,7 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
restart: always
|
restart: always
|
||||||
redis:
|
redis:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
restart: always
|
restart: always
|
||||||
|
33
.github/codespell-words.txt
vendored
33
.github/codespell-words.txt
vendored
@ -1,32 +1,7 @@
|
|||||||
akadmin
|
|
||||||
asgi
|
|
||||||
assertIn
|
|
||||||
authentik
|
|
||||||
authn
|
|
||||||
crate
|
|
||||||
docstrings
|
|
||||||
entra
|
|
||||||
goauthentik
|
|
||||||
gunicorn
|
|
||||||
hass
|
|
||||||
jwe
|
|
||||||
jwks
|
|
||||||
keypair
|
keypair
|
||||||
keypairs
|
keypairs
|
||||||
kubernetes
|
hass
|
||||||
oidc
|
|
||||||
ontext
|
|
||||||
openid
|
|
||||||
passwordless
|
|
||||||
plex
|
|
||||||
saml
|
|
||||||
scim
|
|
||||||
singed
|
|
||||||
slo
|
|
||||||
sso
|
|
||||||
totp
|
|
||||||
traefik
|
|
||||||
# https://github.com/codespell-project/codespell/issues/1224
|
|
||||||
upToDate
|
|
||||||
warmup
|
warmup
|
||||||
webauthn
|
ontext
|
||||||
|
singed
|
||||||
|
assertIn
|
||||||
|
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@ -82,12 +82,6 @@ updates:
|
|||||||
docusaurus:
|
docusaurus:
|
||||||
patterns:
|
patterns:
|
||||||
- "@docusaurus/*"
|
- "@docusaurus/*"
|
||||||
build:
|
|
||||||
patterns:
|
|
||||||
- "@swc/*"
|
|
||||||
- "swc-*"
|
|
||||||
- "lightningcss*"
|
|
||||||
- "@rspack/binding*"
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/lifecycle/aws"
|
directory: "/lifecycle/aws"
|
||||||
schedule:
|
schedule:
|
||||||
@ -98,7 +92,7 @@ updates:
|
|||||||
prefix: "lifecycle/aws:"
|
prefix: "lifecycle/aws:"
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
- package-ecosystem: uv
|
- package-ecosystem: pip
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: daily
|
||||||
|
@ -40,7 +40,7 @@ jobs:
|
|||||||
attestations: write
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-qemu-action@v3.6.0
|
- uses: docker/setup-qemu-action@v3.4.0
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
|
2
.github/workflows/ci-aws-cfn.yml
vendored
2
.github/workflows/ci-aws-cfn.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
- name: Check changes have been applied
|
- name: Check changes have been applied
|
||||||
run: |
|
run: |
|
||||||
uv run make aws-cfn
|
poetry run make aws-cfn
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
ci-aws-cfn-mark:
|
ci-aws-cfn-mark:
|
||||||
if: always()
|
if: always()
|
||||||
|
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@ -15,8 +15,8 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
version:
|
version:
|
||||||
- docs
|
- docs
|
||||||
- version-2025-2
|
|
||||||
- version-2024-12
|
- version-2024-12
|
||||||
|
- version-2024-10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: |
|
- run: |
|
||||||
|
32
.github/workflows/ci-main.yml
vendored
32
.github/workflows/ci-main.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run job
|
- name: run job
|
||||||
run: uv run make ci-${{ matrix.job }}
|
run: poetry run make ci-${{ matrix.job }}
|
||||||
test-migrations:
|
test-migrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: run migrations
|
- name: run migrations
|
||||||
run: uv run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
test-make-seed:
|
test-make-seed:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -69,21 +69,19 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
run: |
|
run: |
|
||||||
|
# Delete all poetry envs
|
||||||
|
rm -rf /home/runner/.cache/pypoetry
|
||||||
# Copy current, latest config to local
|
# Copy current, latest config to local
|
||||||
# Temporarly comment the .github backup while migrating to uv
|
|
||||||
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)
|
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||||
# rm -rf .github/ scripts/
|
rm -rf .github/ scripts/
|
||||||
# mv ../.github ../scripts .
|
mv ../.github ../scripts .
|
||||||
rm -rf scripts/
|
|
||||||
mv ../scripts .
|
|
||||||
- name: Setup authentik env (stable)
|
- name: Setup authentik env (stable)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
postgresql_version: ${{ matrix.psql }}
|
postgresql_version: ${{ matrix.psql }}
|
||||||
continue-on-error: true
|
|
||||||
- name: run migrations to stable
|
- name: run migrations to stable
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
- name: checkout current code
|
- name: checkout current code
|
||||||
@ -93,13 +91,15 @@ jobs:
|
|||||||
git reset --hard HEAD
|
git reset --hard HEAD
|
||||||
git clean -d -fx .
|
git clean -d -fx .
|
||||||
git checkout $GITHUB_SHA
|
git checkout $GITHUB_SHA
|
||||||
|
# Delete previous poetry env
|
||||||
|
rm -rf /home/runner/.cache/pypoetry/virtualenvs/*
|
||||||
- name: Setup authentik env (ensure latest deps are installed)
|
- name: Setup authentik env (ensure latest deps are installed)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
postgresql_version: ${{ matrix.psql }}
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: migrate to latest
|
- name: migrate to latest
|
||||||
run: |
|
run: |
|
||||||
uv run python -m lifecycle.migrate
|
poetry run python -m lifecycle.migrate
|
||||||
- name: run tests
|
- name: run tests
|
||||||
env:
|
env:
|
||||||
# Test in the main database that we just migrated from the previous stable version
|
# Test in the main database that we just migrated from the previous stable version
|
||||||
@ -108,7 +108,7 @@ jobs:
|
|||||||
CI_RUN_ID: ${{ matrix.run_id }}
|
CI_RUN_ID: ${{ matrix.run_id }}
|
||||||
CI_TOTAL_RUNS: "5"
|
CI_TOTAL_RUNS: "5"
|
||||||
run: |
|
run: |
|
||||||
uv run make ci-test
|
poetry run make ci-test
|
||||||
test-unittest:
|
test-unittest:
|
||||||
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
|
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -133,7 +133,7 @@ jobs:
|
|||||||
CI_RUN_ID: ${{ matrix.run_id }}
|
CI_RUN_ID: ${{ matrix.run_id }}
|
||||||
CI_TOTAL_RUNS: "5"
|
CI_TOTAL_RUNS: "5"
|
||||||
run: |
|
run: |
|
||||||
uv run make ci-test
|
poetry run make ci-test
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
@ -156,8 +156,8 @@ jobs:
|
|||||||
uses: helm/kind-action@v1.12.0
|
uses: helm/kind-action@v1.12.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
uv run coverage run manage.py test tests/integration
|
poetry run coverage run manage.py test tests/integration
|
||||||
uv run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
@ -214,8 +214,8 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
poetry run coverage run manage.py test ${{ matrix.job.glob }}
|
||||||
uv run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@ -82,7 +82,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.6.0
|
uses: docker/setup-qemu-action@v3.4.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
|
@ -2,7 +2,7 @@ name: authentik-gen-update-webauthn-mds
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "30 1 1,15 * *"
|
- cron: '30 1 1,15 * *'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: authentik
|
POSTGRES_DB: authentik
|
||||||
@ -24,7 +24,7 @@ jobs:
|
|||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- run: uv run ak update_webauthn_mds
|
- run: poetry run ak update_webauthn_mds
|
||||||
- uses: peter-evans/create-pull-request@v7
|
- uses: peter-evans/create-pull-request@v7
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
|
4
.github/workflows/publish-source-docs.yml
vendored
4
.github/workflows/publish-source-docs.yml
vendored
@ -21,8 +21,8 @@ jobs:
|
|||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: generate docs
|
- name: generate docs
|
||||||
run: |
|
run: |
|
||||||
uv run make migrate
|
poetry run make migrate
|
||||||
uv run ak build_source_docs
|
poetry run ak build_source_docs
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: netlify/actions/cli@master
|
uses: netlify/actions/cli@master
|
||||||
with:
|
with:
|
||||||
|
4
.github/workflows/release-publish.yml
vendored
4
.github/workflows/release-publish.yml
vendored
@ -42,7 +42,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3.6.0
|
uses: docker/setup-qemu-action@v3.4.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
@ -186,7 +186,7 @@ jobs:
|
|||||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||||
docker cp ${container}:web/ .
|
docker cp ${container}:web/ .
|
||||||
- name: Create a Sentry.io release
|
- name: Create a Sentry.io release
|
||||||
uses: getsentry/action-release@v3
|
uses: getsentry/action-release@v1
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: authentik-translate-extract-compile
|
name: authentik-backend-translate-extract-compile
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * *" # every day at midnight
|
- cron: "0 0 * * *" # every day at midnight
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- version-*
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: authentik
|
POSTGRES_DB: authentik
|
||||||
@ -19,30 +15,23 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: generate_token
|
- id: generate_token
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
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 }}
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- uses: actions/checkout@v4
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
- name: Generate API
|
|
||||||
run: make gen-client-ts
|
|
||||||
- name: run extract
|
- name: run extract
|
||||||
run: |
|
run: |
|
||||||
uv run make i18n-extract
|
poetry run make i18n-extract
|
||||||
- name: run compile
|
- name: run compile
|
||||||
run: |
|
run: |
|
||||||
uv run ak compilemessages
|
poetry run ak compilemessages
|
||||||
make web-check-compile
|
make web-check-compile
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@ -1,4 +1,26 @@
|
|||||||
{
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"akadmin",
|
||||||
|
"asgi",
|
||||||
|
"authentik",
|
||||||
|
"authn",
|
||||||
|
"entra",
|
||||||
|
"goauthentik",
|
||||||
|
"jwe",
|
||||||
|
"jwks",
|
||||||
|
"kubernetes",
|
||||||
|
"oidc",
|
||||||
|
"openid",
|
||||||
|
"passwordless",
|
||||||
|
"plex",
|
||||||
|
"saml",
|
||||||
|
"scim",
|
||||||
|
"slo",
|
||||||
|
"sso",
|
||||||
|
"totp",
|
||||||
|
"traefik",
|
||||||
|
"webauthn"
|
||||||
|
],
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
"todo-tree.tree.showBadges": true,
|
"todo-tree.tree.showBadges": true,
|
||||||
"yaml.customTags": [
|
"yaml.customTags": [
|
||||||
|
46
.vscode/tasks.json
vendored
46
.vscode/tasks.json
vendored
@ -3,13 +3,8 @@
|
|||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "authentik/core: make",
|
"label": "authentik/core: make",
|
||||||
"command": "uv",
|
"command": "poetry",
|
||||||
"args": [
|
"args": ["run", "make", "lint-fix", "lint"],
|
||||||
"run",
|
|
||||||
"make",
|
|
||||||
"lint-fix",
|
|
||||||
"lint"
|
|
||||||
],
|
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "new"
|
"panel": "new"
|
||||||
},
|
},
|
||||||
@ -17,12 +12,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/core: run",
|
"label": "authentik/core: run",
|
||||||
"command": "uv",
|
"command": "poetry",
|
||||||
"args": [
|
"args": ["run", "ak", "server"],
|
||||||
"run",
|
|
||||||
"ak",
|
|
||||||
"server"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -32,17 +23,13 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik/web: make",
|
"label": "authentik/web: make",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["web"],
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/web: watch",
|
"label": "authentik/web: watch",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["web-watch"],
|
||||||
"web-watch"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -52,26 +39,19 @@
|
|||||||
{
|
{
|
||||||
"label": "authentik: install",
|
"label": "authentik: install",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["install", "-j4"],
|
||||||
"install",
|
|
||||||
"-j4"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/website: make",
|
"label": "authentik/website: make",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["website"],
|
||||||
"website"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/website: watch",
|
"label": "authentik/website: watch",
|
||||||
"command": "make",
|
"command": "make",
|
||||||
"args": [
|
"args": ["website-watch"],
|
||||||
"website-watch"
|
|
||||||
],
|
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"panel": "dedicated",
|
"panel": "dedicated",
|
||||||
@ -80,12 +60,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "authentik/api: generate",
|
"label": "authentik/api: generate",
|
||||||
"command": "uv",
|
"command": "poetry",
|
||||||
"args": [
|
"args": ["run", "make", "gen"],
|
||||||
"run",
|
|
||||||
"make",
|
|
||||||
"gen"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -10,7 +10,7 @@ schemas/ @goauthentik/backend
|
|||||||
scripts/ @goauthentik/backend
|
scripts/ @goauthentik/backend
|
||||||
tests/ @goauthentik/backend
|
tests/ @goauthentik/backend
|
||||||
pyproject.toml @goauthentik/backend
|
pyproject.toml @goauthentik/backend
|
||||||
uv.lock @goauthentik/backend
|
poetry.lock @goauthentik/backend
|
||||||
go.mod @goauthentik/backend
|
go.mod @goauthentik/backend
|
||||||
go.sum @goauthentik/backend
|
go.sum @goauthentik/backend
|
||||||
# Infrastructure
|
# Infrastructure
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
We as members, contributors, and leaders pledge to make participation in our
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
community a harassment-free experience for everyone, regardless of age, body
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
identity and expression, level of experience, education, socioeconomic status,
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
and orientation.
|
and orientation.
|
||||||
|
|
||||||
|
85
Dockerfile
85
Dockerfile
@ -93,59 +93,53 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
mkdir -p /usr/share/GeoIP && \
|
mkdir -p /usr/share/GeoIP && \
|
||||||
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||||
|
|
||||||
# Stage 5: Download uv
|
# Stage 5: Python dependencies
|
||||||
FROM ghcr.io/astral-sh/uv:0.6.8 AS uv
|
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-deps
|
||||||
# Stage 6: Base python image
|
|
||||||
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
|
|
||||||
|
|
||||||
ENV VENV_PATH="/ak-root/.venv" \
|
|
||||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
|
||||||
UV_COMPILE_BYTECODE=1 \
|
|
||||||
UV_LINK_MODE=copy \
|
|
||||||
UV_NATIVE_TLS=1 \
|
|
||||||
UV_PYTHON_DOWNLOADS=0
|
|
||||||
|
|
||||||
WORKDIR /ak-root/
|
|
||||||
|
|
||||||
COPY --from=uv /uv /uvx /bin/
|
|
||||||
|
|
||||||
# Stage 7: Python dependencies
|
|
||||||
FROM python-base AS python-deps
|
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
WORKDIR /ak-root/poetry
|
||||||
|
|
||||||
ENV PATH="/root/.cargo/bin:$PATH"
|
ENV VENV_PATH="/ak-root/venv" \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=false \
|
||||||
|
PATH="/ak-root/venv/bin:$PATH"
|
||||||
|
|
||||||
|
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||||
|
|
||||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
# Required for installing pip packages
|
# Required for installing pip packages
|
||||||
|
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev libkrb5-dev
|
||||||
|
|
||||||
|
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
|
||||||
|
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \
|
||||||
|
--mount=type=cache,target=/root/.cache/pip \
|
||||||
|
--mount=type=cache,target=/root/.cache/pypoetry \
|
||||||
|
pip install --no-cache cffi && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
# Build essentials
|
build-essential libffi-dev \
|
||||||
build-essential pkg-config libffi-dev git \
|
# Required for cryptography
|
||||||
# cryptography
|
curl pkg-config \
|
||||||
curl \
|
# Required for lxml
|
||||||
# libxml
|
libxslt-dev zlib1g-dev \
|
||||||
libxslt-dev zlib1g-dev \
|
# Required for xmlsec
|
||||||
# postgresql
|
libltdl-dev \
|
||||||
libpq-dev \
|
# Required for kadmin
|
||||||
# python-kadmin-rs
|
sccache clang && \
|
||||||
clang libkrb5-dev sccache \
|
curl https://sh.rustup.rs -sSf | sh -s -- -y && \
|
||||||
# xmlsec
|
. "$HOME/.cargo/env" && \
|
||||||
libltdl-dev && \
|
python -m venv /ak-root/venv/ && \
|
||||||
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
bash -c "source ${VENV_PATH}/bin/activate && \
|
||||||
|
pip3 install --upgrade pip poetry && \
|
||||||
|
poetry config --local installer.no-binary cryptography,xmlsec,lxml,python-kadmin-rs && \
|
||||||
|
poetry install --only=main --no-ansi --no-interaction --no-root && \
|
||||||
|
pip uninstall cryptography -y && \
|
||||||
|
poetry install --only=main --no-ansi --no-interaction --no-root"
|
||||||
|
|
||||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
# Stage 6: Run
|
||||||
|
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS final-image
|
||||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
|
||||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
|
||||||
--mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --frozen --no-install-project --no-dev
|
|
||||||
|
|
||||||
# Stage 8: Run
|
|
||||||
FROM python-base AS final-image
|
|
||||||
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG GIT_BUILD_HASH
|
ARG GIT_BUILD_HASH
|
||||||
@ -177,7 +171,7 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
COPY ./authentik/ /authentik
|
COPY ./authentik/ /authentik
|
||||||
COPY ./pyproject.toml /
|
COPY ./pyproject.toml /
|
||||||
COPY ./uv.lock /
|
COPY ./poetry.lock /
|
||||||
COPY ./schemas /schemas
|
COPY ./schemas /schemas
|
||||||
COPY ./locale /locale
|
COPY ./locale /locale
|
||||||
COPY ./tests /tests
|
COPY ./tests /tests
|
||||||
@ -186,7 +180,7 @@ COPY ./blueprints /blueprints
|
|||||||
COPY ./lifecycle/ /lifecycle
|
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 --from=python-deps /ak-root/.venv /ak-root/.venv
|
COPY --from=python-deps /ak-root/venv /ak-root/venv
|
||||||
COPY --from=web-builder /work/web/dist/ /web/dist/
|
COPY --from=web-builder /work/web/dist/ /web/dist/
|
||||||
COPY --from=web-builder /work/web/authentik/ /web/authentik/
|
COPY --from=web-builder /work/web/authentik/ /web/authentik/
|
||||||
COPY --from=website-builder /work/website/build/ /website/help/
|
COPY --from=website-builder /work/website/build/ /website/help/
|
||||||
@ -197,6 +191,9 @@ USER 1000
|
|||||||
ENV TMPDIR=/dev/shm/ \
|
ENV TMPDIR=/dev/shm/ \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
|
||||||
|
VENV_PATH="/ak-root/venv" \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=false \
|
||||||
GOFIPS=1
|
GOFIPS=1
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
|
||||||
|
72
Makefile
72
Makefile
@ -4,17 +4,34 @@
|
|||||||
PWD = $(shell pwd)
|
PWD = $(shell pwd)
|
||||||
UID = $(shell id -u)
|
UID = $(shell id -u)
|
||||||
GID = $(shell id -g)
|
GID = $(shell id -g)
|
||||||
NPM_VERSION = $(shell python -m scripts.generate_semver)
|
NPM_VERSION = $(shell python -m scripts.npm_version)
|
||||||
PY_SOURCES = authentik tests scripts lifecycle .github
|
PY_SOURCES = authentik tests scripts lifecycle .github
|
||||||
|
GO_SOURCES = cmd internal
|
||||||
|
WEB_SOURCES = web/src web/packages
|
||||||
DOCKER_IMAGE ?= "authentik:test"
|
DOCKER_IMAGE ?= "authentik:test"
|
||||||
|
|
||||||
GEN_API_TS = "gen-ts-api"
|
GEN_API_TS = "gen-ts-api"
|
||||||
GEN_API_PY = "gen-py-api"
|
GEN_API_PY = "gen-py-api"
|
||||||
GEN_API_GO = "gen-go-api"
|
GEN_API_GO = "gen-go-api"
|
||||||
|
|
||||||
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
|
pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null)
|
||||||
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
|
pg_host := $(shell python -m authentik.lib.config postgresql.host 2>/dev/null)
|
||||||
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
|
pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
|
||||||
|
|
||||||
|
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
|
||||||
|
-I .github/codespell-words.txt \
|
||||||
|
-S 'web/src/locales/**' \
|
||||||
|
-S 'website/docs/developer-docs/api/reference/**' \
|
||||||
|
-S '**/node_modules/**' \
|
||||||
|
-S '**/dist/**' \
|
||||||
|
$(PY_SOURCES) \
|
||||||
|
$(GO_SOURCES) \
|
||||||
|
$(WEB_SOURCES) \
|
||||||
|
website/src \
|
||||||
|
website/blog \
|
||||||
|
website/docs \
|
||||||
|
website/integrations \
|
||||||
|
website/src
|
||||||
|
|
||||||
all: lint-fix lint test gen web ## Lint, build, and test everything
|
all: lint-fix lint test gen web ## Lint, build, and test everything
|
||||||
|
|
||||||
@ -32,37 +49,34 @@ go-test:
|
|||||||
go test -timeout 0 -v -race -cover ./...
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
test: ## Run the server tests and produce a coverage report (locally)
|
test: ## Run the server tests and produce a coverage report (locally)
|
||||||
uv run coverage run manage.py test --keepdb authentik
|
coverage run manage.py test --keepdb authentik
|
||||||
uv run coverage html
|
coverage html
|
||||||
uv run coverage report
|
coverage report
|
||||||
|
|
||||||
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||||
uv run black $(PY_SOURCES)
|
black $(PY_SOURCES)
|
||||||
uv run ruff check --fix $(PY_SOURCES)
|
ruff check --fix $(PY_SOURCES)
|
||||||
|
|
||||||
lint-codespell: ## Reports spelling errors.
|
lint-codespell: ## Reports spelling errors.
|
||||||
uv run codespell -w
|
codespell -w $(CODESPELL_ARGS)
|
||||||
|
|
||||||
lint: ## Lint the python and golang sources
|
lint: ## Lint the python and golang sources
|
||||||
uv run bandit -c pyproject.toml -r $(PY_SOURCES)
|
bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules
|
||||||
golangci-lint run -v
|
golangci-lint run -v
|
||||||
|
|
||||||
core-install:
|
core-install:
|
||||||
uv sync --frozen
|
poetry install
|
||||||
|
|
||||||
migrate: ## Run the Authentik Django server's migrations
|
migrate: ## Run the Authentik Django server's migrations
|
||||||
uv run python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
|
|
||||||
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
|
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
|
||||||
|
|
||||||
aws-cfn:
|
aws-cfn:
|
||||||
cd lifecycle/aws && npm run aws-cfn
|
cd lifecycle/aws && npm run aws-cfn
|
||||||
|
|
||||||
run: ## Run the main authentik server process
|
|
||||||
uv run ak server
|
|
||||||
|
|
||||||
core-i18n-extract:
|
core-i18n-extract:
|
||||||
uv run ak makemessages \
|
ak makemessages \
|
||||||
--add-location file \
|
--add-location file \
|
||||||
--no-obsolete \
|
--no-obsolete \
|
||||||
--ignore web \
|
--ignore web \
|
||||||
@ -93,11 +107,11 @@ gen-build: ## Extract the schema from the database
|
|||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true \
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_TENANTS__ENABLED=true \
|
||||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||||
uv run ak make_blueprint_schema > blueprints/schema.json
|
ak make_blueprint_schema > blueprints/schema.json
|
||||||
AUTHENTIK_DEBUG=true \
|
AUTHENTIK_DEBUG=true \
|
||||||
AUTHENTIK_TENANTS__ENABLED=true \
|
AUTHENTIK_TENANTS__ENABLED=true \
|
||||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||||
uv run ak spectacular --file schema.yml
|
ak spectacular --file schema.yml
|
||||||
|
|
||||||
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
||||||
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
|
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
|
||||||
@ -148,7 +162,7 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
|||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
|
docker.io/openapitools/openapi-generator-cli:v7.4.0 generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g python \
|
-g python \
|
||||||
-o /local/${GEN_API_PY} \
|
-o /local/${GEN_API_PY} \
|
||||||
@ -176,7 +190,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
|||||||
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
|
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
|
||||||
|
|
||||||
gen-dev-config: ## Generate a local development config file
|
gen-dev-config: ## Generate a local development config file
|
||||||
uv run scripts/generate_config.py
|
python -m scripts.generate_config
|
||||||
|
|
||||||
gen: gen-build gen-client-ts
|
gen: gen-build gen-client-ts
|
||||||
|
|
||||||
@ -257,21 +271,21 @@ ci--meta-debug:
|
|||||||
node --version
|
node --version
|
||||||
|
|
||||||
ci-black: ci--meta-debug
|
ci-black: ci--meta-debug
|
||||||
uv run black --check $(PY_SOURCES)
|
black --check $(PY_SOURCES)
|
||||||
|
|
||||||
ci-ruff: ci--meta-debug
|
ci-ruff: ci--meta-debug
|
||||||
uv run ruff check $(PY_SOURCES)
|
ruff check $(PY_SOURCES)
|
||||||
|
|
||||||
ci-codespell: ci--meta-debug
|
ci-codespell: ci--meta-debug
|
||||||
uv run codespell -s
|
codespell $(CODESPELL_ARGS) -s
|
||||||
|
|
||||||
ci-bandit: ci--meta-debug
|
ci-bandit: ci--meta-debug
|
||||||
uv run bandit -r $(PY_SOURCES)
|
bandit -r $(PY_SOURCES)
|
||||||
|
|
||||||
ci-pending-migrations: ci--meta-debug
|
ci-pending-migrations: ci--meta-debug
|
||||||
uv run ak makemigrations --check
|
ak makemigrations --check
|
||||||
|
|
||||||
ci-test: ci--meta-debug
|
ci-test: ci--meta-debug
|
||||||
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
||||||
uv run coverage report
|
coverage report
|
||||||
uv run coverage xml
|
coverage xml
|
||||||
|
@ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di
|
|||||||
|
|
||||||
## Independent audits and pentests
|
## Independent audits and pentests
|
||||||
|
|
||||||
We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specific audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security).
|
We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specfic audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security).
|
||||||
|
|
||||||
## What authentik classifies as a CVE
|
## What authentik classifies as a CVE
|
||||||
|
|
||||||
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | --------- |
|
| --------- | --------- |
|
||||||
|
| 2024.10.x | ✅ |
|
||||||
| 2024.12.x | ✅ |
|
| 2024.12.x | ✅ |
|
||||||
| 2025.2.x | ✅ |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2025.2.2"
|
__version__ = "2024.12.3"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ from collections.abc import Iterable
|
|||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
@ -155,17 +154,6 @@ class SourceViewSet(
|
|||||||
matching_sources.append(source_settings.validated_data)
|
matching_sources.append(source_settings.validated_data)
|
||||||
return Response(matching_sources)
|
return Response(matching_sources)
|
||||||
|
|
||||||
def destroy(self, request: Request, *args, **kwargs):
|
|
||||||
"""Prevent deletion of built-in sources"""
|
|
||||||
instance: Source = self.get_object()
|
|
||||||
|
|
||||||
if instance.managed == Source.MANAGED_INBUILT:
|
|
||||||
raise ValidationError(
|
|
||||||
{"detail": "Built-in sources cannot be deleted"}, code="protected"
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().destroy(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSourceConnectionSerializer(SourceSerializer):
|
class UserSourceConnectionSerializer(SourceSerializer):
|
||||||
"""User source connection"""
|
"""User source connection"""
|
||||||
|
@ -32,5 +32,5 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
|||||||
"name": "authentik Built-in",
|
"name": "authentik Built-in",
|
||||||
"slug": "authentik-built-in",
|
"slug": "authentik-built-in",
|
||||||
},
|
},
|
||||||
managed=Source.MANAGED_INBUILT,
|
managed="goauthentik.io/sources/inbuilt",
|
||||||
)
|
)
|
||||||
|
@ -678,8 +678,6 @@ class SourceGroupMatchingModes(models.TextChoices):
|
|||||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||||
|
|
||||||
MANAGED_INBUILT = "goauthentik.io/sources/inbuilt"
|
|
||||||
|
|
||||||
name = models.TextField(help_text=_("Source's display Name."))
|
name = models.TextField(help_text=_("Source's display Name."))
|
||||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||||
|
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
build: "{{ build }}",
|
build: "{{ build }}",
|
||||||
api: {
|
api: {
|
||||||
base: "{{ base_url }}",
|
base: "{{ base_url }}",
|
||||||
relBase: "{{ base_url_rel }}",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
window.addEventListener("DOMContentLoaded", function () {
|
window.addEventListener("DOMContentLoaded", function () {
|
||||||
|
@ -8,8 +8,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
|
|
||||||
<meta name="darkreader-lock">
|
|
||||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
||||||
<link rel="icon" href="{{ brand.branding_favicon_url }}">
|
<link rel="icon" href="{{ brand.branding_favicon_url }}">
|
||||||
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
||||||
|
@ -55,7 +55,7 @@ class RedirectToAppLaunch(View):
|
|||||||
)
|
)
|
||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
raise Http404 from None
|
raise Http404 from None
|
||||||
plan.append_stage(in_memory_stage(RedirectToAppStage))
|
plan.insert_stage(in_memory_stage(RedirectToAppStage))
|
||||||
return plan.to_redirect(request, flow)
|
return plan.to_redirect(request, flow)
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,7 +53,6 @@ class InterfaceView(TemplateView):
|
|||||||
kwargs["build"] = get_build_hash()
|
kwargs["build"] = get_build_hash()
|
||||||
kwargs["url_kwargs"] = self.kwargs
|
kwargs["url_kwargs"] = self.kwargs
|
||||||
kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
|
kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
|
||||||
kwargs["base_url_rel"] = CONFIG.get("web.path", "/")
|
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,7 +37,6 @@ class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSeriali
|
|||||||
"user_delete_action",
|
"user_delete_action",
|
||||||
"group_delete_action",
|
"group_delete_action",
|
||||||
"default_group_email_domain",
|
"default_group_email_domain",
|
||||||
"dry_run",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
|
|
||||||
|
@ -8,10 +8,9 @@ from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse
|
|||||||
|
|
||||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
||||||
from authentik.lib.sync.outgoing import HTTP_CONFLICT
|
from authentik.lib.sync.outgoing import HTTP_CONFLICT
|
||||||
from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient
|
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||||
from authentik.lib.sync.outgoing.exceptions import (
|
from authentik.lib.sync.outgoing.exceptions import (
|
||||||
BadRequestSyncException,
|
BadRequestSyncException,
|
||||||
DryRunRejected,
|
|
||||||
NotFoundSyncException,
|
NotFoundSyncException,
|
||||||
ObjectExistsSyncException,
|
ObjectExistsSyncException,
|
||||||
StopSync,
|
StopSync,
|
||||||
@ -44,8 +43,6 @@ class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict
|
|||||||
self.domains.append(domain_name)
|
self.domains.append(domain_name)
|
||||||
|
|
||||||
def _request(self, request: HttpRequest):
|
def _request(self, request: HttpRequest):
|
||||||
if self.provider.dry_run and request.method.upper() not in SAFE_METHODS:
|
|
||||||
raise DryRunRejected(request.uri, request.method, request.body)
|
|
||||||
try:
|
try:
|
||||||
response = request.execute()
|
response = request.execute()
|
||||||
except GoogleAuthError as exc:
|
except GoogleAuthError as exc:
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 5.0.12 on 2025-02-24 19:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
(
|
|
||||||
"authentik_providers_google_workspace",
|
|
||||||
"0003_googleworkspaceprovidergroup_attributes_and_more",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="googleworkspaceprovider",
|
|
||||||
name="dry_run",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="When enabled, provider will not modify or create objects in the remote system.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -36,7 +36,6 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ
|
|||||||
"filter_group",
|
"filter_group",
|
||||||
"user_delete_action",
|
"user_delete_action",
|
||||||
"group_delete_action",
|
"group_delete_action",
|
||||||
"dry_run",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ from collections.abc import Coroutine
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from azure.core.exceptions import (
|
from azure.core.exceptions import (
|
||||||
ClientAuthenticationError,
|
ClientAuthenticationError,
|
||||||
ServiceRequestError,
|
ServiceRequestError,
|
||||||
@ -13,7 +12,6 @@ from azure.identity.aio import ClientSecretCredential
|
|||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from kiota_abstractions.api_error import APIError
|
from kiota_abstractions.api_error import APIError
|
||||||
from kiota_abstractions.request_information import RequestInformation
|
|
||||||
from kiota_authentication_azure.azure_identity_authentication_provider import (
|
from kiota_authentication_azure.azure_identity_authentication_provider import (
|
||||||
AzureIdentityAuthenticationProvider,
|
AzureIdentityAuthenticationProvider,
|
||||||
)
|
)
|
||||||
@ -23,15 +21,13 @@ from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
|||||||
from msgraph.graph_request_adapter import GraphRequestAdapter, options
|
from msgraph.graph_request_adapter import GraphRequestAdapter, options
|
||||||
from msgraph.graph_service_client import GraphServiceClient
|
from msgraph.graph_service_client import GraphServiceClient
|
||||||
from msgraph_core import GraphClientFactory
|
from msgraph_core import GraphClientFactory
|
||||||
from opentelemetry import trace
|
|
||||||
|
|
||||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
||||||
from authentik.events.utils import sanitize_item
|
from authentik.events.utils import sanitize_item
|
||||||
from authentik.lib.sync.outgoing import HTTP_CONFLICT
|
from authentik.lib.sync.outgoing import HTTP_CONFLICT
|
||||||
from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient
|
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||||
from authentik.lib.sync.outgoing.exceptions import (
|
from authentik.lib.sync.outgoing.exceptions import (
|
||||||
BadRequestSyncException,
|
BadRequestSyncException,
|
||||||
DryRunRejected,
|
|
||||||
NotFoundSyncException,
|
NotFoundSyncException,
|
||||||
ObjectExistsSyncException,
|
ObjectExistsSyncException,
|
||||||
StopSync,
|
StopSync,
|
||||||
@ -39,24 +35,20 @@ from authentik.lib.sync.outgoing.exceptions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthentikRequestAdapter(GraphRequestAdapter):
|
def get_request_adapter(
|
||||||
def __init__(self, auth_provider, provider: MicrosoftEntraProvider, client=None):
|
credentials: ClientSecretCredential, scopes: list[str] | None = None
|
||||||
super().__init__(auth_provider, client)
|
) -> GraphRequestAdapter:
|
||||||
self._provider = provider
|
if scopes:
|
||||||
|
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes)
|
||||||
|
else:
|
||||||
|
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
|
||||||
|
|
||||||
async def get_http_response_message(
|
return GraphRequestAdapter(
|
||||||
self,
|
auth_provider=auth_provider,
|
||||||
request_info: RequestInformation,
|
client=GraphClientFactory.create_with_default_middleware(
|
||||||
parent_span: trace.Span,
|
options=options, client=KiotaClientFactory.get_default_client()
|
||||||
claims: str = "",
|
),
|
||||||
) -> httpx.Response:
|
)
|
||||||
if self._provider.dry_run and request_info.http_method.value.upper() not in SAFE_METHODS:
|
|
||||||
raise DryRunRejected(
|
|
||||||
url=request_info.url,
|
|
||||||
method=request_info.http_method.value,
|
|
||||||
body=request_info.content.decode("utf-8"),
|
|
||||||
)
|
|
||||||
return await super().get_http_response_message(request_info, parent_span, claims=claims)
|
|
||||||
|
|
||||||
|
|
||||||
class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict](
|
class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict](
|
||||||
@ -71,27 +63,9 @@ class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict]
|
|||||||
self.credentials = provider.microsoft_credentials()
|
self.credentials = provider.microsoft_credentials()
|
||||||
self.__prefetch_domains()
|
self.__prefetch_domains()
|
||||||
|
|
||||||
def get_request_adapter(
|
|
||||||
self, credentials: ClientSecretCredential, scopes: list[str] | None = None
|
|
||||||
) -> AuthentikRequestAdapter:
|
|
||||||
if scopes:
|
|
||||||
auth_provider = AzureIdentityAuthenticationProvider(
|
|
||||||
credentials=credentials, scopes=scopes
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
|
|
||||||
|
|
||||||
return AuthentikRequestAdapter(
|
|
||||||
auth_provider=auth_provider,
|
|
||||||
provider=self.provider,
|
|
||||||
client=GraphClientFactory.create_with_default_middleware(
|
|
||||||
options=options, client=KiotaClientFactory.get_default_client()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self):
|
def client(self):
|
||||||
return GraphServiceClient(request_adapter=self.get_request_adapter(**self.credentials))
|
return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials))
|
||||||
|
|
||||||
def _request[T](self, request: Coroutine[Any, Any, T]) -> T:
|
def _request[T](self, request: Coroutine[Any, Any, T]) -> T:
|
||||||
try:
|
try:
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 5.0.12 on 2025-02-24 19:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
(
|
|
||||||
"authentik_providers_microsoft_entra",
|
|
||||||
"0002_microsoftentraprovidergroup_attributes_and_more",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="microsoftentraprovider",
|
|
||||||
name="dry_run",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="When enabled, provider will not modify or create objects in the remote system.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -32,6 +32,7 @@ class MicrosoftEntraUserTests(APITestCase):
|
|||||||
|
|
||||||
@apply_blueprint("system/providers-microsoft-entra.yaml")
|
@apply_blueprint("system/providers-microsoft-entra.yaml")
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|
||||||
# Delete all users and groups as the mocked HTTP responses only return one ID
|
# Delete all users and groups as the mocked HTTP responses only return one ID
|
||||||
# which will cause errors with multiple users
|
# which will cause errors with multiple users
|
||||||
Tenant.objects.update(avatars="none")
|
Tenant.objects.update(avatars="none")
|
||||||
@ -96,38 +97,6 @@ class MicrosoftEntraUserTests(APITestCase):
|
|||||||
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
|
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
|
||||||
user_create.assert_called_once()
|
user_create.assert_called_once()
|
||||||
|
|
||||||
def test_user_create_dry_run(self):
|
|
||||||
"""Test user creation (dry run)"""
|
|
||||||
self.provider.dry_run = True
|
|
||||||
self.provider.save()
|
|
||||||
uid = generate_id()
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
|
|
||||||
MagicMock(return_value={"credentials": self.creds}),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
|
|
||||||
AsyncMock(
|
|
||||||
return_value=OrganizationCollectionResponse(
|
|
||||||
value=[
|
|
||||||
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
user = User.objects.create(
|
|
||||||
username=uid,
|
|
||||||
name=f"{uid} {uid}",
|
|
||||||
email=f"{uid}@goauthentik.io",
|
|
||||||
)
|
|
||||||
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
|
|
||||||
provider=self.provider, user=user
|
|
||||||
).first()
|
|
||||||
self.assertIsNone(microsoft_user)
|
|
||||||
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
|
|
||||||
|
|
||||||
def test_user_not_created(self):
|
def test_user_not_created(self):
|
||||||
"""Test without property mappings, no group is created"""
|
"""Test without property mappings, no group is created"""
|
||||||
self.provider.property_mappings.clear()
|
self.provider.property_mappings.clear()
|
||||||
|
0
authentik/enterprise/reporting/__init__.py
Normal file
0
authentik/enterprise/reporting/__init__.py
Normal file
0
authentik/enterprise/reporting/api/__init__.py
Normal file
0
authentik/enterprise/reporting/api/__init__.py
Normal file
12
authentik/enterprise/reporting/apps.py
Normal file
12
authentik/enterprise/reporting/apps.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""Reporting app config"""
|
||||||
|
|
||||||
|
from authentik.enterprise.apps import EnterpriseConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikEnterpriseReporting(EnterpriseConfig):
|
||||||
|
"""authentik enterprise reporting app config"""
|
||||||
|
|
||||||
|
name = "authentik.enterprise.reporting"
|
||||||
|
label = "authentik_reporting"
|
||||||
|
verbose_name = "authentik Enterprise.Reporting"
|
||||||
|
default = True
|
22
authentik/enterprise/reporting/executor.py
Normal file
22
authentik/enterprise/reporting/executor.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.enterprise.reporting.models import Report
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecutor:
|
||||||
|
"""Execute a report"""
|
||||||
|
|
||||||
|
def __init__(self, report: Report) -> None:
|
||||||
|
self.report = report
|
||||||
|
self.logger = get_logger().bind(report=self.report)
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
# 1. Run through policies bound to report itself
|
||||||
|
# 2. Get all bound components by running through ReportComponentBinding,
|
||||||
|
# while evaluating policies bound to each
|
||||||
|
# 3. render the actual components
|
||||||
|
# 4. Store the final data...somewhere??
|
||||||
|
# 5. Optionally render PDF via chromedriver (special frontend that uses API)
|
||||||
|
# (not required for MVP)
|
||||||
|
# 6. Send out link to CSV/PDF or attach to email via delivery
|
||||||
|
pass
|
131
authentik/enterprise/reporting/migrations/0001_initial.py
Normal file
131
authentik/enterprise/reporting/migrations/0001_initial.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-04-18 21:47
|
||||||
|
|
||||||
|
import authentik.lib.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
|
||||||
|
("authentik_policies", "0011_policybinding_failure_result_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReportComponent",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"widget_uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Report Component",
|
||||||
|
"verbose_name_plural": "Report Components",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Report",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"policybindingmodel_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="authentik_policies.policybindingmodel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.TextField()),
|
||||||
|
("schedule", models.TextField()),
|
||||||
|
("output_type", models.TextField(choices=[("csv", "Csv"), ("pdf", "Pdf")])),
|
||||||
|
(
|
||||||
|
"delivery",
|
||||||
|
models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
to="authentik_events.notificationtransport",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_as",
|
||||||
|
models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Report",
|
||||||
|
"verbose_name_plural": "Reports",
|
||||||
|
},
|
||||||
|
bases=("authentik_policies.policybindingmodel", models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReportComponentBinding",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"policybindingmodel_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
to="authentik_policies.policybindingmodel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"binding_uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("layout_x", models.PositiveIntegerField(default=0)),
|
||||||
|
("layout_y", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"target",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="authentik_reporting.report"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"widget",
|
||||||
|
authentik.lib.models.InheritanceForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="+",
|
||||||
|
to="authentik_reporting.reportcomponent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Report Component Binding",
|
||||||
|
"verbose_name_plural": "Report Component Bindings",
|
||||||
|
"unique_together": {("target", "widget")},
|
||||||
|
},
|
||||||
|
bases=("authentik_policies.policybindingmodel", models.Model),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="report",
|
||||||
|
name="components",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="bindings",
|
||||||
|
through="authentik_reporting.ReportComponentBinding",
|
||||||
|
to="authentik_reporting.reportcomponent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
87
authentik/enterprise/reporting/models.py
Normal file
87
authentik/enterprise/reporting/models.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Reporting models"""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from authentik.events.models import NotificationTransport
|
||||||
|
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||||
|
from authentik.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
|
|
||||||
|
class OutputType(models.TextChoices):
|
||||||
|
"""Different choices in which a report can be 'rendered'"""
|
||||||
|
|
||||||
|
csv = "csv"
|
||||||
|
pdf = "pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class Report(SerializerModel, PolicyBindingModel):
|
||||||
|
"""A report with a defined list of components, which can run on a schedule"""
|
||||||
|
|
||||||
|
name = models.TextField()
|
||||||
|
|
||||||
|
schedule = models.TextField()
|
||||||
|
|
||||||
|
# User under which permissions the queries are run,
|
||||||
|
# when no user is selected the report is inactive
|
||||||
|
run_as = models.ForeignKey(
|
||||||
|
"authentik_core.user", on_delete=models.SET_DEFAULT, default=None, null=True
|
||||||
|
)
|
||||||
|
components = models.ManyToManyField(
|
||||||
|
"ReportComponent", through="ReportComponentBinding", related_name="bindings", blank=True
|
||||||
|
)
|
||||||
|
output_type = models.TextField(choices=OutputType.choices)
|
||||||
|
# Use notification transport to send report result (either link for webhook based?
|
||||||
|
# maybe send full csv?) or fully rendered PDF via Email
|
||||||
|
# when no transport is selected, reports are not sent anywhere but can be retrieved in authentik
|
||||||
|
delivery = models.ForeignKey(
|
||||||
|
NotificationTransport, on_delete=models.SET_DEFAULT, default=None, null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_celery_schedule(self) -> crontab:
|
||||||
|
return crontab(*self.schedule.split())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Report")
|
||||||
|
verbose_name_plural = _("Reports")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportComponentBinding(SerializerModel, PolicyBindingModel):
|
||||||
|
"""Binding of a component to a report"""
|
||||||
|
|
||||||
|
binding_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
|
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
layout_x = models.PositiveIntegerField(default=0)
|
||||||
|
layout_y = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
target = models.ForeignKey("Report", on_delete=models.CASCADE)
|
||||||
|
widget = InheritanceForeignKey("ReportComponent", on_delete=models.CASCADE, related_name="+")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Binding from {self.report.name} to {self.widget}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Report Component Binding")
|
||||||
|
verbose_name_plural = _("Report Component Bindings")
|
||||||
|
unique_together = ("target", "widget")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportComponent(SerializerModel):
|
||||||
|
"""An individual component of a report, a query or graph, etc"""
|
||||||
|
|
||||||
|
widget_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Report Component")
|
||||||
|
verbose_name_plural = _("Report Components")
|
38
authentik/enterprise/reporting/signals.py
Normal file
38
authentik/enterprise/reporting/signals.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from json import dumps
|
||||||
|
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
||||||
|
|
||||||
|
from authentik.enterprise.reporting.models import Report
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Report)
|
||||||
|
def report_post_save(sender, instance: Report, **_):
|
||||||
|
if instance.schedule == "":
|
||||||
|
return
|
||||||
|
schedule = CrontabSchedule.from_schedule(instance.get_celery_schedule())
|
||||||
|
schedule.save()
|
||||||
|
PeriodicTask.objects.update_or_create(
|
||||||
|
name=str(instance.pk),
|
||||||
|
defaults={
|
||||||
|
"crontab": schedule,
|
||||||
|
"task": "authentik.enterprise.reporting.tasks.process_report",
|
||||||
|
"queue": "authentik_reporting",
|
||||||
|
"description": f"Report {instance.name}",
|
||||||
|
"kwargs": dumps(
|
||||||
|
{
|
||||||
|
"report_uuid": str(instance.pk),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=Report)
|
||||||
|
def report_pre_delete(sender, instance: Report, **_):
|
||||||
|
if instance.schedule == "":
|
||||||
|
return
|
||||||
|
PeriodicTask.objects.filter(name=str(instance.pk)).delete()
|
||||||
|
# Cleanup schedules without any tasks
|
||||||
|
CrontabSchedule.objects.filter(periodictask__isnull=True).delete()
|
11
authentik/enterprise/reporting/tasks.py
Normal file
11
authentik/enterprise/reporting/tasks.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from authentik.enterprise.reporting.executor import ReportExecutor
|
||||||
|
from authentik.enterprise.reporting.models import Report
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task()
|
||||||
|
def process_report(report_uuid: str):
|
||||||
|
report = Report.objects.filter(pk=report_uuid).first()
|
||||||
|
if not report or not report.run_as:
|
||||||
|
return
|
||||||
|
ReportExecutor(report).execute()
|
@ -17,6 +17,7 @@ TENANT_APPS = [
|
|||||||
"authentik.enterprise.providers.google_workspace",
|
"authentik.enterprise.providers.google_workspace",
|
||||||
"authentik.enterprise.providers.microsoft_entra",
|
"authentik.enterprise.providers.microsoft_entra",
|
||||||
"authentik.enterprise.providers.ssf",
|
"authentik.enterprise.providers.ssf",
|
||||||
|
"authentik.enterprise.reporting",
|
||||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||||
"authentik.enterprise.stages.source",
|
"authentik.enterprise.stages.source",
|
||||||
]
|
]
|
||||||
|
@ -89,9 +89,9 @@ class SourceStageFinal(StageView):
|
|||||||
This stage uses the override flow token to resume execution of the initial flow the
|
This stage uses the override flow token to resume execution of the initial flow the
|
||||||
source stage is bound to."""
|
source stage is bound to."""
|
||||||
|
|
||||||
def dispatch(self, *args, **kwargs):
|
def dispatch(self):
|
||||||
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
|
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
|
||||||
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
|
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
|
||||||
plan = token.plan
|
plan = token.plan
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||||
response = plan.to_redirect(self.request, token.flow)
|
response = plan.to_redirect(self.request, token.flow)
|
||||||
|
@ -4,8 +4,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||||
from authentik.enterprise.stages.source.models import SourceStage
|
from authentik.enterprise.stages.source.models import SourceStage
|
||||||
from authentik.enterprise.stages.source.stage import SourceStageFinal
|
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken, in_memory_stage
|
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
|
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
@ -88,7 +87,6 @@ class TestSourceStage(FlowTestCase):
|
|||||||
self.assertIsNotNone(flow_token)
|
self.assertIsNotNone(flow_token)
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||||
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
|
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
@ -98,6 +96,4 @@ class TestSourceStage(FlowTestCase):
|
|||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertStageRedirects(
|
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||||
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
|
||||||
)
|
|
||||||
|
@ -54,7 +54,6 @@ class Challenge(PassiveSerializer):
|
|||||||
|
|
||||||
flow_info = ContextualFlowInfo(required=False)
|
flow_info = ContextualFlowInfo(required=False)
|
||||||
component = CharField(default="")
|
component = CharField(default="")
|
||||||
xid = CharField(required=False)
|
|
||||||
|
|
||||||
response_errors = DictField(
|
response_errors = DictField(
|
||||||
child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
|
child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
|
||||||
|
@ -76,10 +76,10 @@ class FlowPlan:
|
|||||||
self.bindings.append(binding)
|
self.bindings.append(binding)
|
||||||
self.markers.append(marker or StageMarker())
|
self.markers.append(marker or StageMarker())
|
||||||
|
|
||||||
def insert_stage(self, stage: Stage, marker: StageMarker | None = None, index=1):
|
def insert_stage(self, stage: Stage, marker: StageMarker | None = None):
|
||||||
"""Insert stage into plan, as immediate next stage"""
|
"""Insert stage into plan, as immediate next stage"""
|
||||||
self.bindings.insert(index, FlowStageBinding(stage=stage, order=0))
|
self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
|
||||||
self.markers.insert(index, marker or StageMarker())
|
self.markers.insert(1, marker or StageMarker())
|
||||||
|
|
||||||
def redirect(self, destination: str):
|
def redirect(self, destination: str):
|
||||||
"""Insert a redirect stage as next stage"""
|
"""Insert a redirect stage as next stage"""
|
||||||
@ -143,12 +143,10 @@ class FlowPlan:
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
flow: Flow,
|
flow: Flow,
|
||||||
allowed_silent_types: list["StageView"] | None = None,
|
allowed_silent_types: list["StageView"] | None = None,
|
||||||
**get_params,
|
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
"""Redirect to the flow executor for this flow plan"""
|
"""Redirect to the flow executor for this flow plan"""
|
||||||
from authentik.flows.views.executor import (
|
from authentik.flows.views.executor import (
|
||||||
SESSION_KEY_PLAN,
|
SESSION_KEY_PLAN,
|
||||||
FlowContainer,
|
|
||||||
FlowExecutorView,
|
FlowExecutorView,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -159,7 +157,6 @@ class FlowPlan:
|
|||||||
# No unskippable stages found, so we can directly return the response of the last stage
|
# No unskippable stages found, so we can directly return the response of the last stage
|
||||||
final_stage: type[StageView] = self.bindings[-1].stage.view
|
final_stage: type[StageView] = self.bindings[-1].stage.view
|
||||||
temp_exec = FlowExecutorView(flow=flow, request=request, plan=self)
|
temp_exec = FlowExecutorView(flow=flow, request=request, plan=self)
|
||||||
temp_exec.container = FlowContainer(request)
|
|
||||||
temp_exec.current_stage = self.bindings[-1].stage
|
temp_exec.current_stage = self.bindings[-1].stage
|
||||||
temp_exec.current_stage_view = final_stage
|
temp_exec.current_stage_view = final_stage
|
||||||
temp_exec.setup(request, flow.slug)
|
temp_exec.setup(request, flow.slug)
|
||||||
@ -177,9 +174,6 @@ class FlowPlan:
|
|||||||
):
|
):
|
||||||
get_qs["inspector"] = "available"
|
get_qs["inspector"] = "available"
|
||||||
|
|
||||||
for key, value in get_params:
|
|
||||||
get_qs[key] = value
|
|
||||||
|
|
||||||
return redirect_with_qs(
|
return redirect_with_qs(
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
get_qs,
|
get_qs,
|
||||||
|
@ -191,7 +191,6 @@ class ChallengeStageView(StageView):
|
|||||||
)
|
)
|
||||||
flow_info.is_valid()
|
flow_info.is_valid()
|
||||||
challenge.initial_data["flow_info"] = flow_info.data
|
challenge.initial_data["flow_info"] = flow_info.data
|
||||||
challenge.initial_data["xid"] = self.executor.container.exec_id
|
|
||||||
if isinstance(challenge, WithUserInfoChallenge):
|
if isinstance(challenge, WithUserInfoChallenge):
|
||||||
# If there's a pending user, update the `username` field
|
# If there's a pending user, update the `username` field
|
||||||
# this field is only used by password managers.
|
# this field is only used by password managers.
|
||||||
|
@ -28,7 +28,7 @@ window.authentik.flow = {
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<ak-message-container></ak-message-container>
|
<ak-message-container></ak-message-container>
|
||||||
<ak-flow-executor flowSlug="{{ flow.slug }}" xid="{{ xid }}">
|
<ak-flow-executor flowSlug="{{ flow.slug }}">
|
||||||
<ak-loading></ak-loading>
|
<ak-loading></ak-loading>
|
||||||
</ak-flow-executor>
|
</ak-flow-executor>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"""authentik multi-stage authentication engine"""
|
"""authentik multi-stage authentication engine"""
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@ -65,7 +64,6 @@ from authentik.policies.engine import PolicyEngine
|
|||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
# Argument used to redirect user after login
|
# Argument used to redirect user after login
|
||||||
NEXT_ARG_NAME = "next"
|
NEXT_ARG_NAME = "next"
|
||||||
SESSION_KEY_PLAN_CONTAINER = "authentik/flows/plan_container/%s"
|
|
||||||
SESSION_KEY_PLAN = "authentik/flows/plan"
|
SESSION_KEY_PLAN = "authentik/flows/plan"
|
||||||
SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
||||||
SESSION_KEY_GET = "authentik/flows/get"
|
SESSION_KEY_GET = "authentik/flows/get"
|
||||||
@ -73,7 +71,6 @@ SESSION_KEY_POST = "authentik/flows/post"
|
|||||||
SESSION_KEY_HISTORY = "authentik/flows/history"
|
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||||
QS_KEY_TOKEN = "flow_token" # nosec
|
QS_KEY_TOKEN = "flow_token" # nosec
|
||||||
QS_QUERY = "query"
|
QS_QUERY = "query"
|
||||||
QS_EXEC_ID = "xid"
|
|
||||||
|
|
||||||
|
|
||||||
def challenge_types():
|
def challenge_types():
|
||||||
@ -100,88 +97,6 @@ class InvalidStageError(SentryIgnoredException):
|
|||||||
"""Error raised when a challenge from a stage is not valid"""
|
"""Error raised when a challenge from a stage is not valid"""
|
||||||
|
|
||||||
|
|
||||||
class FlowContainer:
|
|
||||||
"""Allow for multiple concurrent flow executions in the same session"""
|
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest, exec_id: str | None = None) -> None:
|
|
||||||
self.request = request
|
|
||||||
self.exec_id = exec_id
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def new(request: HttpRequest):
|
|
||||||
exec_id = str(uuid4())
|
|
||||||
request.session[SESSION_KEY_PLAN_CONTAINER % exec_id] = {}
|
|
||||||
return FlowContainer(request, exec_id)
|
|
||||||
|
|
||||||
def exists(self) -> bool:
|
|
||||||
"""Check if flow exists in container/session"""
|
|
||||||
return SESSION_KEY_PLAN in self.session
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
self.request.session.modified = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session(self):
|
|
||||||
# Backwards compatibility: store session plan/etc directly in session
|
|
||||||
if not self.exec_id:
|
|
||||||
return self.request.session
|
|
||||||
self.request.session.setdefault(SESSION_KEY_PLAN_CONTAINER % self.exec_id, {})
|
|
||||||
return self.request.session.get(SESSION_KEY_PLAN_CONTAINER % self.exec_id, {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plan(self) -> FlowPlan:
|
|
||||||
return self.session.get(SESSION_KEY_PLAN)
|
|
||||||
|
|
||||||
def to_redirect(
|
|
||||||
self,
|
|
||||||
request: HttpRequest,
|
|
||||||
flow: Flow,
|
|
||||||
allowed_silent_types: list[StageView] | None = None,
|
|
||||||
**get_params,
|
|
||||||
) -> HttpResponse:
|
|
||||||
get_params[QS_EXEC_ID] = self.exec_id
|
|
||||||
return self.plan.to_redirect(
|
|
||||||
request, flow, allowed_silent_types=allowed_silent_types, **get_params
|
|
||||||
)
|
|
||||||
|
|
||||||
@plan.setter
|
|
||||||
def plan(self, value: FlowPlan):
|
|
||||||
self.session[SESSION_KEY_PLAN] = value
|
|
||||||
self.request.session.modified = True
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def application_pre(self):
|
|
||||||
return self.session.get(SESSION_KEY_APPLICATION_PRE)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get(self) -> QueryDict:
|
|
||||||
return self.session.get(SESSION_KEY_GET)
|
|
||||||
|
|
||||||
@get.setter
|
|
||||||
def get(self, value: QueryDict):
|
|
||||||
self.session[SESSION_KEY_GET] = value
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def post(self) -> QueryDict:
|
|
||||||
return self.session.get(SESSION_KEY_POST)
|
|
||||||
|
|
||||||
@post.setter
|
|
||||||
def post(self, value: QueryDict):
|
|
||||||
self.session[SESSION_KEY_POST] = value
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def history(self) -> list[FlowPlan]:
|
|
||||||
return self.session.get(SESSION_KEY_HISTORY)
|
|
||||||
|
|
||||||
@history.setter
|
|
||||||
def history(self, value: list[FlowPlan]):
|
|
||||||
self.session[SESSION_KEY_HISTORY] = value
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||||
class FlowExecutorView(APIView):
|
class FlowExecutorView(APIView):
|
||||||
"""Flow executor, passing requests to Stage Views"""
|
"""Flow executor, passing requests to Stage Views"""
|
||||||
@ -189,9 +104,8 @@ class FlowExecutorView(APIView):
|
|||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
flow: Flow = None
|
flow: Flow = None
|
||||||
plan: FlowPlan | None = None
|
|
||||||
container: FlowContainer
|
|
||||||
|
|
||||||
|
plan: FlowPlan | None = None
|
||||||
current_binding: FlowStageBinding | None = None
|
current_binding: FlowStageBinding | None = None
|
||||||
current_stage: Stage
|
current_stage: Stage
|
||||||
current_stage_view: View
|
current_stage_view: View
|
||||||
@ -246,12 +160,10 @@ class FlowExecutorView(APIView):
|
|||||||
if QS_KEY_TOKEN in get_params:
|
if QS_KEY_TOKEN in get_params:
|
||||||
plan = self._check_flow_token(get_params[QS_KEY_TOKEN])
|
plan = self._check_flow_token(get_params[QS_KEY_TOKEN])
|
||||||
if plan:
|
if plan:
|
||||||
container = FlowContainer.new(request)
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
container.plan = plan
|
|
||||||
# Early check if there's an active Plan for the current session
|
# Early check if there's an active Plan for the current session
|
||||||
self.container = FlowContainer(request, request.GET.get(QS_EXEC_ID))
|
if SESSION_KEY_PLAN in self.request.session:
|
||||||
if self.container.exists():
|
self.plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||||
self.plan: FlowPlan = self.container.plan
|
|
||||||
if self.plan.flow_pk != self.flow.pk.hex:
|
if self.plan.flow_pk != self.flow.pk.hex:
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"f(exec): Found existing plan for other flow, deleting plan",
|
"f(exec): Found existing plan for other flow, deleting plan",
|
||||||
@ -264,14 +176,13 @@ class FlowExecutorView(APIView):
|
|||||||
self._logger.debug("f(exec): Continuing existing plan")
|
self._logger.debug("f(exec): Continuing existing plan")
|
||||||
|
|
||||||
# Initial flow request, check if we have an upstream query string passed in
|
# Initial flow request, check if we have an upstream query string passed in
|
||||||
self.container.get = get_params
|
request.session[SESSION_KEY_GET] = get_params
|
||||||
# Don't check session again as we've either already loaded the plan or we need to plan
|
# Don't check session again as we've either already loaded the plan or we need to plan
|
||||||
if not self.plan:
|
if not self.plan:
|
||||||
self.container.history = []
|
request.session[SESSION_KEY_HISTORY] = []
|
||||||
self._logger.debug("f(exec): No active Plan found, initiating planner")
|
self._logger.debug("f(exec): No active Plan found, initiating planner")
|
||||||
try:
|
try:
|
||||||
self.plan = self._initiate_plan()
|
self.plan = self._initiate_plan()
|
||||||
self.container.plan = self.plan
|
|
||||||
except FlowNonApplicableException as exc:
|
except FlowNonApplicableException as exc:
|
||||||
self._logger.warning("f(exec): Flow not applicable to current user", exc=exc)
|
self._logger.warning("f(exec): Flow not applicable to current user", exc=exc)
|
||||||
return self.handle_invalid_flow(exc)
|
return self.handle_invalid_flow(exc)
|
||||||
@ -343,19 +254,12 @@ class FlowExecutorView(APIView):
|
|||||||
request=OpenApiTypes.NONE,
|
request=OpenApiTypes.NONE,
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name=QS_QUERY,
|
name="query",
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
required=True,
|
required=True,
|
||||||
description="Querystring as received",
|
description="Querystring as received",
|
||||||
type=OpenApiTypes.STR,
|
type=OpenApiTypes.STR,
|
||||||
),
|
)
|
||||||
OpenApiParameter(
|
|
||||||
name=QS_EXEC_ID,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=False,
|
|
||||||
description="Flow execution ID",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
operation_id="flows_executor_get",
|
operation_id="flows_executor_get",
|
||||||
)
|
)
|
||||||
@ -382,7 +286,7 @@ class FlowExecutorView(APIView):
|
|||||||
span.set_data("authentik Stage", self.current_stage_view)
|
span.set_data("authentik Stage", self.current_stage_view)
|
||||||
span.set_data("authentik Flow", self.flow.slug)
|
span.set_data("authentik Flow", self.flow.slug)
|
||||||
stage_response = self.current_stage_view.dispatch(request)
|
stage_response = self.current_stage_view.dispatch(request)
|
||||||
return to_stage_response(request, stage_response, self.container.exec_id)
|
return to_stage_response(request, stage_response)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return self.handle_exception(exc)
|
return self.handle_exception(exc)
|
||||||
|
|
||||||
@ -401,19 +305,12 @@ class FlowExecutorView(APIView):
|
|||||||
),
|
),
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name=QS_QUERY,
|
name="query",
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
required=True,
|
required=True,
|
||||||
description="Querystring as received",
|
description="Querystring as received",
|
||||||
type=OpenApiTypes.STR,
|
type=OpenApiTypes.STR,
|
||||||
),
|
)
|
||||||
OpenApiParameter(
|
|
||||||
name=QS_EXEC_ID,
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
required=True,
|
|
||||||
description="Flow execution ID",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
operation_id="flows_executor_solve",
|
operation_id="flows_executor_solve",
|
||||||
)
|
)
|
||||||
@ -440,15 +337,14 @@ class FlowExecutorView(APIView):
|
|||||||
span.set_data("authentik Stage", self.current_stage_view)
|
span.set_data("authentik Stage", self.current_stage_view)
|
||||||
span.set_data("authentik Flow", self.flow.slug)
|
span.set_data("authentik Flow", self.flow.slug)
|
||||||
stage_response = self.current_stage_view.dispatch(request)
|
stage_response = self.current_stage_view.dispatch(request)
|
||||||
return to_stage_response(request, stage_response, self.container.exec_id)
|
return to_stage_response(request, stage_response)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return self.handle_exception(exc)
|
return self.handle_exception(exc)
|
||||||
|
|
||||||
def _initiate_plan(self) -> FlowPlan:
|
def _initiate_plan(self) -> FlowPlan:
|
||||||
planner = FlowPlanner(self.flow)
|
planner = FlowPlanner(self.flow)
|
||||||
plan = planner.plan(self.request)
|
plan = planner.plan(self.request)
|
||||||
container = FlowContainer.new(self.request)
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
container.plan = plan
|
|
||||||
try:
|
try:
|
||||||
# Call the has_stages getter to check that
|
# Call the has_stages getter to check that
|
||||||
# there are no issues with the class we might've gotten
|
# there are no issues with the class we might've gotten
|
||||||
@ -472,7 +368,7 @@ class FlowExecutorView(APIView):
|
|||||||
except FlowNonApplicableException as exc:
|
except FlowNonApplicableException as exc:
|
||||||
self._logger.warning("f(exec): Flow restart not applicable to current user", exc=exc)
|
self._logger.warning("f(exec): Flow restart not applicable to current user", exc=exc)
|
||||||
return self.handle_invalid_flow(exc)
|
return self.handle_invalid_flow(exc)
|
||||||
self.container.plan = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
kwargs = self.kwargs
|
kwargs = self.kwargs
|
||||||
kwargs.update({"flow_slug": self.flow.slug})
|
kwargs.update({"flow_slug": self.flow.slug})
|
||||||
return redirect_with_qs("authentik_api:flow-executor", self.request.GET, **kwargs)
|
return redirect_with_qs("authentik_api:flow-executor", self.request.GET, **kwargs)
|
||||||
@ -494,13 +390,9 @@ class FlowExecutorView(APIView):
|
|||||||
)
|
)
|
||||||
self.cancel()
|
self.cancel()
|
||||||
if next_param and not is_url_absolute(next_param):
|
if next_param and not is_url_absolute(next_param):
|
||||||
return to_stage_response(
|
return to_stage_response(self.request, redirect_with_qs(next_param))
|
||||||
self.request, redirect_with_qs(next_param), self.container.exec_id
|
|
||||||
)
|
|
||||||
return to_stage_response(
|
return to_stage_response(
|
||||||
self.request,
|
self.request, self.stage_invalid(error_message=_("Invalid next URL"))
|
||||||
self.stage_invalid(error_message=_("Invalid next URL")),
|
|
||||||
self.container.exec_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def stage_ok(self) -> HttpResponse:
|
def stage_ok(self) -> HttpResponse:
|
||||||
@ -514,7 +406,7 @@ class FlowExecutorView(APIView):
|
|||||||
self.current_stage_view.cleanup()
|
self.current_stage_view.cleanup()
|
||||||
self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan))
|
self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan))
|
||||||
self.plan.pop()
|
self.plan.pop()
|
||||||
self.container.plan = self.plan
|
self.request.session[SESSION_KEY_PLAN] = self.plan
|
||||||
if self.plan.bindings:
|
if self.plan.bindings:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
"f(exec): Continuing with next stage",
|
"f(exec): Continuing with next stage",
|
||||||
@ -557,7 +449,6 @@ class FlowExecutorView(APIView):
|
|||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""Cancel current flow execution"""
|
"""Cancel current flow execution"""
|
||||||
# TODO: Clean up container
|
|
||||||
keys_to_delete = [
|
keys_to_delete = [
|
||||||
SESSION_KEY_APPLICATION_PRE,
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
SESSION_KEY_PLAN,
|
SESSION_KEY_PLAN,
|
||||||
@ -580,8 +471,8 @@ class CancelView(View):
|
|||||||
|
|
||||||
def get(self, request: HttpRequest) -> HttpResponse:
|
def get(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""View which canels the currently active plan"""
|
"""View which canels the currently active plan"""
|
||||||
if FlowContainer(request, request.GET.get(QS_EXEC_ID)).exists():
|
if SESSION_KEY_PLAN in request.session:
|
||||||
del request.session[SESSION_KEY_PLAN_CONTAINER % request.GET.get(QS_EXEC_ID)]
|
del request.session[SESSION_KEY_PLAN]
|
||||||
LOGGER.debug("Canceled current plan")
|
LOGGER.debug("Canceled current plan")
|
||||||
return redirect("authentik_flows:default-invalidation")
|
return redirect("authentik_flows:default-invalidation")
|
||||||
|
|
||||||
@ -629,12 +520,19 @@ class ToDefaultFlow(View):
|
|||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||||
flow = self.get_flow()
|
flow = self.get_flow()
|
||||||
get_qs = request.GET.copy()
|
# If user already has a pending plan, clear it so we don't have to later.
|
||||||
get_qs[QS_EXEC_ID] = str(uuid4())
|
if SESSION_KEY_PLAN in self.request.session:
|
||||||
return redirect_with_qs("authentik_core:if-flow", get_qs, flow_slug=flow.slug)
|
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||||
|
if plan.flow_pk != flow.pk.hex:
|
||||||
|
LOGGER.warning(
|
||||||
|
"f(def): Found existing plan for other flow, deleting plan",
|
||||||
|
flow_slug=flow.slug,
|
||||||
|
)
|
||||||
|
del self.request.session[SESSION_KEY_PLAN]
|
||||||
|
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
|
||||||
|
|
||||||
|
|
||||||
def to_stage_response(request: HttpRequest, source: HttpResponse, xid: str) -> HttpResponse:
|
def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
|
||||||
"""Convert normal HttpResponse into JSON Response"""
|
"""Convert normal HttpResponse into JSON Response"""
|
||||||
if (
|
if (
|
||||||
isinstance(source, HttpResponseRedirect)
|
isinstance(source, HttpResponseRedirect)
|
||||||
@ -653,7 +551,6 @@ def to_stage_response(request: HttpRequest, source: HttpResponse, xid: str) -> H
|
|||||||
RedirectChallenge(
|
RedirectChallenge(
|
||||||
{
|
{
|
||||||
"to": str(redirect_url),
|
"to": str(redirect_url),
|
||||||
"xid": xid,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -662,7 +559,6 @@ def to_stage_response(request: HttpRequest, source: HttpResponse, xid: str) -> H
|
|||||||
ShellChallenge(
|
ShellChallenge(
|
||||||
{
|
{
|
||||||
"body": source.render().content.decode("utf-8"),
|
"body": source.render().content.decode("utf-8"),
|
||||||
"xid": xid,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -672,7 +568,6 @@ def to_stage_response(request: HttpRequest, source: HttpResponse, xid: str) -> H
|
|||||||
ShellChallenge(
|
ShellChallenge(
|
||||||
{
|
{
|
||||||
"body": source.content.decode("utf-8"),
|
"body": source.content.decode("utf-8"),
|
||||||
"xid": xid,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -704,6 +599,4 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
|
|||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
LOGGER.warning("Flow not applicable to user")
|
LOGGER.warning("Flow not applicable to user")
|
||||||
raise Http404 from None
|
raise Http404 from None
|
||||||
container = FlowContainer.new(request)
|
return plan.to_redirect(request, stage.configure_flow)
|
||||||
container.plan = plan
|
|
||||||
return container.to_redirect(request, stage.configure_flow)
|
|
||||||
|
@ -7,7 +7,6 @@ from ua_parser.user_agent_parser import Parse
|
|||||||
|
|
||||||
from authentik.core.views.interface import InterfaceView
|
from authentik.core.views.interface import InterfaceView
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.flows.views.executor import QS_EXEC_ID
|
|
||||||
|
|
||||||
|
|
||||||
class FlowInterfaceView(InterfaceView):
|
class FlowInterfaceView(InterfaceView):
|
||||||
@ -16,7 +15,6 @@ class FlowInterfaceView(InterfaceView):
|
|||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||||
kwargs["inspector"] = "inspector" in self.request.GET
|
kwargs["inspector"] = "inspector" in self.request.GET
|
||||||
kwargs["xid"] = self.request.GET.get(QS_EXEC_ID)
|
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def compat_needs_sfe(self) -> bool:
|
def compat_needs_sfe(self) -> bool:
|
||||||
|
@ -282,14 +282,16 @@ class ConfigLoader:
|
|||||||
|
|
||||||
def get_optional_int(self, path: str, default=None) -> int | None:
|
def get_optional_int(self, path: str, default=None) -> int | None:
|
||||||
"""Wrapper for get that converts value into int or None if set"""
|
"""Wrapper for get that converts value into int or None if set"""
|
||||||
value = self.get(path, UNSET)
|
value = self.get(path, default)
|
||||||
if value is UNSET:
|
if value is UNSET:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
if value is None or (isinstance(value, str) and value.lower() == "null"):
|
if value is None or (isinstance(value, str) and value.lower() == "null"):
|
||||||
return None
|
return default
|
||||||
|
if value is UNSET:
|
||||||
|
return default
|
||||||
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
|
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@ -370,9 +372,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
|
|||||||
"sslcert": config.get("postgresql.sslcert"),
|
"sslcert": config.get("postgresql.sslcert"),
|
||||||
"sslkey": config.get("postgresql.sslkey"),
|
"sslkey": config.get("postgresql.sslkey"),
|
||||||
},
|
},
|
||||||
"CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0),
|
"CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0),
|
||||||
"CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False),
|
"CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False),
|
||||||
"DISABLE_SERVER_SIDE_CURSORS": config.get_bool(
|
"DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool(
|
||||||
"postgresql.disable_server_side_cursors", False
|
"postgresql.disable_server_side_cursors", False
|
||||||
),
|
),
|
||||||
"TEST": {
|
"TEST": {
|
||||||
@ -381,8 +383,8 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn_max_age = config.get_optional_int("postgresql.conn_max_age", UNSET)
|
conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET)
|
||||||
disable_server_side_cursors = config.get_bool("postgresql.disable_server_side_cursors", UNSET)
|
disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET)
|
||||||
if config.get_bool("postgresql.use_pgpool", False):
|
if config.get_bool("postgresql.use_pgpool", False):
|
||||||
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||||
if disable_server_side_cursors is not UNSET:
|
if disable_server_side_cursors is not UNSET:
|
||||||
|
@ -33,7 +33,6 @@ class SyncObjectSerializer(PassiveSerializer):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
sync_object_id = CharField()
|
sync_object_id = CharField()
|
||||||
override_dry_run = BooleanField(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncObjectResultSerializer(PassiveSerializer):
|
class SyncObjectResultSerializer(PassiveSerializer):
|
||||||
@ -99,7 +98,6 @@ class OutgoingSyncProviderStatusMixin:
|
|||||||
page=1,
|
page=1,
|
||||||
provider_pk=provider.pk,
|
provider_pk=provider.pk,
|
||||||
pk=params.validated_data["sync_object_id"],
|
pk=params.validated_data["sync_object_id"],
|
||||||
override_dry_run=params.validated_data["override_dry_run"],
|
|
||||||
).get()
|
).get()
|
||||||
return Response(SyncObjectResultSerializer(instance={"messages": res}).data)
|
return Response(SyncObjectResultSerializer(instance={"messages": res}).data)
|
||||||
|
|
||||||
|
@ -28,14 +28,6 @@ class Direction(StrEnum):
|
|||||||
remove = "remove"
|
remove = "remove"
|
||||||
|
|
||||||
|
|
||||||
SAFE_METHODS = [
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS",
|
|
||||||
"TRACE",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOutgoingSyncClient[
|
class BaseOutgoingSyncClient[
|
||||||
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
|
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
|
||||||
]:
|
]:
|
||||||
|
@ -21,22 +21,6 @@ class BadRequestSyncException(BaseSyncException):
|
|||||||
"""Exception when invalid data was sent to the remote system"""
|
"""Exception when invalid data was sent to the remote system"""
|
||||||
|
|
||||||
|
|
||||||
class DryRunRejected(BaseSyncException):
|
|
||||||
"""When dry_run is enabled and a provider dropped a mutating request"""
|
|
||||||
|
|
||||||
def __init__(self, url: str, method: str, body: dict):
|
|
||||||
super().__init__()
|
|
||||||
self.url = url
|
|
||||||
self.method = method
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.__str__()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Dry-run rejected request: {self.method} {self.url}"
|
|
||||||
|
|
||||||
|
|
||||||
class StopSync(BaseSyncException):
|
class StopSync(BaseSyncException):
|
||||||
"""Exception raised when a configuration error should stop the sync process"""
|
"""Exception raised when a configuration error should stop the sync process"""
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
|
|
||||||
import pglock
|
import pglock
|
||||||
from django.db import connection, models
|
from django.db import connection
|
||||||
from django.db.models import Model, QuerySet, TextChoices
|
from django.db.models import Model, QuerySet, TextChoices
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from authentik.core.models import Group, User
|
from authentik.core.models import Group, User
|
||||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||||
@ -19,14 +18,6 @@ class OutgoingSyncDeleteAction(TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class OutgoingSyncProvider(Model):
|
class OutgoingSyncProvider(Model):
|
||||||
"""Base abstract models for providers implementing outgoing sync"""
|
|
||||||
|
|
||||||
dry_run = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
"When enabled, provider will not modify or create objects in the remote system."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@ -41,7 +32,7 @@ class OutgoingSyncProvider(Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def sync_lock(self) -> pglock.advisory:
|
def sync_lock(self) -> pglock.advisory:
|
||||||
"""Postgres lock for syncing to prevent multiple parallel syncs happening"""
|
"""Postgres lock for syncing SCIM to prevent multiple parallel syncs happening"""
|
||||||
return pglock.advisory(
|
return pglock.advisory(
|
||||||
lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}",
|
lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}",
|
||||||
timeout=0,
|
timeout=0,
|
||||||
|
@ -20,7 +20,6 @@ from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT
|
|||||||
from authentik.lib.sync.outgoing.base import Direction
|
from authentik.lib.sync.outgoing.base import Direction
|
||||||
from authentik.lib.sync.outgoing.exceptions import (
|
from authentik.lib.sync.outgoing.exceptions import (
|
||||||
BadRequestSyncException,
|
BadRequestSyncException,
|
||||||
DryRunRejected,
|
|
||||||
StopSync,
|
StopSync,
|
||||||
TransientSyncException,
|
TransientSyncException,
|
||||||
)
|
)
|
||||||
@ -106,9 +105,7 @@ class SyncTasks:
|
|||||||
return
|
return
|
||||||
task.set_status(TaskStatus.SUCCESSFUL, *messages)
|
task.set_status(TaskStatus.SUCCESSFUL, *messages)
|
||||||
|
|
||||||
def sync_objects(
|
def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter):
|
||||||
self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter
|
|
||||||
):
|
|
||||||
_object_type = path_to_class(object_type)
|
_object_type = path_to_class(object_type)
|
||||||
self.logger = get_logger().bind(
|
self.logger = get_logger().bind(
|
||||||
provider_type=class_to_path(self._provider_model),
|
provider_type=class_to_path(self._provider_model),
|
||||||
@ -119,10 +116,6 @@ class SyncTasks:
|
|||||||
provider = self._provider_model.objects.filter(pk=provider_pk).first()
|
provider = self._provider_model.objects.filter(pk=provider_pk).first()
|
||||||
if not provider:
|
if not provider:
|
||||||
return messages
|
return messages
|
||||||
# Override dry run mode if requested, however don't save the provider
|
|
||||||
# so that scheduled sync tasks still run in dry_run mode
|
|
||||||
if override_dry_run:
|
|
||||||
provider.dry_run = False
|
|
||||||
try:
|
try:
|
||||||
client = provider.client_for_model(_object_type)
|
client = provider.client_for_model(_object_type)
|
||||||
except TransientSyncException:
|
except TransientSyncException:
|
||||||
@ -139,22 +132,6 @@ class SyncTasks:
|
|||||||
except SkipObjectException:
|
except SkipObjectException:
|
||||||
self.logger.debug("skipping object due to SkipObject", obj=obj)
|
self.logger.debug("skipping object due to SkipObject", obj=obj)
|
||||||
continue
|
continue
|
||||||
except DryRunRejected as exc:
|
|
||||||
messages.append(
|
|
||||||
asdict(
|
|
||||||
LogEvent(
|
|
||||||
_("Dropping mutating request due to dry run"),
|
|
||||||
log_level="info",
|
|
||||||
logger=f"{provider._meta.verbose_name}@{object_type}",
|
|
||||||
attributes={
|
|
||||||
"obj": sanitize_item(obj),
|
|
||||||
"method": exc.method,
|
|
||||||
"url": exc.url,
|
|
||||||
"body": exc.body,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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)
|
||||||
messages.append(
|
messages.append(
|
||||||
@ -254,10 +231,8 @@ class SyncTasks:
|
|||||||
raise Retry() from exc
|
raise Retry() from exc
|
||||||
except SkipObjectException:
|
except SkipObjectException:
|
||||||
continue
|
continue
|
||||||
except DryRunRejected as exc:
|
|
||||||
self.logger.info("Rejected dry-run event", exc=exc)
|
|
||||||
except StopSync as exc:
|
except StopSync as exc:
|
||||||
self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk)
|
self.logger.warning(exc, provider_pk=provider.pk)
|
||||||
|
|
||||||
def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]):
|
def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]):
|
||||||
self.logger = get_logger().bind(
|
self.logger = get_logger().bind(
|
||||||
@ -288,7 +263,5 @@ class SyncTasks:
|
|||||||
raise Retry() from exc
|
raise Retry() from exc
|
||||||
except SkipObjectException:
|
except SkipObjectException:
|
||||||
continue
|
continue
|
||||||
except DryRunRejected as exc:
|
|
||||||
self.logger.info("Rejected dry-run event", exc=exc)
|
|
||||||
except StopSync as exc:
|
except StopSync as exc:
|
||||||
self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk)
|
self.logger.warning(exc, provider_pk=provider.pk)
|
||||||
|
@ -158,18 +158,6 @@ class TestConfig(TestCase):
|
|||||||
test_obj = Test()
|
test_obj = Test()
|
||||||
dumps(test_obj, indent=4, cls=AttrEncoder)
|
dumps(test_obj, indent=4, cls=AttrEncoder)
|
||||||
|
|
||||||
def test_get_optional_int(self):
|
|
||||||
config = ConfigLoader()
|
|
||||||
self.assertEqual(config.get_optional_int("foo", 21), 21)
|
|
||||||
self.assertEqual(config.get_optional_int("foo"), None)
|
|
||||||
config.set("foo", "21")
|
|
||||||
self.assertEqual(config.get_optional_int("foo"), 21)
|
|
||||||
self.assertEqual(config.get_optional_int("foo", 0), 21)
|
|
||||||
self.assertEqual(config.get_optional_int("foo", "null"), 21)
|
|
||||||
config.set("foo", "null")
|
|
||||||
self.assertEqual(config.get_optional_int("foo"), None)
|
|
||||||
self.assertEqual(config.get_optional_int("foo", 21), None)
|
|
||||||
|
|
||||||
@mock.patch.dict(environ, check_deprecations_env_vars)
|
@mock.patch.dict(environ, check_deprecations_env_vars)
|
||||||
def test_check_deprecations(self):
|
def test_check_deprecations(self):
|
||||||
"""Test config key re-write for deprecated env vars"""
|
"""Test config key re-write for deprecated env vars"""
|
||||||
@ -233,16 +221,6 @@ class TestConfig(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_db_conn_max_age(self):
|
|
||||||
"""Test DB conn_max_age Config"""
|
|
||||||
config = ConfigLoader()
|
|
||||||
config.set("postgresql.conn_max_age", "null")
|
|
||||||
conf = django_db_config(config)
|
|
||||||
self.assertEqual(
|
|
||||||
conf["default"]["CONN_MAX_AGE"],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_db_read_replicas(self):
|
def test_db_read_replicas(self):
|
||||||
"""Test read replicas"""
|
"""Test read replicas"""
|
||||||
config = ConfigLoader()
|
config = ConfigLoader()
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"""Base Kubernetes Reconciler"""
|
"""Base Kubernetes Reconciler"""
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||||
@ -68,8 +67,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
|||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""Get the name of the object this reconciler manages"""
|
"""Get the name of the object this reconciler manages"""
|
||||||
|
return (
|
||||||
base_name = (
|
|
||||||
self.controller.outpost.config.object_naming_template
|
self.controller.outpost.config.object_naming_template
|
||||||
% {
|
% {
|
||||||
"name": slugify(self.controller.outpost.name),
|
"name": slugify(self.controller.outpost.name),
|
||||||
@ -77,16 +75,6 @@ class KubernetesObjectReconciler(Generic[T]):
|
|||||||
}
|
}
|
||||||
).lower()
|
).lower()
|
||||||
|
|
||||||
formatted = slugify(base_name)
|
|
||||||
formatted = re.sub(r"[^a-z0-9-]", "-", formatted)
|
|
||||||
formatted = re.sub(r"-+", "-", formatted)
|
|
||||||
formatted = formatted[:63]
|
|
||||||
|
|
||||||
if not formatted:
|
|
||||||
formatted = f"outpost-{self.controller.outpost.uuid.hex}"[:63]
|
|
||||||
|
|
||||||
return formatted
|
|
||||||
|
|
||||||
def get_patched_reference_object(self) -> T:
|
def get_patched_reference_object(self) -> T:
|
||||||
"""Get patched reference object"""
|
"""Get patched reference object"""
|
||||||
reference = self.get_reference_object()
|
reference = self.get_reference_object()
|
||||||
@ -124,6 +112,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
|||||||
try:
|
try:
|
||||||
current = self.retrieve()
|
current = self.retrieve()
|
||||||
except (OpenApiException, HTTPError) as exc:
|
except (OpenApiException, HTTPError) as exc:
|
||||||
|
|
||||||
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
||||||
self.logger.debug("Failed to get current, triggering recreate")
|
self.logger.debug("Failed to get current, triggering recreate")
|
||||||
raise NeedsRecreate from exc
|
raise NeedsRecreate from exc
|
||||||
@ -167,6 +156,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
|||||||
self.delete(current)
|
self.delete(current)
|
||||||
self.logger.debug("Removing")
|
self.logger.debug("Removing")
|
||||||
except (OpenApiException, HTTPError) as exc:
|
except (OpenApiException, HTTPError) as exc:
|
||||||
|
|
||||||
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
||||||
self.logger.debug("Failed to get current, assuming non-existent")
|
self.logger.debug("Failed to get current, assuming non-existent")
|
||||||
return
|
return
|
||||||
|
@ -61,14 +61,9 @@ class KubernetesController(BaseController):
|
|||||||
client: KubernetesClient
|
client: KubernetesClient
|
||||||
connection: KubernetesServiceConnection
|
connection: KubernetesServiceConnection
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None:
|
||||||
self,
|
|
||||||
outpost: Outpost,
|
|
||||||
connection: KubernetesServiceConnection,
|
|
||||||
client: KubernetesClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(outpost, connection)
|
super().__init__(outpost, connection)
|
||||||
self.client = client if client else KubernetesClient(connection)
|
self.client = KubernetesClient(connection)
|
||||||
self.reconcilers = {
|
self.reconcilers = {
|
||||||
SecretReconciler.reconciler_name(): SecretReconciler,
|
SecretReconciler.reconciler_name(): SecretReconciler,
|
||||||
DeploymentReconciler.reconciler_name(): DeploymentReconciler,
|
DeploymentReconciler.reconciler_name(): DeploymentReconciler,
|
||||||
|
@ -1,44 +0,0 @@
|
|||||||
"""Kubernetes controller tests"""
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from authentik.blueprints.tests import reconcile_app
|
|
||||||
from authentik.lib.generators import generate_id
|
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
|
||||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
|
||||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
|
||||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost, OutpostType
|
|
||||||
|
|
||||||
|
|
||||||
class KubernetesControllerTests(TestCase):
|
|
||||||
"""Kubernetes controller tests"""
|
|
||||||
|
|
||||||
@reconcile_app("authentik_outposts")
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.outpost = Outpost.objects.create(
|
|
||||||
name="test",
|
|
||||||
type=OutpostType.PROXY,
|
|
||||||
)
|
|
||||||
self.integration = KubernetesServiceConnection(name="test")
|
|
||||||
|
|
||||||
def test_gen_name(self):
|
|
||||||
"""Ensure the generated name is valid"""
|
|
||||||
controller = KubernetesController(
|
|
||||||
Outpost.objects.filter(managed=MANAGED_OUTPOST).first(),
|
|
||||||
self.integration,
|
|
||||||
# Pass something not-none as client so we don't
|
|
||||||
# attempt to connect to K8s as that's not needed
|
|
||||||
client=self,
|
|
||||||
)
|
|
||||||
rec = DeploymentReconciler(controller)
|
|
||||||
self.assertEqual(rec.name, "ak-outpost-authentik-embedded-outpost")
|
|
||||||
|
|
||||||
controller.outpost.name = generate_id()
|
|
||||||
self.assertLess(len(rec.name), 64)
|
|
||||||
|
|
||||||
# Test custom naming template
|
|
||||||
_cfg = controller.outpost.config
|
|
||||||
_cfg.object_naming_template = ""
|
|
||||||
controller.outpost.config = _cfg
|
|
||||||
self.assertEqual(rec.name, f"outpost-{controller.outpost.uuid.hex}")
|
|
||||||
self.assertLess(len(rec.name), 64)
|
|
@ -9,12 +9,7 @@ from hashlib import sha256
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||||
SECP256R1,
|
|
||||||
SECP384R1,
|
|
||||||
SECP521R1,
|
|
||||||
EllipticCurvePrivateKey,
|
|
||||||
)
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||||
from dacite import Config
|
from dacite import Config
|
||||||
@ -119,22 +114,6 @@ class JWTAlgorithms(models.TextChoices):
|
|||||||
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
|
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
|
||||||
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
|
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
|
||||||
ES256 = "ES256", _("ES256 (Asymmetric Encryption)")
|
ES256 = "ES256", _("ES256 (Asymmetric Encryption)")
|
||||||
ES384 = "ES384", _("ES384 (Asymmetric Encryption)")
|
|
||||||
ES512 = "ES512", _("ES512 (Asymmetric Encryption)")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_private_key(cls, private_key: PrivateKeyTypes | None) -> str:
|
|
||||||
if isinstance(private_key, RSAPrivateKey):
|
|
||||||
return cls.RS256
|
|
||||||
if isinstance(private_key, EllipticCurvePrivateKey):
|
|
||||||
curve = private_key.curve
|
|
||||||
if isinstance(curve, SECP256R1):
|
|
||||||
return cls.ES256
|
|
||||||
if isinstance(curve, SECP384R1):
|
|
||||||
return cls.ES384
|
|
||||||
if isinstance(curve, SECP521R1):
|
|
||||||
return cls.ES512
|
|
||||||
raise ValueError(f"Invalid private key type: {type(private_key)}")
|
|
||||||
|
|
||||||
|
|
||||||
class ScopeMapping(PropertyMapping):
|
class ScopeMapping(PropertyMapping):
|
||||||
@ -284,7 +263,11 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
|||||||
return self.client_secret, JWTAlgorithms.HS256
|
return self.client_secret, JWTAlgorithms.HS256
|
||||||
key: CertificateKeyPair = self.signing_key
|
key: CertificateKeyPair = self.signing_key
|
||||||
private_key = key.private_key
|
private_key = key.private_key
|
||||||
return private_key, JWTAlgorithms.from_private_key(private_key)
|
if isinstance(private_key, RSAPrivateKey):
|
||||||
|
return private_key, JWTAlgorithms.RS256
|
||||||
|
if isinstance(private_key, EllipticCurvePrivateKey):
|
||||||
|
return private_key, JWTAlgorithms.ES256
|
||||||
|
raise ValueError(f"Invalid private key type: {type(private_key)}")
|
||||||
|
|
||||||
def get_issuer(self, request: HttpRequest) -> str | None:
|
def get_issuer(self, request: HttpRequest) -> str | None:
|
||||||
"""Get issuer, based on request"""
|
"""Get issuer, based on request"""
|
||||||
|
@ -254,10 +254,10 @@ class OAuthAuthorizationParams:
|
|||||||
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state)
|
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state)
|
||||||
if SCOPE_OFFLINE_ACCESS in self.scope:
|
if SCOPE_OFFLINE_ACCESS in self.scope:
|
||||||
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||||
# Don't explicitly request consent with offline_access, as the spec allows for
|
if PROMPT_CONSENT not in self.prompt:
|
||||||
# "other conditions for processing the request permitting offline access to the
|
# Instead of ignoring the `offline_access` scope when `prompt`
|
||||||
# requested resources are in place"
|
# isn't set to `consent`, we set override it ourselves
|
||||||
# which we interpret as "the admin picks an authorization flow with or without consent"
|
self.prompt.add(PROMPT_CONSENT)
|
||||||
if self.response_type not in [
|
if self.response_type not in [
|
||||||
ResponseTypes.CODE,
|
ResponseTypes.CODE,
|
||||||
ResponseTypes.CODE_TOKEN,
|
ResponseTypes.CODE_TOKEN,
|
||||||
|
@ -71,7 +71,7 @@ class CodeValidatorView(PolicyAccessView):
|
|||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
LOGGER.warning("Flow not applicable to user")
|
LOGGER.warning("Flow not applicable to user")
|
||||||
return None
|
return None
|
||||||
plan.append_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||||
return plan.to_redirect(self.request, self.token.provider.authorization_flow)
|
return plan.to_redirect(self.request, self.token.provider.authorization_flow)
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,5 +34,5 @@ class EndSessionView(PolicyAccessView):
|
|||||||
PLAN_CONTEXT_APPLICATION: self.application,
|
PLAN_CONTEXT_APPLICATION: self.application,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
plan.insert_stage(in_memory_stage(SessionEndStage))
|
||||||
return plan.to_redirect(self.request, self.flow)
|
return plan.to_redirect(self.request, self.flow)
|
||||||
|
@ -75,7 +75,10 @@ class JWKSView(View):
|
|||||||
key_data = {}
|
key_data = {}
|
||||||
|
|
||||||
if use == "sig":
|
if use == "sig":
|
||||||
key_data["alg"] = JWTAlgorithms.from_private_key(private_key)
|
if isinstance(private_key, RSAPrivateKey):
|
||||||
|
key_data["alg"] = JWTAlgorithms.RS256
|
||||||
|
elif isinstance(private_key, EllipticCurvePrivateKey):
|
||||||
|
key_data["alg"] = JWTAlgorithms.ES256
|
||||||
elif use == "enc":
|
elif use == "enc":
|
||||||
key_data["alg"] = "RSA-OAEP-256"
|
key_data["alg"] = "RSA-OAEP-256"
|
||||||
key_data["enc"] = "A256CBC-HS512"
|
key_data["enc"] = "A256CBC-HS512"
|
||||||
|
@ -36,17 +36,17 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
def reconciler_name() -> str:
|
def reconciler_name() -> str:
|
||||||
return "ingress"
|
return "ingress"
|
||||||
|
|
||||||
def _check_annotations(self, current: V1Ingress, reference: V1Ingress):
|
def _check_annotations(self, reference: V1Ingress):
|
||||||
"""Check that all annotations *we* set are correct"""
|
"""Check that all annotations *we* set are correct"""
|
||||||
for key, value in reference.metadata.annotations.items():
|
for key, value in self.get_ingress_annotations().items():
|
||||||
if key not in current.metadata.annotations:
|
if key not in reference.metadata.annotations:
|
||||||
raise NeedsUpdate()
|
raise NeedsUpdate()
|
||||||
if current.metadata.annotations[key] != value:
|
if reference.metadata.annotations[key] != value:
|
||||||
raise NeedsUpdate()
|
raise NeedsUpdate()
|
||||||
|
|
||||||
def reconcile(self, current: V1Ingress, reference: V1Ingress):
|
def reconcile(self, current: V1Ingress, reference: V1Ingress):
|
||||||
super().reconcile(current, reference)
|
super().reconcile(current, reference)
|
||||||
self._check_annotations(current, reference)
|
self._check_annotations(reference)
|
||||||
# Create a list of all expected host and tls hosts
|
# Create a list of all expected host and tls hosts
|
||||||
expected_hosts = []
|
expected_hosts = []
|
||||||
expected_hosts_tls = []
|
expected_hosts_tls = []
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
"""RAC app config"""
|
"""RAC app config"""
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AuthentikProviderRAC(ManagedAppConfig):
|
class AuthentikProviderRAC(AppConfig):
|
||||||
"""authentik rac app config"""
|
"""authentik rac app config"""
|
||||||
|
|
||||||
name = "authentik.providers.rac"
|
name = "authentik.providers.rac"
|
||||||
|
@ -4,7 +4,8 @@ from asgiref.sync import async_to_sync
|
|||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.contrib.auth.signals import user_logged_out
|
from django.contrib.auth.signals import user_logged_out
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
from django.db.models import Model
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
@ -45,8 +46,12 @@ def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver([post_save, post_delete], sender=Endpoint)
|
@receiver(post_save, sender=Endpoint)
|
||||||
def post_save_post_delete_endpoint(**_):
|
def post_save_endpoint(sender: type[Model], instance, created: bool, **_):
|
||||||
"""Clear user's endpoint cache upon endpoint creation or deletion"""
|
"""Clear user's endpoint cache upon endpoint creation"""
|
||||||
|
if not created: # pragma: no cover
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete user endpoint cache
|
||||||
keys = cache.keys(user_endpoint_cache_key("*"))
|
keys = cache.keys(user_endpoint_cache_key("*"))
|
||||||
cache.delete_many(keys)
|
cache.delete_many(keys)
|
||||||
|
@ -46,7 +46,7 @@ class RACStartView(PolicyAccessView):
|
|||||||
)
|
)
|
||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
raise Http404 from None
|
raise Http404 from None
|
||||||
plan.append_stage(
|
plan.insert_stage(
|
||||||
in_memory_stage(
|
in_memory_stage(
|
||||||
RACFinalStage,
|
RACFinalStage,
|
||||||
application=self.application,
|
application=self.application,
|
||||||
|
@ -180,7 +180,6 @@ class SAMLProviderSerializer(ProviderSerializer):
|
|||||||
"session_valid_not_on_or_after",
|
"session_valid_not_on_or_after",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
"name_id_mapping",
|
"name_id_mapping",
|
||||||
"authn_context_class_ref_mapping",
|
|
||||||
"digest_algorithm",
|
"digest_algorithm",
|
||||||
"signature_algorithm",
|
"signature_algorithm",
|
||||||
"signing_kp",
|
"signing_kp",
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 5.0.13 on 2025-03-18 17:41
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_saml", "0016_samlprovider_encryption_kp_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="samlprovider",
|
|
||||||
name="authn_context_class_ref_mapping",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
default=None,
|
|
||||||
help_text="Configure how the AuthnContextClassRef value will be created. When left empty, the AuthnContextClassRef will be set based on which authentication methods the user used to authenticate.",
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
|
||||||
related_name="+",
|
|
||||||
to="authentik_providers_saml.samlpropertymapping",
|
|
||||||
verbose_name="AuthnContextClassRef Property Mapping",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -71,20 +71,6 @@ class SAMLProvider(Provider):
|
|||||||
"the NameIDPolicy of the incoming request will be considered"
|
"the NameIDPolicy of the incoming request will be considered"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
authn_context_class_ref_mapping = models.ForeignKey(
|
|
||||||
"SAMLPropertyMapping",
|
|
||||||
default=None,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=models.SET_DEFAULT,
|
|
||||||
verbose_name=_("AuthnContextClassRef Property Mapping"),
|
|
||||||
related_name="+",
|
|
||||||
help_text=_(
|
|
||||||
"Configure how the AuthnContextClassRef value will be created. When left empty, "
|
|
||||||
"the AuthnContextClassRef will be set based on which authentication methods the user "
|
|
||||||
"used to authenticate."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertion_valid_not_before = models.TextField(
|
assertion_valid_not_before = models.TextField(
|
||||||
default="minutes=-5",
|
default="minutes=-5",
|
||||||
@ -184,6 +170,7 @@ class SAMLProvider(Provider):
|
|||||||
def launch_url(self) -> str | None:
|
def launch_url(self) -> str | None:
|
||||||
"""Use IDP-Initiated SAML flow as launch URL"""
|
"""Use IDP-Initiated SAML flow as launch URL"""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
return reverse(
|
return reverse(
|
||||||
"authentik_providers_saml:sso-init",
|
"authentik_providers_saml:sso-init",
|
||||||
kwargs={"application_slug": self.application.slug},
|
kwargs={"application_slug": self.application.slug},
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"""SAML Assertion generator"""
|
"""SAML Assertion generator"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from types import GeneratorType
|
from types import GeneratorType
|
||||||
|
|
||||||
@ -53,7 +52,6 @@ class AssertionProcessor:
|
|||||||
_assertion_id: str
|
_assertion_id: str
|
||||||
_response_id: str
|
_response_id: str
|
||||||
|
|
||||||
_auth_instant: str
|
|
||||||
_valid_not_before: str
|
_valid_not_before: str
|
||||||
_session_not_on_or_after: str
|
_session_not_on_or_after: str
|
||||||
_valid_not_on_or_after: str
|
_valid_not_on_or_after: str
|
||||||
@ -67,11 +65,6 @@ class AssertionProcessor:
|
|||||||
self._assertion_id = get_random_id()
|
self._assertion_id = get_random_id()
|
||||||
self._response_id = get_random_id()
|
self._response_id = get_random_id()
|
||||||
|
|
||||||
_login_event = get_login_event(self.http_request)
|
|
||||||
_login_time = datetime.now()
|
|
||||||
if _login_event:
|
|
||||||
_login_time = _login_event.created
|
|
||||||
self._auth_instant = get_time_string(_login_time)
|
|
||||||
self._valid_not_before = get_time_string(
|
self._valid_not_before = get_time_string(
|
||||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||||
)
|
)
|
||||||
@ -138,7 +131,7 @@ class AssertionProcessor:
|
|||||||
def get_assertion_auth_n_statement(self) -> Element:
|
def get_assertion_auth_n_statement(self) -> Element:
|
||||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||||
auth_n_statement.attrib["AuthnInstant"] = self._auth_instant
|
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
|
||||||
auth_n_statement.attrib["SessionIndex"] = sha256(
|
auth_n_statement.attrib["SessionIndex"] = sha256(
|
||||||
self.http_request.session.session_key.encode("ascii")
|
self.http_request.session.session_key.encode("ascii")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@ -165,28 +158,6 @@ class AssertionProcessor:
|
|||||||
auth_n_context_class_ref.text = (
|
auth_n_context_class_ref.text = (
|
||||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract"
|
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract"
|
||||||
)
|
)
|
||||||
if self.provider.authn_context_class_ref_mapping:
|
|
||||||
try:
|
|
||||||
value = self.provider.authn_context_class_ref_mapping.evaluate(
|
|
||||||
user=self.http_request.user,
|
|
||||||
request=self.http_request,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
if value is not None:
|
|
||||||
auth_n_context_class_ref.text = str(value)
|
|
||||||
return auth_n_statement
|
|
||||||
except PropertyMappingExpressionException as exc:
|
|
||||||
Event.new(
|
|
||||||
EventAction.CONFIGURATION_ERROR,
|
|
||||||
message=(
|
|
||||||
"Failed to evaluate property-mapping: "
|
|
||||||
f"'{self.provider.authn_context_class_ref_mapping.name}'"
|
|
||||||
),
|
|
||||||
provider=self.provider,
|
|
||||||
mapping=self.provider.authn_context_class_ref_mapping,
|
|
||||||
).from_http(self.http_request)
|
|
||||||
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
|
|
||||||
return auth_n_statement
|
|
||||||
return auth_n_statement
|
return auth_n_statement
|
||||||
|
|
||||||
def get_assertion_conditions(self) -> Element:
|
def get_assertion_conditions(self) -> Element:
|
||||||
|
@ -294,61 +294,6 @@ class TestAuthNRequest(TestCase):
|
|||||||
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
|
self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re")
|
||||||
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
|
self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL)
|
||||||
|
|
||||||
def test_authn_context_class_ref_mapping(self):
|
|
||||||
"""Test custom authn_context_class_ref"""
|
|
||||||
authn_context_class_ref = generate_id()
|
|
||||||
mapping = SAMLPropertyMapping.objects.create(
|
|
||||||
name=generate_id(), expression=f"""return '{authn_context_class_ref}'"""
|
|
||||||
)
|
|
||||||
self.provider.authn_context_class_ref_mapping = mapping
|
|
||||||
self.provider.save()
|
|
||||||
user = create_test_admin_user()
|
|
||||||
http_request = get_request("/", user=user)
|
|
||||||
|
|
||||||
# First create an AuthNRequest
|
|
||||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
|
||||||
request = request_proc.build_auth_n()
|
|
||||||
|
|
||||||
# To get an assertion we need a parsed request (parsed by provider)
|
|
||||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
|
||||||
b64encode(request.encode()).decode(), "test_state"
|
|
||||||
)
|
|
||||||
# Now create a response and convert it to string (provider)
|
|
||||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
|
||||||
response = response_proc.build_response()
|
|
||||||
self.assertIn(user.username, response)
|
|
||||||
self.assertIn(authn_context_class_ref, response)
|
|
||||||
|
|
||||||
def test_authn_context_class_ref_mapping_invalid(self):
|
|
||||||
"""Test custom authn_context_class_ref (invalid)"""
|
|
||||||
mapping = SAMLPropertyMapping.objects.create(name=generate_id(), expression="q")
|
|
||||||
self.provider.authn_context_class_ref_mapping = mapping
|
|
||||||
self.provider.save()
|
|
||||||
user = create_test_admin_user()
|
|
||||||
http_request = get_request("/", user=user)
|
|
||||||
|
|
||||||
# First create an AuthNRequest
|
|
||||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
|
||||||
request = request_proc.build_auth_n()
|
|
||||||
|
|
||||||
# To get an assertion we need a parsed request (parsed by provider)
|
|
||||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
|
||||||
b64encode(request.encode()).decode(), "test_state"
|
|
||||||
)
|
|
||||||
# Now create a response and convert it to string (provider)
|
|
||||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
|
||||||
response = response_proc.build_response()
|
|
||||||
self.assertIn(user.username, response)
|
|
||||||
|
|
||||||
events = Event.objects.filter(
|
|
||||||
action=EventAction.CONFIGURATION_ERROR,
|
|
||||||
)
|
|
||||||
self.assertTrue(events.exists())
|
|
||||||
self.assertEqual(
|
|
||||||
events.first().context["message"],
|
|
||||||
f"Failed to evaluate property-mapping: '{mapping.name}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_request_attributes(self):
|
def test_request_attributes(self):
|
||||||
"""Test full SAML Request/Response flow, fully signed"""
|
"""Test full SAML Request/Response flow, fully signed"""
|
||||||
user = create_test_admin_user()
|
user = create_test_admin_user()
|
||||||
@ -376,10 +321,8 @@ class TestAuthNRequest(TestCase):
|
|||||||
request = request_proc.build_auth_n()
|
request = request_proc.build_auth_n()
|
||||||
|
|
||||||
# Create invalid PropertyMapping
|
# Create invalid PropertyMapping
|
||||||
mapping = SAMLPropertyMapping.objects.create(
|
scope = SAMLPropertyMapping.objects.create(name="test", saml_name="test", expression="q")
|
||||||
name=generate_id(), saml_name="test", expression="q"
|
self.provider.property_mappings.add(scope)
|
||||||
)
|
|
||||||
self.provider.property_mappings.add(mapping)
|
|
||||||
|
|
||||||
# To get an assertion we need a parsed request (parsed by provider)
|
# To get an assertion we need a parsed request (parsed by provider)
|
||||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||||
@ -395,7 +338,7 @@ class TestAuthNRequest(TestCase):
|
|||||||
self.assertTrue(events.exists())
|
self.assertTrue(events.exists())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
events.first().context["message"],
|
events.first().context["message"],
|
||||||
f"Failed to evaluate property-mapping: '{mapping.name}'",
|
"Failed to evaluate property-mapping: 'test'",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_idp_initiated(self):
|
def test_idp_initiated(self):
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
"""Time utilities"""
|
"""Time utilities"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
import datetime
|
||||||
|
|
||||||
from django.utils.timezone import now
|
|
||||||
|
|
||||||
|
|
||||||
def get_time_string(delta: timedelta | datetime | None = None) -> str:
|
def get_time_string(delta: datetime.timedelta | None = None) -> str:
|
||||||
"""Get Data formatted in SAML format"""
|
"""Get Data formatted in SAML format"""
|
||||||
if delta is None:
|
if delta is None:
|
||||||
delta = timedelta()
|
delta = datetime.timedelta()
|
||||||
if isinstance(delta, timedelta):
|
now = datetime.datetime.now()
|
||||||
final = now() + delta
|
final = now + delta
|
||||||
else:
|
|
||||||
final = delta
|
|
||||||
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
|
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
@ -61,7 +61,7 @@ class SAMLSLOView(PolicyAccessView):
|
|||||||
PLAN_CONTEXT_APPLICATION: self.application,
|
PLAN_CONTEXT_APPLICATION: self.application,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
plan.insert_stage(in_memory_stage(SessionEndStage))
|
||||||
return plan.to_redirect(self.request, self.flow)
|
return plan.to_redirect(self.request, self.flow)
|
||||||
|
|
||||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
|
@ -24,9 +24,7 @@ class SCIMProviderGroupSerializer(ModelSerializer):
|
|||||||
"group",
|
"group",
|
||||||
"group_obj",
|
"group_obj",
|
||||||
"provider",
|
"provider",
|
||||||
"attributes",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {"attributes": {"read_only": True}}
|
|
||||||
|
|
||||||
|
|
||||||
class SCIMProviderGroupViewSet(
|
class SCIMProviderGroupViewSet(
|
||||||
|
@ -28,10 +28,8 @@ class SCIMProviderSerializer(ProviderSerializer):
|
|||||||
"url",
|
"url",
|
||||||
"verify_certificates",
|
"verify_certificates",
|
||||||
"token",
|
"token",
|
||||||
"compatibility_mode",
|
|
||||||
"exclude_users_service_account",
|
"exclude_users_service_account",
|
||||||
"filter_group",
|
"filter_group",
|
||||||
"dry_run",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
|
|
||||||
|
@ -24,9 +24,7 @@ class SCIMProviderUserSerializer(ModelSerializer):
|
|||||||
"user",
|
"user",
|
||||||
"user_obj",
|
"user_obj",
|
||||||
"provider",
|
"provider",
|
||||||
"attributes",
|
|
||||||
]
|
]
|
||||||
extra_kwargs = {"attributes": {"read_only": True}}
|
|
||||||
|
|
||||||
|
|
||||||
class SCIMProviderUserViewSet(
|
class SCIMProviderUserViewSet(
|
||||||
|
@ -12,9 +12,8 @@ from authentik.lib.sync.outgoing import (
|
|||||||
HTTP_SERVICE_UNAVAILABLE,
|
HTTP_SERVICE_UNAVAILABLE,
|
||||||
HTTP_TOO_MANY_REQUESTS,
|
HTTP_TOO_MANY_REQUESTS,
|
||||||
)
|
)
|
||||||
from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient
|
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||||
from authentik.lib.sync.outgoing.exceptions import (
|
from authentik.lib.sync.outgoing.exceptions import (
|
||||||
DryRunRejected,
|
|
||||||
NotFoundSyncException,
|
NotFoundSyncException,
|
||||||
ObjectExistsSyncException,
|
ObjectExistsSyncException,
|
||||||
TransientSyncException,
|
TransientSyncException,
|
||||||
@ -22,7 +21,7 @@ from authentik.lib.sync.outgoing.exceptions import (
|
|||||||
from authentik.lib.utils.http import get_http_session
|
from authentik.lib.utils.http import get_http_session
|
||||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
||||||
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
|
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
|
||||||
from authentik.providers.scim.models import SCIMCompatibilityMode, SCIMProvider
|
from authentik.providers.scim.models import SCIMProvider
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@ -55,8 +54,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
|||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs) -> dict:
|
def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
"""Wrapper to send a request to the full URL"""
|
"""Wrapper to send a request to the full URL"""
|
||||||
if self.provider.dry_run and method.upper() not in SAFE_METHODS:
|
|
||||||
raise DryRunRejected(f"{self.base_url}{path}", method, body=kwargs.get("json"))
|
|
||||||
try:
|
try:
|
||||||
response = self._session.request(
|
response = self._session.request(
|
||||||
method,
|
method,
|
||||||
@ -90,14 +87,9 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
|||||||
"""Get Service provider config"""
|
"""Get Service provider config"""
|
||||||
default_config = ServiceProviderConfiguration.default()
|
default_config = ServiceProviderConfiguration.default()
|
||||||
try:
|
try:
|
||||||
config = ServiceProviderConfiguration.model_validate(
|
return ServiceProviderConfiguration.model_validate(
|
||||||
self._request("GET", "/ServiceProviderConfig")
|
self._request("GET", "/ServiceProviderConfig")
|
||||||
)
|
)
|
||||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.AWS:
|
|
||||||
config.patch.supported = False
|
|
||||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.SLACK:
|
|
||||||
config.filter.supported = True
|
|
||||||
return config
|
|
||||||
except (ValidationError, SCIMRequestException, NotFoundSyncException) as exc:
|
except (ValidationError, SCIMRequestException, NotFoundSyncException) as exc:
|
||||||
self.logger.warning("failed to get ServiceProviderConfig", exc=exc)
|
self.logger.warning("failed to get ServiceProviderConfig", exc=exc)
|
||||||
return default_config
|
return default_config
|
||||||
|
@ -102,7 +102,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
|||||||
if not scim_id or scim_id == "":
|
if not scim_id or scim_id == "":
|
||||||
raise StopSync("SCIM Response with missing or invalid `id`")
|
raise StopSync("SCIM Response with missing or invalid `id`")
|
||||||
connection = SCIMProviderGroup.objects.create(
|
connection = SCIMProviderGroup.objects.create(
|
||||||
provider=self.provider, group=group, scim_id=scim_id, attributes=response
|
provider=self.provider, group=group, scim_id=scim_id
|
||||||
)
|
)
|
||||||
users = list(group.users.order_by("id").values_list("id", flat=True))
|
users = list(group.users.order_by("id").values_list("id", flat=True))
|
||||||
self._patch_add_users(connection, users)
|
self._patch_add_users(connection, users)
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
"""User client"""
|
"""User client"""
|
||||||
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils.http import urlencode
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||||
from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync
|
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||||
from authentik.policies.utils import delete_none_values
|
from authentik.policies.utils import delete_none_values
|
||||||
from authentik.providers.scim.clients.base import SCIMClient
|
from authentik.providers.scim.clients.base import SCIMClient
|
||||||
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
||||||
@ -57,44 +55,24 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
def create(self, user: User):
|
def create(self, user: User):
|
||||||
"""Create user from scratch and create a connection object"""
|
"""Create user from scratch and create a connection object"""
|
||||||
scim_user = self.to_schema(user, None)
|
scim_user = self.to_schema(user, None)
|
||||||
with transaction.atomic():
|
response = self._request(
|
||||||
try:
|
"POST",
|
||||||
response = self._request(
|
"/Users",
|
||||||
"POST",
|
json=scim_user.model_dump(
|
||||||
"/Users",
|
mode="json",
|
||||||
json=scim_user.model_dump(
|
exclude_unset=True,
|
||||||
mode="json",
|
),
|
||||||
exclude_unset=True,
|
)
|
||||||
),
|
scim_id = response.get("id")
|
||||||
)
|
if not scim_id or scim_id == "":
|
||||||
except ObjectExistsSyncException as exc:
|
raise StopSync("SCIM Response with missing or invalid `id`")
|
||||||
if not self._config.filter.supported:
|
return SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
|
||||||
raise exc
|
|
||||||
users = self._request(
|
|
||||||
"GET", f"/Users?{urlencode({'filter': f'userName eq {scim_user.userName}'})}"
|
|
||||||
)
|
|
||||||
users_res = users.get("Resources", [])
|
|
||||||
if len(users_res) < 1:
|
|
||||||
raise exc
|
|
||||||
return SCIMProviderUser.objects.create(
|
|
||||||
provider=self.provider,
|
|
||||||
user=user,
|
|
||||||
scim_id=users_res[0]["id"],
|
|
||||||
attributes=users_res[0],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
scim_id = response.get("id")
|
|
||||||
if not scim_id or scim_id == "":
|
|
||||||
raise StopSync("SCIM Response with missing or invalid `id`")
|
|
||||||
return SCIMProviderUser.objects.create(
|
|
||||||
provider=self.provider, user=user, scim_id=scim_id, attributes=response
|
|
||||||
)
|
|
||||||
|
|
||||||
def update(self, user: User, connection: SCIMProviderUser):
|
def update(self, user: User, connection: SCIMProviderUser):
|
||||||
"""Update existing user"""
|
"""Update existing user"""
|
||||||
scim_user = self.to_schema(user, connection)
|
scim_user = self.to_schema(user, connection)
|
||||||
scim_user.id = connection.scim_id
|
scim_user.id = connection.scim_id
|
||||||
response = self._request(
|
self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/Users/{connection.scim_id}",
|
f"/Users/{connection.scim_id}",
|
||||||
json=scim_user.model_dump(
|
json=scim_user.model_dump(
|
||||||
@ -102,5 +80,3 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
exclude_unset=True,
|
exclude_unset=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
connection.attributes = response
|
|
||||||
connection.save()
|
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 5.0.12 on 2025-02-24 19:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_scim", "0010_scimprovider_verify_certificates"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="scimprovider",
|
|
||||||
name="dry_run",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="When enabled, provider will not modify or create objects in the remote system.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 5.0.12 on 2025-03-07 23:35
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_scim", "0011_scimprovider_dry_run"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="scimprovider",
|
|
||||||
name="compatibility_mode",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[("default", "Default"), ("aws", "AWS"), ("slack", "Slack")],
|
|
||||||
default="default",
|
|
||||||
help_text="Alter authentik behavior for vendor-specific SCIM implementations.",
|
|
||||||
max_length=30,
|
|
||||||
verbose_name="SCIM Compatibility Mode",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.0.13 on 2025-03-18 13:47
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_providers_scim", "0012_scimprovider_compatibility_mode"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="scimprovidergroup",
|
|
||||||
name="attributes",
|
|
||||||
field=models.JSONField(default=dict),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="scimprovideruser",
|
|
||||||
name="attributes",
|
|
||||||
field=models.JSONField(default=dict),
|
|
||||||
),
|
|
||||||
]
|
|
@ -22,7 +22,6 @@ class SCIMProviderUser(SerializerModel):
|
|||||||
scim_id = models.TextField()
|
scim_id = models.TextField()
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE)
|
provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE)
|
||||||
attributes = models.JSONField(default=dict)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
@ -44,7 +43,6 @@ class SCIMProviderGroup(SerializerModel):
|
|||||||
scim_id = models.TextField()
|
scim_id = models.TextField()
|
||||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||||
provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE)
|
provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE)
|
||||||
attributes = models.JSONField(default=dict)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
@ -59,14 +57,6 @@ class SCIMProviderGroup(SerializerModel):
|
|||||||
return f"SCIM Provider Group {self.group_id} to {self.provider_id}"
|
return f"SCIM Provider Group {self.group_id} to {self.provider_id}"
|
||||||
|
|
||||||
|
|
||||||
class SCIMCompatibilityMode(models.TextChoices):
|
|
||||||
"""SCIM compatibility mode"""
|
|
||||||
|
|
||||||
DEFAULT = "default", _("Default")
|
|
||||||
AWS = "aws", _("AWS")
|
|
||||||
SLACK = "slack", _("Slack")
|
|
||||||
|
|
||||||
|
|
||||||
class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||||
"""SCIM 2.0 provider to create users and groups in external applications"""
|
"""SCIM 2.0 provider to create users and groups in external applications"""
|
||||||
|
|
||||||
@ -87,14 +77,6 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
|||||||
help_text=_("Property mappings used for group creation/updating."),
|
help_text=_("Property mappings used for group creation/updating."),
|
||||||
)
|
)
|
||||||
|
|
||||||
compatibility_mode = models.CharField(
|
|
||||||
max_length=30,
|
|
||||||
choices=SCIMCompatibilityMode.choices,
|
|
||||||
default=SCIMCompatibilityMode.DEFAULT,
|
|
||||||
verbose_name=_("SCIM Compatibility Mode"),
|
|
||||||
help_text=_("Alter authentik behavior for vendor-specific SCIM implementations."),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> str | None:
|
def icon_url(self) -> str | None:
|
||||||
return static("authentik/sources/scim.png")
|
return static("authentik/sources/scim.png")
|
||||||
|
@ -3,15 +3,12 @@
|
|||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils.text import slugify
|
|
||||||
from jsonschema import validate
|
from jsonschema import validate
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.blueprints.tests import apply_blueprint
|
from authentik.blueprints.tests import apply_blueprint
|
||||||
from authentik.core.models import Application, Group, User
|
from authentik.core.models import Application, Group, User
|
||||||
from authentik.events.models import SystemTask
|
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.lib.sync.outgoing.base import SAFE_METHODS
|
|
||||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||||
from authentik.providers.scim.tasks import scim_sync, sync_tasks
|
from authentik.providers.scim.tasks import scim_sync, sync_tasks
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@ -333,59 +330,3 @@ class SCIMUserTests(TestCase):
|
|||||||
"userName": uid,
|
"userName": uid,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_user_create_dry_run(self):
|
|
||||||
"""Test user creation (dry_run)"""
|
|
||||||
# Update the provider before we start mocking as saving the provider triggers a full sync
|
|
||||||
self.provider.dry_run = True
|
|
||||||
self.provider.save()
|
|
||||||
with Mocker() as mock:
|
|
||||||
scim_id = generate_id()
|
|
||||||
mock.get(
|
|
||||||
"https://localhost/ServiceProviderConfig",
|
|
||||||
json={},
|
|
||||||
)
|
|
||||||
mock.post(
|
|
||||||
"https://localhost/Users",
|
|
||||||
json={
|
|
||||||
"id": scim_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
uid = generate_id()
|
|
||||||
User.objects.create(
|
|
||||||
username=uid,
|
|
||||||
name=f"{uid} {uid}",
|
|
||||||
email=f"{uid}@goauthentik.io",
|
|
||||||
)
|
|
||||||
self.assertEqual(mock.call_count, 1, mock.request_history)
|
|
||||||
self.assertEqual(mock.request_history[0].method, "GET")
|
|
||||||
|
|
||||||
def test_sync_task_dry_run(self):
|
|
||||||
"""Test sync tasks"""
|
|
||||||
# Update the provider before we start mocking as saving the provider triggers a full sync
|
|
||||||
self.provider.dry_run = True
|
|
||||||
self.provider.save()
|
|
||||||
with Mocker() as mock:
|
|
||||||
uid = generate_id()
|
|
||||||
mock.get(
|
|
||||||
"https://localhost/ServiceProviderConfig",
|
|
||||||
json={},
|
|
||||||
)
|
|
||||||
User.objects.create(
|
|
||||||
username=uid,
|
|
||||||
name=f"{uid} {uid}",
|
|
||||||
email=f"{uid}@goauthentik.io",
|
|
||||||
)
|
|
||||||
|
|
||||||
sync_tasks.trigger_single_task(self.provider, scim_sync).get()
|
|
||||||
|
|
||||||
self.assertEqual(mock.call_count, 3)
|
|
||||||
for request in mock.request_history:
|
|
||||||
self.assertIn(request.method, SAFE_METHODS)
|
|
||||||
task = SystemTask.objects.filter(uid=slugify(self.provider.name)).first()
|
|
||||||
self.assertIsNotNone(task)
|
|
||||||
drop_msg = task.messages[2]
|
|
||||||
self.assertEqual(drop_msg["event"], "Dropping mutating request due to dry run")
|
|
||||||
self.assertIsNotNone(drop_msg["attributes"]["url"])
|
|
||||||
self.assertIsNotNone(drop_msg["attributes"]["body"])
|
|
||||||
self.assertIsNotNone(drop_msg["attributes"]["method"])
|
|
||||||
|
@ -125,6 +125,7 @@ TENANT_APPS = [
|
|||||||
"authentik.brands",
|
"authentik.brands",
|
||||||
"authentik.blueprints",
|
"authentik.blueprints",
|
||||||
"guardian",
|
"guardian",
|
||||||
|
"django_celery_beat",
|
||||||
]
|
]
|
||||||
|
|
||||||
TENANT_MODEL = "authentik_tenants.Tenant"
|
TENANT_MODEL = "authentik_tenants.Tenant"
|
||||||
|
@ -68,6 +68,8 @@ class OAuth2Client(BaseOAuthClient):
|
|||||||
error_desc = self.get_request_arg("error_description", None)
|
error_desc = self.get_request_arg("error_description", None)
|
||||||
return {"error": error_desc or error or _("No token received.")}
|
return {"error": error_desc or error or _("No token received.")}
|
||||||
args = {
|
args = {
|
||||||
|
"client_id": self.get_client_id(),
|
||||||
|
"client_secret": self.get_client_secret(),
|
||||||
"redirect_uri": callback,
|
"redirect_uri": callback,
|
||||||
"code": code,
|
"code": code,
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
|
@ -28,7 +28,7 @@ def update_well_known_jwks(self: SystemTask):
|
|||||||
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
|
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
|
||||||
messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
||||||
continue
|
continue
|
||||||
config: dict = well_known_config.json()
|
config = well_known_config.json()
|
||||||
try:
|
try:
|
||||||
dirty = False
|
dirty = False
|
||||||
source_attr_key = (
|
source_attr_key = (
|
||||||
@ -40,9 +40,7 @@ def update_well_known_jwks(self: SystemTask):
|
|||||||
for source_attr, config_key in source_attr_key:
|
for source_attr, config_key in source_attr_key:
|
||||||
# Check if we're actually changing anything to only
|
# Check if we're actually changing anything to only
|
||||||
# save when something has changed
|
# save when something has changed
|
||||||
if config_key not in config:
|
if getattr(source, source_attr, "") != config[config_key]:
|
||||||
continue
|
|
||||||
if getattr(source, source_attr, "") != config.get(config_key, ""):
|
|
||||||
dirty = True
|
dirty = True
|
||||||
setattr(source, source_attr, config[config_key])
|
setattr(source, source_attr, config[config_key])
|
||||||
except (IndexError, KeyError) as exc:
|
except (IndexError, KeyError) as exc:
|
||||||
|
@ -25,10 +25,8 @@ class RedditOAuth2Client(UserprofileHeaderAuthClient):
|
|||||||
|
|
||||||
def get_access_token(self, **request_kwargs):
|
def get_access_token(self, **request_kwargs):
|
||||||
"Fetch access token from callback request."
|
"Fetch access token from callback request."
|
||||||
request_kwargs["auth"] = HTTPBasicAuth(
|
auth = HTTPBasicAuth(self.source.consumer_key, self.source.consumer_secret)
|
||||||
self.source.consumer_key, self.source.consumer_secret
|
return super().get_access_token(auth=auth)
|
||||||
)
|
|
||||||
return super().get_access_token(**request_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class RedditOAuth2Callback(OAuthCallback):
|
class RedditOAuth2Callback(OAuthCallback):
|
||||||
|
@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
from authentik.core.types import UserSettingSerializer
|
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.exceptions import StageInvalidException
|
from authentik.flows.exceptions import StageInvalidException
|
||||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||||
@ -72,14 +71,6 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
return "ak-stage-authenticator-email-form"
|
return "ak-stage-authenticator-email-form"
|
||||||
|
|
||||||
def ui_user_settings(self) -> UserSettingSerializer | None:
|
|
||||||
return UserSettingSerializer(
|
|
||||||
data={
|
|
||||||
"title": self.friendly_name or str(self._meta.verbose_name),
|
|
||||||
"component": "ak-user-settings-authenticator-email",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def backend_class(self) -> type[BaseEmailBackend]:
|
def backend_class(self) -> type[BaseEmailBackend]:
|
||||||
"""Get the email backend class to use"""
|
"""Get the email backend class to use"""
|
||||||
|
@ -299,6 +299,10 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
|||||||
data={"component": "ak-stage-authenticator-email", "code": device.token},
|
data={"component": "ak-stage-authenticator-email", "code": device.token},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(device.confirmed)
|
||||||
|
# Session key should be removed after device is saved
|
||||||
|
device.save()
|
||||||
|
self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
|
||||||
|
|
||||||
def test_model_properties_and_methods(self):
|
def test_model_properties_and_methods(self):
|
||||||
"""Test model properties"""
|
"""Test model properties"""
|
||||||
@ -325,6 +329,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
|||||||
self.stage.send(device)
|
self.stage.send(device)
|
||||||
|
|
||||||
def test_email_tasks(self):
|
def test_email_tasks(self):
|
||||||
|
|
||||||
email_send_mock = MagicMock()
|
email_send_mock = MagicMock()
|
||||||
with patch(
|
with patch(
|
||||||
"authentik.stages.email.tasks.send_mails",
|
"authentik.stages.email.tasks.send_mails",
|
||||||
|
@ -146,10 +146,5 @@
|
|||||||
"name": "LogMeOnce",
|
"name": "LogMeOnce",
|
||||||
"icon_dark": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMjA0OCAyMDQ4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTSAwIDAgTCAtNSAxIEwgLTE1IDQgTCAtMzggMTEgTCAtNTcgMTggTCAtNzYgMjkgTCAtOTAgMzggTCAtMTA5IDUyIEwgLTExNiA1OSBMIC0xMjcgNjggTCAtMTQwIDgxIEwgLTE0OCA4OCBMIC0xNDkgOTEgTCAtMTUxIDkxIEwgLTE2MSAxMDEgTCAtMTY5IDEwOCBMIC0xNjkgMTEwIEwgLTE3MSAxMTAgTCAtMTgwIDExOSBMIC0xODggMTI2IEwgLTIwMSAxMzcgTCAtMjEyIDE0NiBMIC0yMjYgMTU3IEwgLTIzOSAxNjcgTCAtMjUwIDE3NSBMIC0yNzAgMTg4IEwgLTI5MSAyMDIgTCAtMzA5IDIxMiBMIC0zMzAgMjI0IEwgLTM1MyAyMzUgTCAtMzg2IDI1MCBMIC00MDcgMjU5IEwgLTQ0OSAyNzMgTCAtNDcyIDI4MSBMIC01MDIgMjg5IEwgLTU1MSAyOTggTCAtNjE1IDMwOSBMIC02NDcgMzE2IEwgLTY4MSAzMjQgTCAtNjk4IDMzMCBMIC03MTQgMzM4IEwgLTczNSAzNTEgTCAtNzQ1IDM1OCBMIC03NTYgMzY3IEwgLTc2NCAzNzUgTCAtNzY2IDM3OSBMIC03NjggMzc5IEwgLTc3MyAzODUgTCAtNzgxIDM5NSBMIC03OTEgNDEwIEwgLTgwMSA0MjggTCAtODEwIDQ0NyBMIC04MTYgNDY1IEwgLTgyMiA1MDAgTCAtODI3IDU0NSBMIC04MzAgNTkyIEwgLTgzMiA2NTMgTCAtODMyIDcxMiBMIC04MzEgNzQyIEwgLTgyOCA3OTQgTCAtODIyIDg2NiBMIC04MTYgOTI4IEwgLTgxMiA5NTcgTCAtODAwIDEwMjAgTCAtNzg5IDEwNzIgTCAtNzgzIDEwOTggTCAtNzY1IDExNjIgTCAtNzUxIDEyMDcgTCAtNzQ0IDEyMjUgTCAtNzM0IDEyNTQgTCAtNzIwIDEyOTAgTCAtNzA0IDEzMjggTCAtNjg5IDEzNjEgTCAtNjY4IDE0MDMgTCAtNjUzIDE0MzEgTCAtNjM5IDE0NTYgTCAtNjIwIDE0ODggTCAtNjA3IDE1MDkgTCAtNTk0IDE1MjkgTCAtNTg0IDE1NDQgTCAtNTcxIDE1NjMgTCAtNTU4IDE1ODEgTCAtNTQ0IDE2MDAgTCAtNTMzIDE2MTUgTCAtNTIwIDE2MzIgTCAtNTA3IDE2NDggTCAtNDc5IDE2ODIgTCAtNDcwIDE2OTMgTCAtNDYwIDE3MDQgTCAtNDQ5IDE3MTYgTCAtNDQyIDE3MjQgTCAtNDMyIDE3MzQgTCAtNDI1IDE3NDIgTCAtMzY4IDE3OTkgTCAtMzUxIDE4MTUgTCAtMzM1IDE4MzAgTCAtMzI3IDE4MzcgTCAtMzE0IDE4NDkgTCAtMjg4IDE4NzEgTCAtMjcxIDE4ODUgTCAtMjYxIDE4OTMgTCAtMjQ0IDE5MDcgTCAtMjI4IDE5MTkgTCAtMjE1IDE5MjkgTCAtMTk2IDE5NDMgTCAtMTg0IDE5NTIgTCAtMTY4IDE5NjMgTCAtMTQ4IDE5NzcgTCAtMTMzIDE5ODcgTCAtMTEyIDIwMDAgTCAtODggMjAxMyBMIC03MiAyMDIxIEwgLTUyIDIwMzAgTCAtMzIgMjAzNyBMIDEyIDIwNDggTCA2MCAyMDQ4IEwgNjEgMjA0NyBMIDczIDIwNDQgTCAxMDAgMjAzNyBMIDEyMSAyMDI5IEwgMTQ5IDIwMTUgTCAxNjcgMjAwNSBMIDE4NiAxOTk0IEwgMjEwIDE5NzkgTCAyMzEgMTk2NSBMIDI2NiAxOTQxIEwgMjg0IDE5MjggTCAyOTUgMTkyMCBMIDMwNiAxOTExIEwgMzI0IDE4OTggTCAzMzggMTg4NyBMIDM0OSAxODc3IEwgMzU4IDE4NzAgTCAzNjkgMTg2MCBMIDM4MCAxODUxIEwgMzgwIDE4NDkgTCAzODIgMTg0OSBMIDM4NSAxODQ2IEwgMzk2IDE4MzcgTCA0MDMgMTgzMCBMIDQxMSAxODIzIEwgNDE4IDE4MTYgTCA0MjYgMTgwOSBMIDQ3NSAxNzYwIEwgNDc3IDE3NTYgTCA0NzkgMTc1NiBMIDQ4NyAxNzQ3IEwgNTAzIDE3MzAgTCA1MTAgMTcyMiBMIDUxOSAxNzEyIEwgNTI2IDE3MDQgTCA1MzggMTY5MSBMIDU0NyAxNjgwIEwgNTU4IDE2NjcgTCA1NjYgMTY1NiBMIDU3OSAxNjQwIEwgNTkwIDE2MjYgTCA2MDYgMTYwNSBMIDYyMSAxNTgzIEwgNjMxIDE1NjkgTCA2NDUgMTU0OSBMIDY1NSAxNTMzIEwgNjcwIDE1MTAgTCA2ODUgMTQ4NSBMIDY5NiAxNDY3IEwgNzA2IDE0NDkgTCA3MzAgMTQwNSBMIDc0NSAxMzc0IEwgNzYyIDEzMzggTCA3NzQgMTMxMCBMIDc5MiAxMjY3IEwgODExIDEyMTYgTCA4MjcgMTE2NiBMIDg0MiAxMTE3IEwgODU4IDEwNTEgTCA4NzAgOTk2IEwgODc1IDk2NyBMIDg4MSA5MTcgTCA4ODYgODgyIEwgODkxIDg0NiBMIDg5NCA4MTIgTCA4OTYgNzc0IEwgODk2IDY1NSBMIDg5NSA2MzAgTCA4OTIgNTkzIEwgODg4IDU1OSBMIDg3NyA0NzAgTCA4NzIgNDQ1IEwgODY1IDQyNiBMIDg1NyA0MTEgTCA4NDcgMzk1IEwgODM0IDM3OCBMIDgxNCAzNTggTCA4MDAgMzQ3IEwgNzg1IDMzNyBMIDc2NiAzMjcgTCA3NTAgMzIxIEwgNzI3IDMxNSBMIDY5MyAzMDggTCA2NTAgMzAxIEwgNTk3IDI5MiBMIDU3MCAyODYgTCA1NDggMjgwIEwgNTE1IDI3MCBMIDQ4NyAyNjEgTCA0NTkgMjUwIEwgNDM4IDI0MSBMIDQxNCAyMzAgTCAzODIgMjEzIEwgMzU4IDE5OSBMIDM0NyAxOTIgTCAzMzAgMTgwIEwgMzEyIDE2NyBMIDMwMCAxNTcgTCAyODYgMTQ2IEwgMjcxIDEzMyBMIDI2MyAxMjYgTCAyNTEgMTE1IEwgMjQwIDEwNiBMIDIyOCA5NSBMIDIwMCA3MSBMIDE4OSA2MiBMIDE3NCA1MCBMIDE1OCAzOCBMIDE0MiAyNyBMIDEyNyAxOSBMIDExNSAxNCBMIDk1IDggTCA3MSAwIEwgMCAwIHogTSAzMCA2MjIgTCA1MSA2MjMgTCA3OSA2MjcgTCAxMDAgNjMzIEwgMTIxIDY0MSBMIDE0MSA2NTIgTCAxNTcgNjYzIEwgMTcyIDY3NiBMIDE4NiA2OTAgTCAxODYgNjkyIEwgMTg4IDY5MiBMIDIwNiA3MTYgTCAyMTYgNzM0IEwgMjIxIDc0NSBMIDIyNiA3NTggTCAyMzMgNzg0IEwgMjM2IDc5OCBMIDIzNyA4MDggTCAyMzcgODQwIEwgMjMzIDg2NSBMIDIyNiA4OTEgTCAyMTkgOTA5IEwgMjA5IDkyOCBMIDE5NSA5NDkgTCAxODYgOTU5IEwgMTc5IDk2NyBMIDE2NiA5ODAgTCAxNTUgOTg5IEwgMTQyIDk5OCBMIDEyNiAxMDA3IEwgMTEzIDEwMTIgTCAxMTQgMTAyNCBMIDEyMiAxMDYwIEwgMTM3IDExMjMgTCAxNTYgMTIwOCBMIDE3MCAxMjcxIEwgMTc0IDEyOTYgTCAxNzYgMTMxMCBMIDE3NiAxMzM1IEwgMTczIDEzNDkgTCAxNjYgMTM1OSBMIDE1NyAxMzY3IEwgMTQ2IDEzNzIgTCAxNDAgMTM3NCBMIDExOSAxMzc2IEwgNTEgMTM3NiBMIC0zMiAxMzc3IEwgLTY0IDEzNzcgTCAtNzggMTM3NCBMIC04NSAxMzcwIEwgLTk4IDEzNTkgTCAtMTA2IDEzNDkgTCAtMTEwIDEzNDEgTCAtMTEyIDEzMzMgTCAtMTEyIDEzMTggTCAtMTA3IDEyODggTCAtMTAwIDEyNTYgTCAtODUgMTE5NiBMIC02OSAxMTI0IEwgLTU3IDEwNzIgTCAtNTAgMTAzNiBMIC00NyAxMDExIEwgLTUzIDEwMDkgTCAtNjkgMTAwMSBMIC04MSA5OTQgTCAtOTMgOTg1IEwgLTEwMyA5NzYgTCAtMTExIDk2OSBMIC0xMjAgOTYwIEwgLTEzMSA5NDYgTCAtMTQyIDkyOSBMIC0xNTIgOTEwIEwgLTE2MCA4OTAgTCAtMTY2IDg3MCBMIC0xNjkgODUzIEwgLTE3MCA4NDQgTCAtMTcwIDgxNCBMIC0xNjcgNzk0IEwgLTE2MSA3NjcgTCAtMTU0IDc0NiBMIC0xMzcgNzEzIEwgLTEyNiA2OTggTCAtMTE5IDY5MCBMIC0xMDggNjc4IEwgLTkzIDY2NSBMIC03NyA2NTMgTCAtNTYgNjQxIEwgLTM4IDYzMyBMIC0xNiA2MjcgTCAzIDYyNCBMIDMwIDYyMiB6ICIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTkxKSIgc3R5bGU9ImZpbGw6I2ZmZmZmZiIgLz4KPC9zdmc+Cg==",
|
"icon_dark": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMjA0OCAyMDQ4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTSAwIDAgTCAtNSAxIEwgLTE1IDQgTCAtMzggMTEgTCAtNTcgMTggTCAtNzYgMjkgTCAtOTAgMzggTCAtMTA5IDUyIEwgLTExNiA1OSBMIC0xMjcgNjggTCAtMTQwIDgxIEwgLTE0OCA4OCBMIC0xNDkgOTEgTCAtMTUxIDkxIEwgLTE2MSAxMDEgTCAtMTY5IDEwOCBMIC0xNjkgMTEwIEwgLTE3MSAxMTAgTCAtMTgwIDExOSBMIC0xODggMTI2IEwgLTIwMSAxMzcgTCAtMjEyIDE0NiBMIC0yMjYgMTU3IEwgLTIzOSAxNjcgTCAtMjUwIDE3NSBMIC0yNzAgMTg4IEwgLTI5MSAyMDIgTCAtMzA5IDIxMiBMIC0zMzAgMjI0IEwgLTM1MyAyMzUgTCAtMzg2IDI1MCBMIC00MDcgMjU5IEwgLTQ0OSAyNzMgTCAtNDcyIDI4MSBMIC01MDIgMjg5IEwgLTU1MSAyOTggTCAtNjE1IDMwOSBMIC02NDcgMzE2IEwgLTY4MSAzMjQgTCAtNjk4IDMzMCBMIC03MTQgMzM4IEwgLTczNSAzNTEgTCAtNzQ1IDM1OCBMIC03NTYgMzY3IEwgLTc2NCAzNzUgTCAtNzY2IDM3OSBMIC03NjggMzc5IEwgLTc3MyAzODUgTCAtNzgxIDM5NSBMIC03OTEgNDEwIEwgLTgwMSA0MjggTCAtODEwIDQ0NyBMIC04MTYgNDY1IEwgLTgyMiA1MDAgTCAtODI3IDU0NSBMIC04MzAgNTkyIEwgLTgzMiA2NTMgTCAtODMyIDcxMiBMIC04MzEgNzQyIEwgLTgyOCA3OTQgTCAtODIyIDg2NiBMIC04MTYgOTI4IEwgLTgxMiA5NTcgTCAtODAwIDEwMjAgTCAtNzg5IDEwNzIgTCAtNzgzIDEwOTggTCAtNzY1IDExNjIgTCAtNzUxIDEyMDcgTCAtNzQ0IDEyMjUgTCAtNzM0IDEyNTQgTCAtNzIwIDEyOTAgTCAtNzA0IDEzMjggTCAtNjg5IDEzNjEgTCAtNjY4IDE0MDMgTCAtNjUzIDE0MzEgTCAtNjM5IDE0NTYgTCAtNjIwIDE0ODggTCAtNjA3IDE1MDkgTCAtNTk0IDE1MjkgTCAtNTg0IDE1NDQgTCAtNTcxIDE1NjMgTCAtNTU4IDE1ODEgTCAtNTQ0IDE2MDAgTCAtNTMzIDE2MTUgTCAtNTIwIDE2MzIgTCAtNTA3IDE2NDggTCAtNDc5IDE2ODIgTCAtNDcwIDE2OTMgTCAtNDYwIDE3MDQgTCAtNDQ5IDE3MTYgTCAtNDQyIDE3MjQgTCAtNDMyIDE3MzQgTCAtNDI1IDE3NDIgTCAtMzY4IDE3OTkgTCAtMzUxIDE4MTUgTCAtMzM1IDE4MzAgTCAtMzI3IDE4MzcgTCAtMzE0IDE4NDkgTCAtMjg4IDE4NzEgTCAtMjcxIDE4ODUgTCAtMjYxIDE4OTMgTCAtMjQ0IDE5MDcgTCAtMjI4IDE5MTkgTCAtMjE1IDE5MjkgTCAtMTk2IDE5NDMgTCAtMTg0IDE5NTIgTCAtMTY4IDE5NjMgTCAtMTQ4IDE5NzcgTCAtMTMzIDE5ODcgTCAtMTEyIDIwMDAgTCAtODggMjAxMyBMIC03MiAyMDIxIEwgLTUyIDIwMzAgTCAtMzIgMjAzNyBMIDEyIDIwNDggTCA2MCAyMDQ4IEwgNjEgMjA0NyBMIDczIDIwNDQgTCAxMDAgMjAzNyBMIDEyMSAyMDI5IEwgMTQ5IDIwMTUgTCAxNjcgMjAwNSBMIDE4NiAxOTk0IEwgMjEwIDE5NzkgTCAyMzEgMTk2NSBMIDI2NiAxOTQxIEwgMjg0IDE5MjggTCAyOTUgMTkyMCBMIDMwNiAxOTExIEwgMzI0IDE4OTggTCAzMzggMTg4NyBMIDM0OSAxODc3IEwgMzU4IDE4NzAgTCAzNjkgMTg2MCBMIDM4MCAxODUxIEwgMzgwIDE4NDkgTCAzODIgMTg0OSBMIDM4NSAxODQ2IEwgMzk2IDE4MzcgTCA0MDMgMTgzMCBMIDQxMSAxODIzIEwgNDE4IDE4MTYgTCA0MjYgMTgwOSBMIDQ3NSAxNzYwIEwgNDc3IDE3NTYgTCA0NzkgMTc1NiBMIDQ4NyAxNzQ3IEwgNTAzIDE3MzAgTCA1MTAgMTcyMiBMIDUxOSAxNzEyIEwgNTI2IDE3MDQgTCA1MzggMTY5MSBMIDU0NyAxNjgwIEwgNTU4IDE2NjcgTCA1NjYgMTY1NiBMIDU3OSAxNjQwIEwgNTkwIDE2MjYgTCA2MDYgMTYwNSBMIDYyMSAxNTgzIEwgNjMxIDE1NjkgTCA2NDUgMTU0OSBMIDY1NSAxNTMzIEwgNjcwIDE1MTAgTCA2ODUgMTQ4NSBMIDY5NiAxNDY3IEwgNzA2IDE0NDkgTCA3MzAgMTQwNSBMIDc0NSAxMzc0IEwgNzYyIDEzMzggTCA3NzQgMTMxMCBMIDc5MiAxMjY3IEwgODExIDEyMTYgTCA4MjcgMTE2NiBMIDg0MiAxMTE3IEwgODU4IDEwNTEgTCA4NzAgOTk2IEwgODc1IDk2NyBMIDg4MSA5MTcgTCA4ODYgODgyIEwgODkxIDg0NiBMIDg5NCA4MTIgTCA4OTYgNzc0IEwgODk2IDY1NSBMIDg5NSA2MzAgTCA4OTIgNTkzIEwgODg4IDU1OSBMIDg3NyA0NzAgTCA4NzIgNDQ1IEwgODY1IDQyNiBMIDg1NyA0MTEgTCA4NDcgMzk1IEwgODM0IDM3OCBMIDgxNCAzNTggTCA4MDAgMzQ3IEwgNzg1IDMzNyBMIDc2NiAzMjcgTCA3NTAgMzIxIEwgNzI3IDMxNSBMIDY5MyAzMDggTCA2NTAgMzAxIEwgNTk3IDI5MiBMIDU3MCAyODYgTCA1NDggMjgwIEwgNTE1IDI3MCBMIDQ4NyAyNjEgTCA0NTkgMjUwIEwgNDM4IDI0MSBMIDQxNCAyMzAgTCAzODIgMjEzIEwgMzU4IDE5OSBMIDM0NyAxOTIgTCAzMzAgMTgwIEwgMzEyIDE2NyBMIDMwMCAxNTcgTCAyODYgMTQ2IEwgMjcxIDEzMyBMIDI2MyAxMjYgTCAyNTEgMTE1IEwgMjQwIDEwNiBMIDIyOCA5NSBMIDIwMCA3MSBMIDE4OSA2MiBMIDE3NCA1MCBMIDE1OCAzOCBMIDE0MiAyNyBMIDEyNyAxOSBMIDExNSAxNCBMIDk1IDggTCA3MSAwIEwgMCAwIHogTSAzMCA2MjIgTCA1MSA2MjMgTCA3OSA2MjcgTCAxMDAgNjMzIEwgMTIxIDY0MSBMIDE0MSA2NTIgTCAxNTcgNjYzIEwgMTcyIDY3NiBMIDE4NiA2OTAgTCAxODYgNjkyIEwgMTg4IDY5MiBMIDIwNiA3MTYgTCAyMTYgNzM0IEwgMjIxIDc0NSBMIDIyNiA3NTggTCAyMzMgNzg0IEwgMjM2IDc5OCBMIDIzNyA4MDggTCAyMzcgODQwIEwgMjMzIDg2NSBMIDIyNiA4OTEgTCAyMTkgOTA5IEwgMjA5IDkyOCBMIDE5NSA5NDkgTCAxODYgOTU5IEwgMTc5IDk2NyBMIDE2NiA5ODAgTCAxNTUgOTg5IEwgMTQyIDk5OCBMIDEyNiAxMDA3IEwgMTEzIDEwMTIgTCAxMTQgMTAyNCBMIDEyMiAxMDYwIEwgMTM3IDExMjMgTCAxNTYgMTIwOCBMIDE3MCAxMjcxIEwgMTc0IDEyOTYgTCAxNzYgMTMxMCBMIDE3NiAxMzM1IEwgMTczIDEzNDkgTCAxNjYgMTM1OSBMIDE1NyAxMzY3IEwgMTQ2IDEzNzIgTCAxNDAgMTM3NCBMIDExOSAxMzc2IEwgNTEgMTM3NiBMIC0zMiAxMzc3IEwgLTY0IDEzNzcgTCAtNzggMTM3NCBMIC04NSAxMzcwIEwgLTk4IDEzNTkgTCAtMTA2IDEzNDkgTCAtMTEwIDEzNDEgTCAtMTEyIDEzMzMgTCAtMTEyIDEzMTggTCAtMTA3IDEyODggTCAtMTAwIDEyNTYgTCAtODUgMTE5NiBMIC02OSAxMTI0IEwgLTU3IDEwNzIgTCAtNTAgMTAzNiBMIC00NyAxMDExIEwgLTUzIDEwMDkgTCAtNjkgMTAwMSBMIC04MSA5OTQgTCAtOTMgOTg1IEwgLTEwMyA5NzYgTCAtMTExIDk2OSBMIC0xMjAgOTYwIEwgLTEzMSA5NDYgTCAtMTQyIDkyOSBMIC0xNTIgOTEwIEwgLTE2MCA4OTAgTCAtMTY2IDg3MCBMIC0xNjkgODUzIEwgLTE3MCA4NDQgTCAtMTcwIDgxNCBMIC0xNjcgNzk0IEwgLTE2MSA3NjcgTCAtMTU0IDc0NiBMIC0xMzcgNzEzIEwgLTEyNiA2OTggTCAtMTE5IDY5MCBMIC0xMDggNjc4IEwgLTkzIDY2NSBMIC03NyA2NTMgTCAtNTYgNjQxIEwgLTM4IDYzMyBMIC0xNiA2MjcgTCAzIDYyNCBMIDMwIDYyMiB6ICIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTkxKSIgc3R5bGU9ImZpbGw6I2ZmZmZmZiIgLz4KPC9zdmc+Cg==",
|
||||||
"icon_light": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAyMDQ4IDIwNDgiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSg5OTEpIiBkPSJtMCAwaDcxbDI0IDggMjAgNiAxMiA1IDE1IDggMTYgMTEgMTYgMTIgMTUgMTIgMTEgOSAyOCAyNCAxMiAxMSAxMSA5IDEyIDExIDggNyAxNSAxMyAxNCAxMSAxMiAxMCAxOCAxMyAxNyAxMiAxMSA3IDI0IDE0IDMyIDE3IDI0IDExIDIxIDkgMjggMTEgMjggOSAzMyAxMCAyMiA2IDI3IDYgNTMgOSA0MyA3IDM0IDcgMjMgNiAxNiA2IDE5IDEwIDE1IDEwIDE0IDExIDIwIDIwIDEzIDE3IDEwIDE2IDggMTUgNyAxOSA1IDI1IDExIDg5IDQgMzQgMyAzNyAxIDI1djExOWwtMiAzOC0zIDM0LTUgMzYtNSAzNS02IDUwLTUgMjktMTIgNTUtMTYgNjYtMTUgNDktMTYgNTAtMTkgNTEtMTggNDMtMTIgMjgtMTcgMzYtMTUgMzEtMjQgNDQtMTAgMTgtMTEgMTgtMTUgMjUtMTUgMjMtMTAgMTYtMTQgMjAtMTAgMTQtMTUgMjItMTYgMjEtMTEgMTQtMTMgMTYtOCAxMS0xMSAxMy05IDExLTEyIDEzLTcgOC05IDEwLTcgOC0xNiAxNy04IDloLTJsLTIgNC00OSA0OS04IDctNyA3LTggNy03IDctMTEgOS0zIDNoLTJ2MmwtMTEgOS0xMSAxMC05IDctMTEgMTAtMTQgMTEtMTggMTMtMTEgOS0xMSA4LTE4IDEzLTM1IDI0LTIxIDE0LTI0IDE1LTE5IDExLTE4IDEwLTI4IDE0LTIxIDgtMjcgNy0xMiAzLTEgMWgtNDhsLTQ0LTExLTIwLTctMjAtOS0xNi04LTI0LTEzLTIxLTEzLTE1LTEwLTIwLTE0LTE2LTExLTEyLTktMTktMTQtMTMtMTAtMTYtMTItMTctMTQtMTAtOC0xNy0xNC0yNi0yMi0xMy0xMi04LTctMTYtMTUtMTctMTYtNTctNTctNy04LTEwLTEwLTctOC0xMS0xMi0xMC0xMS05LTExLTI4LTM0LTEzLTE2LTEzLTE3LTExLTE1LTE0LTE5LTEzLTE4LTEzLTE5LTEwLTE1LTEzLTIwLTEzLTIxLTE5LTMyLTE0LTI1LTE1LTI4LTIxLTQyLTE1LTMzLTE2LTM4LTE0LTM2LTEwLTI5LTctMTgtMTQtNDUtMTgtNjQtNi0yNi0xMS01Mi0xMi02My00LTI5LTYtNjItNi03Mi0zLTUyLTEtMzB2LTU5bDItNjEgMy00NyA1LTQ1IDYtMzUgNi0xOCA5LTE5IDEwLTE4IDEwLTE1IDgtMTAgNS02aDJsMi00IDgtOCAxMS05IDEwLTcgMjEtMTMgMTYtOCAxNy02IDM0LTggMzItNyA2NC0xMSA0OS05IDMwLTggMjMtOCA0Mi0xNCAyMS05IDMzLTE1IDIzLTExIDIxLTEyIDE4LTEwIDIxLTE0IDIwLTEzIDExLTggMTMtMTAgMTQtMTEgMTEtOSAxMy0xMSA4LTcgOS05aDJ2LTJsOC03IDEwLTEwaDJsMS0zIDgtNyAxMy0xMyAxMS05IDctNyAxOS0xNCAxNC05IDE5LTExIDE5LTcgMjMtNyAxMC0zeiIgZmlsbD0iI0YxODQyOSIvPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMDIxLDYyMikiIGQ9Im0wIDAgMjEgMSAyOCA0IDIxIDYgMjEgOCAyMCAxMSAxNiAxMSAxNSAxMyAxNCAxNHYyaDJsMTggMjQgMTAgMTggNSAxMSA1IDEzIDcgMjYgMyAxNCAxIDEwdjMybC00IDI1LTcgMjYtNyAxOC0xMCAxOS0xNCAyMS05IDEwLTcgOC0xMyAxMy0xMSA5LTEzIDktMTYgOS0xMyA1IDEgMTIgOCAzNiAxNSA2MyAxOSA4NSAxNCA2MyA0IDI1IDIgMTR2MjVsLTMgMTQtNyAxMC05IDgtMTEgNS02IDItMjEgMmgtNjhsLTgzIDFoLTMybC0xNC0zLTctNC0xMy0xMS04LTEwLTQtOC0yLTh2LTE1bDUtMzAgNy0zMiAxNS02MCAxNi03MiAxMi01MiA3LTM2IDMtMjUtNi0yLTE2LTgtMTItNy0xMi05LTEwLTktOC03LTktOS0xMS0xNC0xMS0xNy0xMC0xOS04LTIwLTYtMjAtMy0xNy0xLTl2LTMwbDMtMjAgNi0yNyA3LTIxIDE3LTMzIDExLTE1IDctOCAxMS0xMiAxNS0xMyAxNi0xMiAyMS0xMiAxOC04IDIyLTYgMTktM3oiIGZpbGw9IiNGRUZGRkUiLz4KPC9zdmc+Cg=="
|
"icon_light": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAyMDQ4IDIwNDgiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSg5OTEpIiBkPSJtMCAwaDcxbDI0IDggMjAgNiAxMiA1IDE1IDggMTYgMTEgMTYgMTIgMTUgMTIgMTEgOSAyOCAyNCAxMiAxMSAxMSA5IDEyIDExIDggNyAxNSAxMyAxNCAxMSAxMiAxMCAxOCAxMyAxNyAxMiAxMSA3IDI0IDE0IDMyIDE3IDI0IDExIDIxIDkgMjggMTEgMjggOSAzMyAxMCAyMiA2IDI3IDYgNTMgOSA0MyA3IDM0IDcgMjMgNiAxNiA2IDE5IDEwIDE1IDEwIDE0IDExIDIwIDIwIDEzIDE3IDEwIDE2IDggMTUgNyAxOSA1IDI1IDExIDg5IDQgMzQgMyAzNyAxIDI1djExOWwtMiAzOC0zIDM0LTUgMzYtNSAzNS02IDUwLTUgMjktMTIgNTUtMTYgNjYtMTUgNDktMTYgNTAtMTkgNTEtMTggNDMtMTIgMjgtMTcgMzYtMTUgMzEtMjQgNDQtMTAgMTgtMTEgMTgtMTUgMjUtMTUgMjMtMTAgMTYtMTQgMjAtMTAgMTQtMTUgMjItMTYgMjEtMTEgMTQtMTMgMTYtOCAxMS0xMSAxMy05IDExLTEyIDEzLTcgOC05IDEwLTcgOC0xNiAxNy04IDloLTJsLTIgNC00OSA0OS04IDctNyA3LTggNy03IDctMTEgOS0zIDNoLTJ2MmwtMTEgOS0xMSAxMC05IDctMTEgMTAtMTQgMTEtMTggMTMtMTEgOS0xMSA4LTE4IDEzLTM1IDI0LTIxIDE0LTI0IDE1LTE5IDExLTE4IDEwLTI4IDE0LTIxIDgtMjcgNy0xMiAzLTEgMWgtNDhsLTQ0LTExLTIwLTctMjAtOS0xNi04LTI0LTEzLTIxLTEzLTE1LTEwLTIwLTE0LTE2LTExLTEyLTktMTktMTQtMTMtMTAtMTYtMTItMTctMTQtMTAtOC0xNy0xNC0yNi0yMi0xMy0xMi04LTctMTYtMTUtMTctMTYtNTctNTctNy04LTEwLTEwLTctOC0xMS0xMi0xMC0xMS05LTExLTI4LTM0LTEzLTE2LTEzLTE3LTExLTE1LTE0LTE5LTEzLTE4LTEzLTE5LTEwLTE1LTEzLTIwLTEzLTIxLTE5LTMyLTE0LTI1LTE1LTI4LTIxLTQyLTE1LTMzLTE2LTM4LTE0LTM2LTEwLTI5LTctMTgtMTQtNDUtMTgtNjQtNi0yNi0xMS01Mi0xMi02My00LTI5LTYtNjItNi03Mi0zLTUyLTEtMzB2LTU5bDItNjEgMy00NyA1LTQ1IDYtMzUgNi0xOCA5LTE5IDEwLTE4IDEwLTE1IDgtMTAgNS02aDJsMi00IDgtOCAxMS05IDEwLTcgMjEtMTMgMTYtOCAxNy02IDM0LTggMzItNyA2NC0xMSA0OS05IDMwLTggMjMtOCA0Mi0xNCAyMS05IDMzLTE1IDIzLTExIDIxLTEyIDE4LTEwIDIxLTE0IDIwLTEzIDExLTggMTMtMTAgMTQtMTEgMTEtOSAxMy0xMSA4LTcgOS05aDJ2LTJsOC03IDEwLTEwaDJsMS0zIDgtNyAxMy0xMyAxMS05IDctNyAxOS0xNCAxNC05IDE5LTExIDE5LTcgMjMtNyAxMC0zeiIgZmlsbD0iI0YxODQyOSIvPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMDIxLDYyMikiIGQ9Im0wIDAgMjEgMSAyOCA0IDIxIDYgMjEgOCAyMCAxMSAxNiAxMSAxNSAxMyAxNCAxNHYyaDJsMTggMjQgMTAgMTggNSAxMSA1IDEzIDcgMjYgMyAxNCAxIDEwdjMybC00IDI1LTcgMjYtNyAxOC0xMCAxOS0xNCAyMS05IDEwLTcgOC0xMyAxMy0xMSA5LTEzIDktMTYgOS0xMyA1IDEgMTIgOCAzNiAxNSA2MyAxOSA4NSAxNCA2MyA0IDI1IDIgMTR2MjVsLTMgMTQtNyAxMC05IDgtMTEgNS02IDItMjEgMmgtNjhsLTgzIDFoLTMybC0xNC0zLTctNC0xMy0xMS04LTEwLTQtOC0yLTh2LTE1bDUtMzAgNy0zMiAxNS02MCAxNi03MiAxMi01MiA3LTM2IDMtMjUtNi0yLTE2LTgtMTItNy0xMi05LTEwLTktOC03LTktOS0xMS0xNC0xMS0xNy0xMC0xOS04LTIwLTYtMjAtMy0xNy0xLTl2LTMwbDMtMjAgNi0yNyA3LTIxIDE3LTMzIDExLTE1IDctOCAxMS0xMiAxNS0xMyAxNi0xMiAyMS0xMiAxOC04IDIyLTYgMTktM3oiIGZpbGw9IiNGRUZGRkUiLz4KPC9zdmc+Cg=="
|
||||||
},
|
}
|
||||||
"a10c6dd9-465e-4226-8198-c7c44b91c555": {
|
}
|
||||||
"name": "Kaspersky Password Manager",
|
|
||||||
"icon_dark": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzc0ODRfODc0NSkiPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMV83NDg0Xzg3NDUpIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDJfNzQ4NF84NzQ1KSI+PHBhdGggZD0iTTI4MS45NjQgNi45NjE3MUMyNjUuODkzIC0yLjI5OTc0IDI0Ni4xMDcgLTIuMjk5NzQgMjMwLjAzNiA2Ljk2MTcxTDQ2LjAzNjUgMTEzLjAwMkMyOS45MjcxIDEyMi4yODYgMjAgMTM5LjQ2NiAyMCAxNTguMDZWMzUzLjk3MkMyMCAzNzIuNTY2IDI5LjkyNzEgMzg5Ljc0NSA0Ni4wMzY1IDM5OS4wMjlMMjMwLjAzNiA1MDUuMDdDMjQ2LjEwNyA1MTQuMzMxIDI2NS44OTMgNTE0LjMzMSAyODEuOTY0IDUwNS4wN0w0NjUuOTY0IDM5OS4wMjlDNDgyLjA3MyAzODkuNzQ1IDQ5MiAzNzIuNTY2IDQ5MiAzNTMuOTcyVjE1OC4wNkM0OTIgMTM5LjQ2NiA0ODIuMDczIDEyMi4yODYgNDY1Ljk2NCAxMTMuMDAyTDI4MS45NjQgNi45NjE3MVoiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl83NDg0Xzg3NDUpIi8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00NTMuOTggMTMzLjc5OEwyNjkuOTggMjcuNzU3NEMyNjEuMzI3IDIyLjc3MDQgMjUwLjY3MyAyMi43NzA0IDI0Mi4wMiAyNy43NTc0TDU4LjAxOTYgMTMzLjc5OEM0OS4zNDUzIDEzOC43OTcgNDQgMTQ4LjA0NyA0NCAxNTguMDZWMzUzLjk3MkM0NCAzNjMuOTg0IDQ5LjM0NTMgMzczLjIzNCA1OC4wMTk2IDM3OC4yMzNMMjQyLjAyIDQ4NC4yNzRDMjUwLjY3MyA0ODkuMjYxIDI2MS4zMjcgNDg5LjI2MSAyNjkuOTggNDg0LjI3NEw0NTMuOTggMzc4LjIzM0M0NjIuNjU1IDM3My4yMzQgNDY4IDM2My45ODQgNDY4IDM1My45NzJWMTU4LjA2QzQ2OCAxNDguMDQ3IDQ2Mi42NTUgMTM4Ljc5NyA0NTMuOTggMTMzLjc5OFpNMjgxLjk2NCA2Ljk2MTcxQzI2NS44OTMgLTIuMjk5NzQgMjQ2LjEwNyAtMi4yOTk3NCAyMzAuMDM2IDYuOTYxNzFMNDYuMDM2NSAxMTMuMDAyQzI5LjkyNzEgMTIyLjI4NiAyMCAxMzkuNDY2IDIwIDE1OC4wNlYzNTMuOTcyQzIwIDM3Mi41NjYgMjkuOTI3MSAzODkuNzQ1IDQ2LjAzNjUgMzk5LjAyOUwyMzAuMDM2IDUwNS4wN0MyNDYuMTA3IDUxNC4zMzEgMjY1Ljg5MyA1MTQuMzMxIDI4MS45NjQgNTA1LjA3TDQ2NS45NjQgMzk5LjAyOUM0ODIuMDczIDM4OS43NDUgNDkyIDM3Mi41NjYgNDkyIDM1My45NzJWMTU4LjA2QzQ5MiAxMzkuNDY2IDQ4Mi4wNzMgMTIyLjI4NiA0NjUuOTY0IDExMy4wMDJMMjgxLjk2NCA2Ljk2MTcxWiIgZmlsbD0iYmxhY2siLz48L2c+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yNTYgMTQwQzI0Mi43NDUgMTQwIDIzMiAxNTAuNzQ1IDIzMiAxNjRDMjMyIDE3Ny4yNTUgMjQyLjc0NSAxODggMjU2IDE4OEMyNjkuMjU1IDE4OCAyODAgMTc3LjI1NSAyODAgMTY0QzI4MCAxNTAuNzQ1IDI2OS4yNTUgMTQwIDI1NiAxNDBaTTI0OCAxNjRDMjQ4IDE1OS41ODIgMjUxLjU4MiAxNTYgMjU2IDE1NkMyNjAuNDE4IDE1NiAyNjQgMTU5LjU4MiAyNjQgMTY0QzI2NCAxNjguNDE4IDI2MC40MTggMTcyIDI1NiAxNzJDMjUxLjU4MiAxNzIgMjQ4IDE2OC40MTggMjQ4IDE2NFoiIGZpbGw9ImJsYWNrIi8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNzIgMTkyQzE3MiAxNDUuNjA4IDIwOS42MDggMTA4IDI1NiAxMDhDMzAyLjM5MiAxMDggMzQwIDE0NS42MDggMzQwIDE5MkMzNDAgMjI3LjA2MSAzMTguNTE5IDI1Ny4xMDUgMjg4IDI2OS42OVYzODYuNjdDMjg4IDM5Mi4zOTEgMjg0Ljk0NiAzOTcuNjc2IDI3OS45ODkgNDAwLjUzM0wyNjMuOTg5IDQwOS43NTNDMjU5LjA0NCA0MTIuNjAzIDI1Mi45NTYgNDEyLjYwMyAyNDguMDExIDQwOS43NTNMMjMyLjAxMSA0MDAuNTMzQzIyNy4wNTQgMzk3LjY3NiAyMjQgMzkyLjM5MSAyMjQgMzg2LjY3VjM3MkMyMjQgMzY5Ljg3OCAyMjQuODQzIDM2Ny44NDQgMjI2LjM0MyAzNjYuMzQzTDIzNiAzNTYuNjg2VjM1NS4zMTRMMjI2LjM0MyAzNDUuNjU3QzIyNC44NDMgMzQ0LjE1NyAyMjQgMzQyLjEyMiAyMjQgMzQwVjMzMkMyMjQgMzI5Ljg3OCAyMjQuODQzIDMyNy44NDQgMjI2LjM0MyAzMjYuMzQzTDIzNiAzMTYuNjg2VjMxNS4zMTRMMjI2LjM0MyAzMDUuNjU3QzIyNC44NDMgMzA0LjE1NyAyMjQgMzAyLjEyMiAyMjQgMzAwVjI2OS42OUMxOTMuNDgxIDI1Ny4xMDUgMTcyIDIyNy4wNjEgMTcyIDE5MlpNMjU2IDEyNEMyMTguNDQ1IDEyNCAxODggMTU0LjQ0NSAxODggMTkyQzE4OCAyMjkuNTU1IDIxOC40NDUgMjYwIDI1NiAyNjBDMjkzLjU1NSAyNjAgMzI0IDIyOS41NTUgMzI0IDE5MkMzMjQgMTU0LjQ0NSAyOTMuNTU1IDEyNCAyNTYgMTI0Wk0yNTYgMjc2QzI2MS40NzEgMjc2IDI2Ni44MiAyNzUuNDc3IDI3MiAyNzQuNDc4VjM4Ni42N0wyNTYgMzk1Ljg5TDI0MCAzODYuNjdWMzc1LjMxNEwyNDkuNjU3IDM2NS42NTdDMjUxLjE1NyAzNjQuMTU3IDI1MiAzNjIuMTIyIDI1MiAzNjBWMzUyQzI1MiAzNDkuODc4IDI1MS4xNTcgMzQ3Ljg0NCAyNDkuNjU3IDM0Ni4zNDNMMjQwIDMzNi42ODZWMzM1LjMxNEwyNDkuNjU3IDMyNS42NTdDMjUxLjE1NyAzMjQuMTU3IDI1MiAzMjIuMTIyIDI1MiAzMjBWMzEyQzI1MiAzMDkuODc4IDI1MS4xNTcgMzA3Ljg0NCAyNDkuNjU3IDMwNi4zNDNMMjQwIDI5Ni42ODZWMjc0LjQ3OEMyNDUuMTggMjc1LjQ3NyAyNTAuNTI5IDI3NiAyNTYgMjc2WiIgZmlsbD0iYmxhY2siLz48L2c+PC9nPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl83NDg0Xzg3NDUiIHgxPSIzOTMuODY1IiB5MT0iNjMuMjc5NiIgeDI9Ijk5LjIwNDMiIHkyPSI0MjkuOTk4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iIzRERkY4OCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzNERThDQSIvPjwvbGluZWFyR3JhZGllbnQ+PGNsaXBQYXRoIGlkPSJjbGlwMF83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwMV83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwMl83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==",
|
|
||||||
"icon_light": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzc0ODRfODc0NSkiPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMV83NDg0Xzg3NDUpIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDJfNzQ4NF84NzQ1KSI+PHBhdGggZD0iTTI4MS45NjQgNi45NjE3MUMyNjUuODkzIC0yLjI5OTc0IDI0Ni4xMDcgLTIuMjk5NzQgMjMwLjAzNiA2Ljk2MTcxTDQ2LjAzNjUgMTEzLjAwMkMyOS45MjcxIDEyMi4yODYgMjAgMTM5LjQ2NiAyMCAxNTguMDZWMzUzLjk3MkMyMCAzNzIuNTY2IDI5LjkyNzEgMzg5Ljc0NSA0Ni4wMzY1IDM5OS4wMjlMMjMwLjAzNiA1MDUuMDdDMjQ2LjEwNyA1MTQuMzMxIDI2NS44OTMgNTE0LjMzMSAyODEuOTY0IDUwNS4wN0w0NjUuOTY0IDM5OS4wMjlDNDgyLjA3MyAzODkuNzQ1IDQ5MiAzNzIuNTY2IDQ5MiAzNTMuOTcyVjE1OC4wNkM0OTIgMTM5LjQ2NiA0ODIuMDczIDEyMi4yODYgNDY1Ljk2NCAxMTMuMDAyTDI4MS45NjQgNi45NjE3MVoiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl83NDg0Xzg3NDUpIi8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00NTMuOTggMTMzLjc5OEwyNjkuOTggMjcuNzU3NEMyNjEuMzI3IDIyLjc3MDQgMjUwLjY3MyAyMi43NzA0IDI0Mi4wMiAyNy43NTc0TDU4LjAxOTYgMTMzLjc5OEM0OS4zNDUzIDEzOC43OTcgNDQgMTQ4LjA0NyA0NCAxNTguMDZWMzUzLjk3MkM0NCAzNjMuOTg0IDQ5LjM0NTMgMzczLjIzNCA1OC4wMTk2IDM3OC4yMzNMMjQyLjAyIDQ4NC4yNzRDMjUwLjY3MyA0ODkuMjYxIDI2MS4zMjcgNDg5LjI2MSAyNjkuOTggNDg0LjI3NEw0NTMuOTggMzc4LjIzM0M0NjIuNjU1IDM3My4yMzQgNDY4IDM2My45ODQgNDY4IDM1My45NzJWMTU4LjA2QzQ2OCAxNDguMDQ3IDQ2Mi42NTUgMTM4Ljc5NyA0NTMuOTggMTMzLjc5OFpNMjgxLjk2NCA2Ljk2MTcxQzI2NS44OTMgLTIuMjk5NzQgMjQ2LjEwNyAtMi4yOTk3NCAyMzAuMDM2IDYuOTYxNzFMNDYuMDM2NSAxMTMuMDAyQzI5LjkyNzEgMTIyLjI4NiAyMCAxMzkuNDY2IDIwIDE1OC4wNlYzNTMuOTcyQzIwIDM3Mi41NjYgMjkuOTI3MSAzODkuNzQ1IDQ2LjAzNjUgMzk5LjAyOUwyMzAuMDM2IDUwNS4wN0MyNDYuMTA3IDUxNC4zMzEgMjY1Ljg5MyA1MTQuMzMxIDI4MS45NjQgNTA1LjA3TDQ2NS45NjQgMzk5LjAyOUM0ODIuMDczIDM4OS43NDUgNDkyIDM3Mi41NjYgNDkyIDM1My45NzJWMTU4LjA2QzQ5MiAxMzkuNDY2IDQ4Mi4wNzMgMTIyLjI4NiA0NjUuOTY0IDExMy4wMDJMMjgxLjk2NCA2Ljk2MTcxWiIgZmlsbD0iYmxhY2siLz48L2c+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yNTYgMTQwQzI0Mi43NDUgMTQwIDIzMiAxNTAuNzQ1IDIzMiAxNjRDMjMyIDE3Ny4yNTUgMjQyLjc0NSAxODggMjU2IDE4OEMyNjkuMjU1IDE4OCAyODAgMTc3LjI1NSAyODAgMTY0QzI4MCAxNTAuNzQ1IDI2OS4yNTUgMTQwIDI1NiAxNDBaTTI0OCAxNjRDMjQ4IDE1OS41ODIgMjUxLjU4MiAxNTYgMjU2IDE1NkMyNjAuNDE4IDE1NiAyNjQgMTU5LjU4MiAyNjQgMTY0QzI2NCAxNjguNDE4IDI2MC40MTggMTcyIDI1NiAxNzJDMjUxLjU4MiAxNzIgMjQ4IDE2OC40MTggMjQ4IDE2NFoiIGZpbGw9ImJsYWNrIi8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNzIgMTkyQzE3MiAxNDUuNjA4IDIwOS42MDggMTA4IDI1NiAxMDhDMzAyLjM5MiAxMDggMzQwIDE0NS42MDggMzQwIDE5MkMzNDAgMjI3LjA2MSAzMTguNTE5IDI1Ny4xMDUgMjg4IDI2OS42OVYzODYuNjdDMjg4IDM5Mi4zOTEgMjg0Ljk0NiAzOTcuNjc2IDI3OS45ODkgNDAwLjUzM0wyNjMuOTg5IDQwOS43NTNDMjU5LjA0NCA0MTIuNjAzIDI1Mi45NTYgNDEyLjYwMyAyNDguMDExIDQwOS43NTNMMjMyLjAxMSA0MDAuNTMzQzIyNy4wNTQgMzk3LjY3NiAyMjQgMzkyLjM5MSAyMjQgMzg2LjY3VjM3MkMyMjQgMzY5Ljg3OCAyMjQuODQzIDM2Ny44NDQgMjI2LjM0MyAzNjYuMzQzTDIzNiAzNTYuNjg2VjM1NS4zMTRMMjI2LjM0MyAzNDUuNjU3QzIyNC44NDMgMzQ0LjE1NyAyMjQgMzQyLjEyMiAyMjQgMzQwVjMzMkMyMjQgMzI5Ljg3OCAyMjQuODQzIDMyNy44NDQgMjI2LjM0MyAzMjYuMzQzTDIzNiAzMTYuNjg2VjMxNS4zMTRMMjI2LjM0MyAzMDUuNjU3QzIyNC44NDMgMzA0LjE1NyAyMjQgMzAyLjEyMiAyMjQgMzAwVjI2OS42OUMxOTMuNDgxIDI1Ny4xMDUgMTcyIDIyNy4wNjEgMTcyIDE5MlpNMjU2IDEyNEMyMTguNDQ1IDEyNCAxODggMTU0LjQ0NSAxODggMTkyQzE4OCAyMjkuNTU1IDIxOC40NDUgMjYwIDI1NiAyNjBDMjkzLjU1NSAyNjAgMzI0IDIyOS41NTUgMzI0IDE5MkMzMjQgMTU0LjQ0NSAyOTMuNTU1IDEyNCAyNTYgMTI0Wk0yNTYgMjc2QzI2MS40NzEgMjc2IDI2Ni44MiAyNzUuNDc3IDI3MiAyNzQuNDc4VjM4Ni42N0wyNTYgMzk1Ljg5TDI0MCAzODYuNjdWMzc1LjMxNEwyNDkuNjU3IDM2NS42NTdDMjUxLjE1NyAzNjQuMTU3IDI1MiAzNjIuMTIyIDI1MiAzNjBWMzUyQzI1MiAzNDkuODc4IDI1MS4xNTcgMzQ3Ljg0NCAyNDkuNjU3IDM0Ni4zNDNMMjQwIDMzNi42ODZWMzM1LjMxNEwyNDkuNjU3IDMyNS42NTdDMjUxLjE1NyAzMjQuMTU3IDI1MiAzMjIuMTIyIDI1MiAzMjBWMzEyQzI1MiAzMDkuODc4IDI1MS4xNTcgMzA3Ljg0NCAyNDkuNjU3IDMwNi4zNDNMMjQwIDI5Ni42ODZWMjc0LjQ3OEMyNDUuMTggMjc1LjQ3NyAyNTAuNTI5IDI3NiAyNTYgMjc2WiIgZmlsbD0iYmxhY2siLz48L2c+PC9nPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl83NDg0Xzg3NDUiIHgxPSIzOTMuODY1IiB5MT0iNjMuMjc5NiIgeDI9Ijk5LjIwNDMiIHkyPSI0MjkuOTk4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iIzRERkY4OCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzNERThDQSIvPjwvbGluZWFyR3JhZGllbnQ+PGNsaXBQYXRoIGlkPSJjbGlwMF83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwMV83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwMl83NDg0Xzg3NDUiPjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg=="
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user