Compare commits

..

1 Commits

Author SHA1 Message Date
3d06924f42 Add human friendly labels to tasks. 2025-04-29 19:40:00 +02:00
889 changed files with 21734 additions and 53837 deletions

36
.bumpversion.cfg Normal file
View File

@ -0,0 +1,36 @@
[bumpversion]
current_version = 2025.2.4
tag = 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*))?
serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch}
message = release: {new_version}
tag_name = version/{new_version}
[bumpversion:part:rc_t]
values =
rc
final
optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml]
[bumpversion:file:blueprints/schema.json]
[bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/common/constants.ts]
[bumpversion:file:lifecycle/aws/template.yaml]

View File

@ -1,6 +1,5 @@
---
name: "Prepare docker environment variables"
description: "Prepare docker environment variables"
inputs:
image-name:

View File

@ -1,9 +1,13 @@
"""Helper script to get the actual branch name, docker safe"""
import configparser
import os
from importlib.metadata import version as package_version
from json import dumps
from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
# Decide if we should push the image or not
should_push = True
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
@ -27,7 +31,7 @@ is_release = "dev" not in image_names[0]
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
# 2042.1.0 or 2042.1.0-rc1
version = package_version("authentik")
version = parser.get("bumpversion", "current_version")
# 2042.1
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
prerelease = "-" in version

View File

@ -1,4 +1,4 @@
name: "Setup authentik testing environment"
name: Setup authentik testing environment
description: "Setup authentik testing environment"
inputs:
@ -36,7 +36,7 @@ runs:
with:
go-version-file: "go.mod"
- name: Setup docker cache
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
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

View File

@ -23,13 +23,7 @@ updates:
- package-ecosystem: npm
directories:
- "/web"
- "/web/packages/sfe"
- "/web/packages/core"
- "/web/packages/esbuild-plugin-live-reload"
- "/packages/prettier-config"
- "/packages/tsconfig"
- "/packages/docusaurus-config"
- "/packages/eslint-config"
- "/web/sfe"
schedule:
interval: daily
time: "04:00"
@ -74,9 +68,6 @@ updates:
wdio:
patterns:
- "@wdio/*"
goauthentik:
patterns:
- "@goauthentik/*"
- package-ecosystem: npm
directory: "/website"
schedule:
@ -97,9 +88,6 @@ updates:
- "swc-*"
- "lightningcss*"
- "@rspack/binding*"
goauthentik:
patterns:
- "@goauthentik/*"
- package-ecosystem: npm
directory: "/lifecycle/aws"
schedule:
@ -130,15 +118,3 @@ updates:
prefix: "core:"
labels:
- dependencies
- package-ecosystem: docker-compose
directories:
# - /scripts # Maybe
- /tests/e2e
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "core:"
labels:
- dependencies

View File

@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3.6.0
- uses: docker/setup-buildx-action@v3
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
@ -64,12 +64,12 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
- name: Make empty clients
if: ${{ inputs.release }}
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: generate ts client
- name: Generate TypeScript API Client
if: ${{ !inputs.release }}
run: make gen-client-ts
- name: Build Docker Image

View File

@ -49,7 +49,7 @@ jobs:
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
@ -69,7 +69,7 @@ jobs:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:

View File

@ -7,6 +7,7 @@ on:
workflow_dispatch:
jobs:
build:
name: Build and Publish
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
permissions:
@ -14,6 +15,7 @@ jobs:
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
name: Generate token
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@ -30,7 +32,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Generate API Client
- name: Generate Python API Client
run: make gen-client-py
- name: Publish package
working-directory: gen-py-api/

View File

@ -7,6 +7,7 @@ on:
workflow_dispatch:
jobs:
build:
name: Build and Publish
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
@ -22,7 +23,7 @@ jobs:
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"
- name: Generate API Client
- name: Generate TypeScript API Client
run: make gen-client-ts
- name: Publish package
working-directory: gen-ts-api/
@ -53,7 +54,6 @@ jobs:
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -18,6 +18,7 @@ env:
jobs:
check-changes-applied:
name: Check changes applied
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -36,6 +37,7 @@ jobs:
uv run make aws-cfn
git diff --exit-code
ci-aws-cfn-mark:
name: CI AWS CloudFormation Mark
if: always()
needs:
- check-changes-applied

View File

@ -9,6 +9,7 @@ on:
jobs:
test-container:
name: Test Container ${{ matrix.version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
@ -19,6 +20,7 @@ jobs:
- version-2024-12
steps:
- uses: actions/checkout@v4
name: ${{ matrix.version }} Setup
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"

View File

@ -19,6 +19,7 @@ env:
jobs:
lint:
name: Lint
strategy:
fail-fast: false
matrix:
@ -33,9 +34,10 @@ jobs:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
- name: Run job ${{ matrix.job }}
run: uv run make ci-${{ matrix.job }}
test-migrations:
name: Test Migrations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -44,6 +46,7 @@ jobs:
- name: run migrations
run: uv run python -m lifecycle.migrate
test-make-seed:
name: Test Make Seed
runs-on: ubuntu-latest
steps:
- id: seed
@ -52,7 +55,7 @@ jobs:
outputs:
seed: ${{ steps.seed.outputs.seed }}
test-migrations-from-stable:
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
name: Test Migrations From Stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest
timeout-minutes: 20
needs: test-make-seed
@ -62,28 +65,31 @@ jobs:
psql:
- 15-alpine
- 16-alpine
- 17-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: checkout stable
- name: Checkout Stable
run: |
# Copy current, latest config to local
# Temporarly comment the .github backup while migrating to uv
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
# cp -R .github ..
cp -R scripts ..
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
rm -rf .github/ scripts/
mv ../.github ../scripts .
# rm -rf .github/ scripts/
# mv ../.github ../scripts .
rm -rf scripts/
mv ../scripts .
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: run migrations to stable
run: uv run python -m lifecycle.migrate
- name: checkout current code
continue-on-error: true
- name: Run migrations to stable
run: poetry run python -m lifecycle.migrate
- name: Checkout current code
run: |
set -x
git fetch
@ -94,10 +100,10 @@ jobs:
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: migrate to latest
- name: Migrate to latest
run: |
uv run python -m lifecycle.migrate
- name: run tests
- name: Run tests
env:
# Test in the main database that we just migrated from the previous stable version
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
@ -107,7 +113,7 @@ jobs:
run: |
uv run make ci-test
test-unittest:
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
name: Unit tests - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest
timeout-minutes: 20
needs: test-make-seed
@ -117,11 +123,10 @@ jobs:
psql:
- 15-alpine
- 16-alpine
- 17-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
- name: Setup authentik env (${{ matrix.psql }})
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
@ -144,6 +149,7 @@ jobs:
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-integration:
name: Integration tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@ -152,7 +158,7 @@ jobs:
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.12.0
- name: run integration
- name: Run integration
run: |
uv run coverage run manage.py test tests/integration
uv run coverage xml
@ -168,50 +174,49 @@ jobs:
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
name: test-e2e (${{ matrix.job.name }})
name: Test E2E (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: proxy
- name: Proxy Provider
glob: tests/e2e/test_provider_proxy*
- name: oauth
- name: OAuth2 Provider
glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
- name: oauth-oidc
- name: OIDC Provider
glob: tests/e2e/test_provider_oidc*
- name: saml
- name: SAML Provider
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
- name: ldap
- name: LDAP Provider
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: radius
- name: RADIUS Provider
glob: tests/e2e/test_provider_radius*
- name: scim
- name: SCIM Source
glob: tests/e2e/test_source_scim*
- name: flows
- name: Flows
glob: tests/e2e/test_flows*
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
- name: Setup E2E env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
- name: Prepare Web UI
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run e2e
- name: Run E2E tests
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
@ -227,6 +232,7 @@ jobs:
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark:
name: CI Core Mark
if: always()
needs:
- lint
@ -241,6 +247,7 @@ jobs:
with:
jobs: ${{ toJSON(needs) }}
build:
name: Build
permissions:
# Needed to upload container images to ghcr.io
packages: write
@ -254,6 +261,7 @@ jobs:
image_name: ghcr.io/goauthentik/dev-server
release: false
pr-comment:
name: PR Comment
needs:
- build
runs-on: ubuntu-latest
@ -266,7 +274,7 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:

View File

@ -14,6 +14,7 @@ on:
jobs:
lint-golint:
name: Lint Go
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -26,15 +27,16 @@ jobs:
mkdir -p web/dist
mkdir -p website/help
touch web/dist/test website/help/test
- name: Generate API
- name: Generate Go API Client
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v7
with:
version: latest
args: --timeout 5000s --verbose
skip-cache: true
test-unittest:
name: Unit Test Go
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -43,12 +45,13 @@ jobs:
go-version-file: "go.mod"
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
- name: Generate Go API Client
run: make gen-client-go
- name: Go unittests
run: |
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
ci-outpost-mark:
name: CI Outpost Mark
if: always()
needs:
- lint-golint
@ -59,6 +62,7 @@ jobs:
with:
jobs: ${{ toJSON(needs) }}
build-container:
name: Build Container
timeout-minutes: 120
needs:
- ci-outpost-mark
@ -85,7 +89,7 @@ jobs:
uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
@ -99,7 +103,7 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate API
- name: Generate Go API Client
run: make gen-client-go
- name: Build Docker Image
id: push
@ -122,6 +126,7 @@ jobs:
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-binary:
name: Build Binary
timeout-minutes: 120
needs:
- ci-outpost-mark
@ -140,7 +145,6 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- uses: actions/setup-node@v4
@ -148,7 +152,7 @@ jobs:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Generate API
- name: Generate Go API Client
run: make gen-client-go
- name: Build web
working-directory: web/

View File

@ -13,6 +13,7 @@ on:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
@ -39,12 +40,13 @@ jobs:
- working-directory: ${{ matrix.project }}/
run: |
npm ci
- name: Generate API
- name: Generate TypeScript API
run: make gen-client-ts
- name: Lint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -61,6 +63,7 @@ jobs:
working-directory: web/
run: npm run build
ci-web-mark:
name: CI Web Mark
if: always()
needs:
- build

View File

@ -13,6 +13,7 @@ on:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
@ -24,10 +25,11 @@ jobs:
- uses: actions/checkout@v4
- working-directory: website/
run: npm ci
- name: Lint
- name: Lint ${{ matrix.command }}
working-directory: website/
run: npm run ${{ matrix.command }}
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -37,18 +39,14 @@ jobs:
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install dependencies
run: npm ci
- name: test
- name: Documentation test
working-directory: website/
run: npm test
build:
name: Build Docs
runs-on: ubuntu-latest
name: ${{ matrix.job }}
strategy:
fail-fast: false
matrix:
job:
- build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -58,10 +56,11 @@ jobs:
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: npm ci
- name: build
- name: Build Docusaurus
working-directory: website/
run: npm run ${{ matrix.job }}
run: npm run build
ci-website-mark:
name: Mark CI Website
if: always()
needs:
- lint

View File

@ -1,4 +1,4 @@
name: "CodeQL"
name: CodeQL
on:
push:

View File

@ -11,6 +11,7 @@ env:
jobs:
build:
name: Update WebAuthn MDS
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
@ -37,7 +38,6 @@ jobs:
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -12,6 +12,7 @@ permissions:
jobs:
cleanup:
name: Cleanup Cache
runs-on: ubuntu-latest
steps:
- name: Check out code

View File

@ -20,7 +20,7 @@ on:
jobs:
compress:
name: compress
name: Compress Docker images
runs-on: ubuntu-latest
# Don't run on forks. Token will not be available. Will run on main and open a PR anyway
if: |
@ -53,7 +53,6 @@ jobs:
body: ${{ steps.compress.outputs.markdown }}
delete-branch: true
signoff: true
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
with:

View File

@ -7,7 +7,6 @@ on:
- packages/eslint-config/**
- packages/prettier-config/**
- packages/tsconfig/**
- web/packages/esbuild-plugin-live-reload/**
workflow_dispatch:
jobs:
publish:
@ -17,28 +16,28 @@ jobs:
fail-fast: false
matrix:
package:
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/tsconfig
- web/packages/esbuild-plugin-live-reload
- docusaurus-config
- eslint-config
- prettier-config
- tsconfig
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
name: Setup Node.js
with:
node-version-file: ${{ matrix.package }}/package.json
node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
- name: Changed files (${{ matrix.package }})
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with:
files: |
${{ matrix.package }}/package.json
- name: Publish package
packages/${{ matrix.package }}/package.json
- name: Publish package (${{ matrix.package }})
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }}
working-directory: packages/${{ matrix.package}}
run: |
npm ci
npm run build

View File

@ -12,6 +12,7 @@ env:
jobs:
publish-source-docs:
name: Publish
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
timeout-minutes: 120
@ -19,11 +20,11 @@ jobs:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate docs
- name: Generate docs
run: |
uv run make migrate
uv run ak build_source_docs
- name: Publish
- name: Deploy to Netlify
uses: netlify/actions/cli@master
with:
args: deploy --dir=source_docs --prod

View File

@ -11,6 +11,7 @@ permissions:
jobs:
update-next:
name: Update Next Branch
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
environment: internal-production

View File

@ -7,6 +7,7 @@ on:
jobs:
build-server:
name: Build server
uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit
permissions:
@ -21,6 +22,7 @@ jobs:
registry_dockerhub: true
registry_ghcr: true
build-outpost:
name: Build outpost
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
@ -45,14 +47,14 @@ jobs:
uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
- name: make empty clients
- name: Make empty clients
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
@ -85,6 +87,7 @@ jobs:
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost-binary:
name: Build outpost binary
timeout-minutes: 120
runs-on: ubuntu-latest
permissions:
@ -129,6 +132,7 @@ jobs:
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }}
upload-aws-cfn-template:
name: Upload AWS CloudFormation template
permissions:
# Needed for AWS login
id-token: write
@ -150,6 +154,7 @@ jobs:
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
test-release:
name: Test release
needs:
- build-server
- build-outpost
@ -166,6 +171,7 @@ jobs:
docker compose start postgresql redis
docker compose run -u root server test-all
sentry-release:
name: Sentry release
needs:
- build-server
- build-outpost
@ -173,7 +179,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:

View File

@ -20,7 +20,7 @@ jobs:
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: prepare variables
- name: Prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:

View File

@ -4,6 +4,7 @@ on: [push, delete]
jobs:
to_internal:
name: Mirror to internal repository
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:

View File

@ -11,6 +11,7 @@ permissions:
jobs:
stale:
name: Stale Issues
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:

View File

@ -16,6 +16,7 @@ permissions:
jobs:
post-comment:
name: Post Comment
runs-on: ubuntu-latest
steps:
- name: Find Comment

View File

@ -16,6 +16,7 @@ env:
jobs:
compile:
name: Compile Translations
runs-on: ubuntu-latest
steps:
- id: generate_token
@ -32,12 +33,12 @@ jobs:
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
- name: Generate TypeScript API
run: make gen-client-ts
- name: run extract
- name: Extract Translations
run: |
uv run make i18n-extract
- name: run compile
- name: Compile Messages
run: |
uv run ak compilemessages
make web-check-compile
@ -52,6 +53,3 @@ jobs:
body: "core, web: update translations"
delete-branch: true
signoff: true
labels: dependencies
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>

View File

@ -12,10 +12,10 @@ permissions:
jobs:
rename_pr:
name: Rename PR
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
steps:
- uses: actions/checkout@v4
- id: generate_token
uses: tibdex/github-app-token@v2
with:
@ -26,13 +26,23 @@ jobs:
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
title=$(gh pr view ${{ github.event.pull_request.number }} --json "title" -q ".title")
title=$(curl -q -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }} | jq -r .title)
echo "title=${title}" >> "$GITHUB_OUTPUT"
- name: Rename
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
gh pr edit ${{ github.event.pull_request.number }} -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
curl -L \
-X PATCH \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }} \
-d "{\"title\":\"translate: ${{ steps.title.outputs.title }}\"}"
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -16,7 +16,7 @@
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.tsdk": "./web/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml"
@ -30,5 +30,7 @@
}
],
"go.testFlags": ["-count=1"],
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
"github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
]
}

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS website-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS website-builder
ENV NODE_ENV=production
@ -20,7 +20,7 @@ COPY ./SECURITY.md /work/
RUN npm run build-bundled
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS web-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
@ -40,8 +40,7 @@ COPY ./web /work/web/
COPY ./website /work/website/
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build && \
npm run build:sfe
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
@ -86,17 +85,18 @@ FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
USER root
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
mkdir -p /usr/share/GeoIP && \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /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
FROM ghcr.io/astral-sh/uv:0.7.11 AS uv
FROM ghcr.io/astral-sh/uv:0.6.17 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -1,7 +1,6 @@
.PHONY: gen dev-reset all clean test web website
SHELL := /usr/bin/env bash
.SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail
.SHELLFLAGS += ${SHELLFLAGS} -e
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
@ -9,9 +8,9 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
GEN_API_TS = gen-ts-api
GEN_API_PY = gen-py-api
GEN_API_GO = gen-go-api
GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
pg_user := $(shell uv run 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)
@ -57,7 +56,7 @@ migrate: ## Run the Authentik Django server's migrations
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn:
cd lifecycle/aws && npm i && npm run aws-cfn
cd lifecycle/aws && npm run aws-cfn
run: ## Run the main authentik server process
uv run ak server
@ -86,15 +85,6 @@ dev-create-db:
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
bump:
uv version $(version)
$(MAKE) gen-build
$(MAKE) gen-compose
$(MAKE) aws-cfn
npm version --no-git-tag-version --allow-same-version $(version)
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
echo $(version) > ${PWD}/internal/constants/VERSION
#########################
## API Schema
#########################
@ -109,9 +99,6 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak spectacular --file schema.yml
gen-compose:
uv run scripts/generate_docker_compose.py
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
npx prettier --write changelog.md
@ -130,19 +117,14 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for Typescript
rm -rf ${PWD}/${GEN_API_TS}/
rm -rf ${PWD}/web/node_modules/@goauthentik/api/
rm -rf ./${GEN_API_TS}/
rm -rf ./web/node_modules/@goauthentik/api/
gen-clean-go: ## Remove generated API client for Go
mkdir -p ${PWD}/${GEN_API_GO}
ifneq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
make -C ${PWD}/${GEN_API_GO} clean
else
rm -rf ${PWD}/${GEN_API_GO}
endif
rm -rf ./${GEN_API_GO}/
gen-clean-py: ## Remove generated API client for Python
rm -rf ${PWD}/${GEN_API_PY}/
rm -rf ./${GEN_API_PY}/
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
@ -159,8 +141,8 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
--git-repo-id authentik \
--git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
cd ${PWD}/${GEN_API_TS} && npm i
\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api
cd ./${GEN_API_TS} && npm i
\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \
@ -174,17 +156,24 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python
--additional-properties=packageVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
pip install ./${GEN_API_PY}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ${PWD}/${GEN_API_GO}
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
else
cd ${PWD}/${GEN_API_GO} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
make -C ${PWD}/${GEN_API_GO} build
mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache
cp schema.yml ./${GEN_API_GO}/
docker run \
--rm -v ${PWD}/${GEN_API_GO}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
-i /local/schema.yml \
-g go \
-o /local/ \
-c /local/config.yaml
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
gen-dev-config: ## Generate a local development config file
uv run scripts/generate_config.py
@ -255,7 +244,7 @@ docker: ## Build a docker image of the current source tree
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ${PWD}/scripts/test_docker.sh
BUILD=true ./scripts/test_docker.sh
#########################
## CI

View File

@ -42,4 +42,4 @@ See [SECURITY.md](SECURITY.md)
## Adoption and Contributions
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [contribution guide](https://docs.goauthentik.io/docs/developer-docs?utm_source=github).
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md).

View File

@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| --------- | --------- |
| 2025.4.x | ✅ |
| 2025.6.x | ✅ |
| 2024.12.x | ✅ |
| 2025.2.x | ✅ |
## Reporting a Vulnerability

View File

@ -1,28 +1,20 @@
"""authentik root module"""
from functools import lru_cache
from importlib.metadata import version
from os import environ
__version__ = "2025.2.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
@lru_cache
def authentik_version() -> str:
return version("authentik")
@lru_cache
def authentik_build_hash(fallback: str | None = None) -> str:
def get_build_hash(fallback: str | None = None) -> str:
"""Get build hash"""
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
return fallback if build_hash == "" and fallback else build_hash
@lru_cache
def authentik_full_version() -> str:
def get_full_version() -> str:
"""Get full version, with build hash appended"""
version = authentik_version()
if (build_hash := authentik_build_hash()) != "":
version = __version__
if (build_hash := get_build_hash()) != "":
return f"{version}+{build_hash}"
return version

View File

@ -16,7 +16,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.license import LicenseKey
from authentik.lib.config import CONFIG
@ -78,7 +78,7 @@ class SystemInfoSerializer(PassiveSerializer):
"""Get versions"""
return {
"architecture": platform.machine(),
"authentik_version": authentik_full_version(),
"authentik_version": get_full_version(),
"environment": get_env(),
"openssl_fips_enabled": (
backend._fips_enabled if LicenseKey.get_total().status().is_valid else None

View File

@ -1,7 +1,6 @@
"""authentik administration overview"""
from django.core.cache import cache
from django_tenants.utils import get_public_schema_name
from drf_spectacular.utils import extend_schema
from packaging.version import parse
from rest_framework.fields import SerializerMethodField
@ -10,11 +9,10 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import authentik_build_hash, authentik_version
from authentik import __version__, get_build_hash
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.models import Outpost
from authentik.tenants.utils import get_current_tenant
class VersionSerializer(PassiveSerializer):
@ -29,20 +27,18 @@ class VersionSerializer(PassiveSerializer):
def get_build_hash(self, _) -> str:
"""Get build hash, if version is not latest or released"""
return authentik_build_hash()
return get_build_hash()
def get_version_current(self, _) -> str:
"""Get current version"""
return authentik_version()
return __version__
def get_version_latest(self, _) -> str:
"""Get latest version from cache"""
if get_current_tenant().schema_name == get_public_schema_name():
return __version__
version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover
update_latest_version.delay()
return authentik_version()
return __version__
return version_in_cache
def get_version_latest_valid(self, _) -> bool:

View File

@ -10,7 +10,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.rbac.permissions import HasPermission
from authentik.root.celery import CELERY_APP
@ -34,7 +34,7 @@ class WorkerView(APIView):
def get(self, request: Request) -> Response:
"""Get currently connected worker count."""
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
our_version = parse(authentik_full_version())
our_version = parse(get_full_version())
response = []
for worker in raw:
key = list(worker.keys())[0]
@ -50,7 +50,7 @@ class WorkerView(APIView):
response.append(
{
"worker_id": f"authentik-debug@{gethostname()}",
"version": authentik_full_version(),
"version": get_full_version(),
"version_matching": True,
}
)

View File

@ -14,19 +14,3 @@ class AuthentikAdminConfig(ManagedAppConfig):
label = "authentik_admin"
verbose_name = "authentik Admin"
default = True
@ManagedAppConfig.reconcile_global
def clear_update_notifications(self):
"""Clear update notifications on startup if the notification was for the version
we're running now."""
from packaging.version import parse
from authentik.admin.tasks import LOCAL_VERSION
from authentik.events.models import EventAction, Notification
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()

View File

@ -1,7 +1,6 @@
"""authentik admin settings"""
from celery.schedules import crontab
from django_tenants.utils import get_public_schema_name
from authentik.lib.utils.time import fqdn_rand
@ -9,7 +8,6 @@ CELERY_BEAT_SCHEDULE = {
"admin_latest_version": {
"task": "authentik.admin.tasks.update_latest_version",
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"),
"tenant_schemas": [get_public_schema_name()],
"options": {"queue": "authentik_scheduled"},
}
}

View File

@ -4,7 +4,7 @@ from django.dispatch import receiver
from packaging.version import parse
from prometheus_client import Gauge
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.root.celery import CELERY_APP
from authentik.root.monitoring import monitoring_set
@ -15,7 +15,7 @@ GAUGE_WORKERS = Gauge(
)
_version = parse(authentik_full_version())
_version = parse(get_full_version())
@receiver(monitoring_set)

View File

@ -1,14 +1,15 @@
"""authentik admin tasks"""
from django.core.cache import cache
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
from packaging.version import parse
from requests import RequestException
from structlog.stdlib import get_logger
from authentik import authentik_build_hash, authentik_version
from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO
from authentik.events.models import Event, EventAction
from authentik.events.models import Event, EventAction, Notification
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session
@ -18,20 +19,34 @@ LOGGER = get_logger()
VERSION_NULL = "0.0.0"
VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
LOCAL_VERSION = parse(authentik_version())
LOCAL_VERSION = parse(__version__)
def _set_prom_info():
"""Set prometheus info for version"""
PROM_INFO.info(
{
"version": authentik_version(),
"version": __version__,
"latest": cache.get(VERSION_CACHE_KEY, ""),
"build_hash": authentik_build_hash(),
"build_hash": get_build_hash(),
}
)
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def clear_update_notifications():
"""Clear update notifications on startup if the notification was for the version
we're running now."""
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()
@CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task
def update_latest_version(self: SystemTask):

View File

@ -5,7 +5,7 @@ from json import loads
from django.test import TestCase
from django.urls import reverse
from authentik import authentik_version
from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User
from authentik.lib.generators import generate_id
@ -27,7 +27,7 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:admin_version"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["version_current"], authentik_version())
self.assertEqual(body["version_current"], __version__)
def test_workers(self):
"""Test Workers API"""

View File

@ -1,12 +1,12 @@
"""test admin tasks"""
from django.apps import apps
from django.core.cache import cache
from django.test import TestCase
from requests_mock import Mocker
from authentik.admin.tasks import (
VERSION_CACHE_KEY,
clear_update_notifications,
update_latest_version,
)
from authentik.events.models import Event, EventAction
@ -72,13 +72,12 @@ class TestAdminTasks(TestCase):
def test_clear_update_notifications(self):
"""Test clear of previous notification"""
admin_config = apps.get_app_config("authentik_admin")
Event.objects.create(
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
)
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
admin_config.clear_update_notifications()
clear_update_notifications()
self.assertFalse(
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"

View File

@ -1,13 +1,12 @@
"""authentik API AppConfig"""
from authentik.blueprints.apps import ManagedAppConfig
from django.apps import AppConfig
class AuthentikAPIConfig(ManagedAppConfig):
class AuthentikAPIConfig(AppConfig):
"""authentik API Config"""
name = "authentik.api"
label = "authentik_api"
mountpoint = "api/"
verbose_name = "authentik API"
default = True

View File

@ -1,12 +1,9 @@
"""API Authentication"""
from hmac import compare_digest
from pathlib import Path
from tempfile import gettempdir
from typing import Any
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
@ -14,17 +11,11 @@ from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
LOGGER = get_logger()
_tmp = Path(gettempdir())
try:
with open(_tmp / "authentik-core-ipc.key") as _f:
ipc_key = _f.read()
except OSError:
ipc_key = None
def validate_auth(header: bytes) -> str | None:
@ -82,11 +73,6 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
if user:
CTX_AUTH_VIA.set("secret_key")
return user
# then try to auth via secret key (for embedded outpost/etc)
user = token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
@ -104,43 +90,6 @@ def token_secret_key(value: str) -> User | None:
return outpost.user
class IPCUser(AnonymousUser):
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
username = "authentik:system"
is_active = True
is_superuser = True
@property
def type(self):
return UserTypes.INTERNAL_SERVICE_ACCOUNT
def has_perm(self, perm, obj=None):
return True
def has_perms(self, perm_list, obj=None):
return True
def has_module_perms(self, module):
return True
@property
def is_anonymous(self):
return False
@property
def is_authenticated(self):
return True
def token_ipc(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser()
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""

View File

@ -54,7 +54,7 @@ def create_component(generator: SchemaGenerator, name, schema, type_=ResolvedCom
return component
def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs): # noqa: W0613
"""Workaround to set a default response for endpoints.
Workaround suggested at
<https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357>

View File

@ -11,7 +11,7 @@ from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik import authentik_version
from authentik import __version__
from authentik.blueprints.v1.common import BlueprintEntryDesiredState
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
@ -48,7 +48,7 @@ class Command(BaseCommand):
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": f"authentik {authentik_version()} Blueprint schema",
"title": f"authentik {__version__} Blueprint schema",
"required": ["version", "entries"],
"properties": {
"version": {

View File

@ -1,14 +0,0 @@
from django.test import TestCase
from authentik.blueprints.apps import ManagedAppConfig
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import get_apps
class TestManagedAppConfig(TestCase):
def test_apps_use_managed_app_config(self):
for app in get_apps():
if app.name.startswith("authentik.enterprise"):
self.assertIn(EnterpriseConfig, app.__class__.__bases__)
else:
self.assertIn(ManagedAppConfig, app.__class__.__bases__)

View File

@ -164,7 +164,9 @@ class BlueprintEntry:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))
def get_permissions(self, blueprint: "Blueprint") -> Generator[BlueprintEntryPermission]:
def get_permissions(
self, blueprint: "Blueprint"
) -> Generator[BlueprintEntryPermission, None, None]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(

View File

@ -59,7 +59,6 @@ class BrandSerializer(ModelSerializer):
"flow_device_code",
"default_application",
"web_certificate",
"client_certificates",
"attributes",
]
extra_kwargs = {
@ -121,7 +120,6 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"domain",
"branding_title",
"web_certificate__name",
"client_certificates__name",
]
filterset_fields = [
"brand_uuid",
@ -138,7 +136,6 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_user_settings",
"flow_device_code",
"web_certificate",
"client_certificates",
]
ordering = ["domain"]

View File

@ -1,9 +1,9 @@
"""authentik brands app"""
from authentik.blueprints.apps import ManagedAppConfig
from django.apps import AppConfig
class AuthentikBrandsConfig(ManagedAppConfig):
class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app"""
name = "authentik.brands"
@ -12,4 +12,3 @@ class AuthentikBrandsConfig(ManagedAppConfig):
mountpoints = {
"authentik.brands.urls_root": "",
}
default = True

View File

@ -16,7 +16,7 @@ def migrate_custom_css(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
if not path.exists():
return
css = path.read_text()
Brand.objects.using(db_alias).all().update(branding_custom_css=css)
Brand.objects.using(db_alias).update(branding_custom_css=css)
class Migration(migrations.Migration):

View File

@ -1,37 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-19 15:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0009_brand_branding_default_flow_background"),
("authentik_crypto", "0004_alter_certificatekeypair_name"),
]
operations = [
migrations.AddField(
model_name="brand",
name="client_certificates",
field=models.ManyToManyField(
blank=True,
default=None,
help_text="Certificates used for client authentication.",
to="authentik_crypto.certificatekeypair",
),
),
migrations.AlterField(
model_name="brand",
name="web_certificate",
field=models.ForeignKey(
default=None,
help_text="Web Certificate used by the authentik Core webserver.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="+",
to="authentik_crypto.certificatekeypair",
),
),
]

View File

@ -73,13 +73,6 @@ class Brand(SerializerModel):
default=None,
on_delete=models.SET_DEFAULT,
help_text=_("Web Certificate used by the authentik Core webserver."),
related_name="+",
)
client_certificates = models.ManyToManyField(
CertificateKeyPair,
default=None,
blank=True,
help_text=_("Certificates used for client authentication."),
)
attributes = models.JSONField(default=dict, blank=True)

View File

@ -5,10 +5,10 @@ from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from sentry_sdk import get_current_span
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.lib.sentry import get_http_meta
from authentik.tenants.models import Tenant
_q_default = Q(default=True)
@ -32,9 +32,13 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects brand object into every template"""
brand = getattr(request, "brand", DEFAULT_BRAND)
tenant = getattr(request, "tenant", Tenant())
trace = ""
span = get_current_span()
if span:
trace = span.to_traceparent()
return {
"brand": brand,
"footer_links": tenant.footer_links,
"html_meta": {**get_http_meta()},
"version": authentik_full_version(),
"sentry_trace": trace,
"version": get_full_version(),
}

View File

@ -99,17 +99,18 @@ class GroupSerializer(ModelSerializer):
if superuser
else "authentik_core.disable_group_superuser"
)
if self.instance or superuser:
has_perm = user.has_perm(perm) or user.has_perm(perm, self.instance)
if not has_perm:
raise ValidationError(
_(
(
"User does not have permission to set "
"superuser status to {superuser_status}."
).format_map({"superuser_status": superuser})
)
has_perm = user.has_perm(perm)
if self.instance and not has_perm:
has_perm = user.has_perm(perm, self.instance)
if not has_perm:
raise ValidationError(
_(
(
"User does not have permission to set "
"superuser status to {superuser_status}."
).format_map({"superuser_status": superuser})
)
)
return superuser
class Meta:

View File

@ -84,7 +84,6 @@ from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.stages.email.flow import pickle_flow_token_for_email
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@ -452,7 +451,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def _create_recovery_link(self, for_email=False) -> tuple[str, Token]:
def _create_recovery_link(self) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand
@ -474,16 +473,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
raise ValidationError(
{"non_field_errors": "Recovery flow not applicable to user"}
) from None
_plan = FlowToken.pickle(plan)
if for_email:
_plan = pickle_flow_token_for_email(plan)
token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset",
defaults={
"user": user,
"flow": flow,
"_plan": _plan,
"revoke_on_execution": not for_email,
"_plan": FlowToken.pickle(plan),
},
)
querystring = urlencode({QS_KEY_TOKEN: token.key})
@ -653,7 +648,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
raise ValidationError({"non_field_errors": "User does not have an email address set."})
link, token = self._create_recovery_link(for_email=True)
link, token = self._create_recovery_link()
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"

View File

@ -2,7 +2,6 @@
from django.apps import apps
from django.contrib.auth.management import create_permissions
from django.core.management import call_command
from django.core.management.base import BaseCommand, no_translations
from guardian.management import create_anonymous_user
@ -17,10 +16,6 @@ class Command(BaseCommand):
"""Check permissions for all apps"""
for tenant in Tenant.objects.filter(ready=True):
with tenant:
# See https://code.djangoproject.com/ticket/28417
# Remove potential lingering old permissions
call_command("remove_stale_contenttypes", "--no-input")
for app in apps.get_app_configs():
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
create_permissions(app, verbosity=0)

View File

@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.core.models import User
from authentik.events.middleware import should_log_model
from authentik.events.models import Event, EventAction
@ -19,7 +19,7 @@ from authentik.events.utils import model_to_dict
def get_banner_text(shell_type="shell") -> str:
return f"""### authentik {shell_type} ({authentik_full_version()})
return f"""### authentik {shell_type} ({get_full_version()})
### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """

View File

@ -31,10 +31,7 @@ class PickleSerializer:
def loads(self, data):
"""Unpickle data to be loaded from redis"""
try:
return pickle.loads(data) # nosec
except Exception:
return {}
return pickle.loads(data) # nosec
def _migrate_session(
@ -79,7 +76,6 @@ def _migrate_session(
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
uuid=old_auth_session.uuid,
)

View File

@ -1,103 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-14 11:15
from django.apps.registry import Apps, apps as global_apps
from django.db import migrations
from django.contrib.contenttypes.management import create_contenttypes
from django.contrib.auth.management import create_permissions
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_authenticated_session_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
"""Migrate permissions from OldAuthenticatedSession to AuthenticatedSession"""
db_alias = schema_editor.connection.alias
# `apps` here is just an instance of `django.db.migrations.state.AppConfigStub`, we need the
# real config for creating permissions and content types
authentik_core_config = global_apps.get_app_config("authentik_core")
# These are only ran by django after all migrations, but we need them right now.
# `global_apps` is needed,
create_permissions(authentik_core_config, using=db_alias, verbosity=1)
create_contenttypes(authentik_core_config, using=db_alias, verbosity=1)
# But from now on, this is just a regular migration, so use `apps`
Permission = apps.get_model("auth", "Permission")
ContentType = apps.get_model("contenttypes", "ContentType")
try:
old_ct = ContentType.objects.using(db_alias).get(
app_label="authentik_core", model="oldauthenticatedsession"
)
new_ct = ContentType.objects.using(db_alias).get(
app_label="authentik_core", model="authenticatedsession"
)
except ContentType.DoesNotExist:
# This should exist at this point, but if not, let's cut our losses
return
# Get all permissions for the old content type
old_perms = Permission.objects.using(db_alias).filter(content_type=old_ct)
# Create equivalent permissions for the new content type
for old_perm in old_perms:
new_perm = (
Permission.objects.using(db_alias)
.filter(
content_type=new_ct,
codename=old_perm.codename,
)
.first()
)
if not new_perm:
# This should exist at this point, but if not, let's cut our losses
continue
# Global user permissions
User = apps.get_model("authentik_core", "User")
User.user_permissions.through.objects.using(db_alias).filter(
permission=old_perm
).all().update(permission=new_perm)
# Global role permissions
DjangoGroup = apps.get_model("auth", "Group")
DjangoGroup.permissions.through.objects.using(db_alias).filter(
permission=old_perm
).all().update(permission=new_perm)
# Object user permissions
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
UserObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
permission=new_perm, content_type=new_ct
)
# Object role permissions
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
GroupObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
permission=new_perm, content_type=new_ct
)
def remove_old_authenticated_session_content_type(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
):
db_alias = schema_editor.connection.alias
ContentType = apps.get_model("contenttypes", "ContentType")
ContentType.objects.using(db_alias).filter(model="oldauthenticatedsession").delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0047_delete_oldauthenticatedsession"),
]
operations = [
migrations.RunPython(
code=migrate_authenticated_session_permissions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=remove_old_authenticated_session_content_type,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -21,9 +21,7 @@
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
{% block head %}
{% endblock %}
{% for key, value in html_meta.items %}
<meta name="{{key}}" content="{{ value }}" />
{% endfor %}
<meta name="sentry-trace" content="{{ sentry_trace }}" />
</head>
<body>
{% block body %}

View File

@ -3,7 +3,7 @@
from django import template
from django.templatetags.static import static as static_loader
from authentik import authentik_full_version
from authentik import get_full_version
register = template.Library()
@ -11,4 +11,4 @@ register = template.Library()
@register.simple_tag()
def versioned_script(path: str) -> str:
"""Wrapper around {% static %} tag that supports setting the version"""
return static_loader(path.replace("%v", authentik_full_version()))
return static_loader(path.replace("%v", get_full_version()))

View File

@ -124,16 +124,6 @@ class TestGroupsAPI(APITestCase):
{"is_superuser": ["User does not have permission to set superuser status to True."]},
)
def test_superuser_no_perm_no_superuser(self):
"""Test creating a group without permission and without superuser flag"""
assign_perm("authentik_core.add_group", self.login_user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
data={"name": generate_id(), "is_superuser": False},
)
self.assertEqual(res.status_code, 201)
def test_superuser_update_no_perm(self):
"""Test updating a superuser group without permission"""
group = Group.objects.create(name=generate_id(), is_superuser=True)

View File

@ -10,7 +10,7 @@ from django.utils.translation import gettext as _
from django.views.generic.base import RedirectView, TemplateView
from rest_framework.request import Request
from authentik import authentik_build_hash
from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
@ -50,7 +50,7 @@ class InterfaceView(TemplateView):
kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data)
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = authentik_build_hash()
kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs
kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
kwargs["base_url_rel"] = CONFIG.get("web.path", "/")

View File

@ -30,7 +30,6 @@ from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import UserTypes
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
from authentik.crypto.models import CertificateKeyPair
@ -273,12 +272,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def view_certificate(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs certificate and log access"""
certificate: CertificateKeyPair = self.get_object()
if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT:
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="certificate",
).from_http(request)
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="certificate",
).from_http(request)
if "download" in request.query_params:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(
@ -304,12 +302,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def view_private_key(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs private key and log access"""
certificate: CertificateKeyPair = self.get_object()
if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT:
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="private_key",
).from_http(request)
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="private_key",
).from_http(request)
if "download" in request.query_params:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")

View File

@ -12,7 +12,7 @@ from cryptography.x509.oid import NameOID
from django.db import models
from django.utils.translation import gettext_lazy as _
from authentik import authentik_version
from authentik import __version__
from authentik.crypto.models import CertificateKeyPair
@ -85,7 +85,7 @@ class CertificateBuilder:
.issuer_name(
x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, f"authentik {authentik_version()}"),
x509.NameAttribute(NameOID.COMMON_NAME, f"authentik {__version__}"),
]
)
)

View File

@ -132,14 +132,13 @@ class LicenseKey:
"""Get a summarized version of all (not expired) licenses"""
total = LicenseKey(get_license_aud(), 0, "Summarized license", 0, 0)
for lic in License.objects.all():
if lic.is_valid:
total.internal_users += lic.internal_users
total.external_users += lic.external_users
total.license_flags.extend(lic.status.license_flags)
total.internal_users += lic.internal_users
total.external_users += lic.external_users
exp_ts = int(mktime(lic.expiry.timetuple()))
if total.exp == 0:
total.exp = exp_ts
total.exp = max(total.exp, exp_ts)
total.license_flags.extend(lic.status.license_flags)
return total
@staticmethod

View File

@ -39,10 +39,6 @@ class License(SerializerModel):
internal_users = models.BigIntegerField()
external_users = models.BigIntegerField()
@property
def is_valid(self) -> bool:
return self.expiry >= now()
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.api import LicenseSerializer

View File

@ -25,7 +25,7 @@ class GoogleWorkspaceGroupClient(
"""Google client for groups"""
connection_type = GoogleWorkspaceProviderGroup
connection_attr = "googleworkspaceprovidergroup_set"
connection_type_query = "group"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:

View File

@ -20,7 +20,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
"""Sync authentik users into google workspace"""
connection_type = GoogleWorkspaceProviderUser
connection_attr = "googleworkspaceprovideruser_set"
connection_type_query = "user"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:

View File

@ -132,11 +132,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = (
User.objects.prefetch_related("googleworkspaceprovideruser_set")
.all()
.exclude_anonymous()
)
base = User.objects.all().exclude_anonymous()
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
@ -146,11 +142,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return (
Group.objects.prefetch_related("googleworkspaceprovidergroup_set")
.all()
.order_by("pk")
)
return Group.objects.all().order_by("pk")
raise ValueError(f"Invalid type {type}")
def google_credentials(self):

View File

@ -29,7 +29,7 @@ class MicrosoftEntraGroupClient(
"""Microsoft client for groups"""
connection_type = MicrosoftEntraProviderGroup
connection_attr = "microsoftentraprovidergroup_set"
connection_type_query = "group"
can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:

View File

@ -24,7 +24,7 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
"""Sync authentik users into microsoft entra"""
connection_type = MicrosoftEntraProviderUser
connection_attr = "microsoftentraprovideruser_set"
connection_type_query = "user"
can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:

View File

@ -121,11 +121,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = (
User.objects.prefetch_related("microsoftentraprovideruser_set")
.all()
.exclude_anonymous()
)
base = User.objects.all().exclude_anonymous()
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
@ -135,11 +131,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return (
Group.objects.prefetch_related("microsoftentraprovidergroup_set")
.all()
.order_by("pk")
)
return Group.objects.all().order_by("pk")
raise ValueError(f"Invalid type {type}")
def microsoft_credentials(self):

View File

@ -19,7 +19,6 @@ TENANT_APPS = [
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",
]

View File

@ -1,31 +0,0 @@
"""Mutual TLS Stage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.mtls.models import MutualTLSStage
from authentik.flows.api.stages import StageSerializer
class MutualTLSStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""MutualTLSStage Serializer"""
class Meta:
model = MutualTLSStage
fields = StageSerializer.Meta.fields + [
"mode",
"certificate_authorities",
"cert_attribute",
"user_attribute",
]
class MutualTLSStageViewSet(UsedByMixin, ModelViewSet):
"""MutualTLSStage Viewset"""
queryset = MutualTLSStage.objects.all()
serializer_class = MutualTLSStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]

View File

@ -1,12 +0,0 @@
"""authentik stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageMTLSConfig(EnterpriseConfig):
"""authentik MTLS stage config"""
name = "authentik.enterprise.stages.mtls"
label = "authentik_stages_mtls"
verbose_name = "authentik Enterprise.Stages.MTLS"
default = True

View File

@ -1,68 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-19 18:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.CreateModel(
name="MutualTLSStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"mode",
models.TextField(choices=[("optional", "Optional"), ("required", "Required")]),
),
(
"cert_attribute",
models.TextField(
choices=[
("subject", "Subject"),
("common_name", "Common Name"),
("email", "Email"),
]
),
),
(
"user_attribute",
models.TextField(choices=[("username", "Username"), ("email", "Email")]),
),
(
"certificate_authorities",
models.ManyToManyField(
blank=True,
default=None,
help_text="Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`.",
to="authentik_crypto.certificatekeypair",
),
),
],
options={
"verbose_name": "Mutual TLS Stage",
"verbose_name_plural": "Mutual TLS Stages",
"permissions": [
("pass_outpost_certificate", "Permissions to pass Certificates for outposts.")
],
},
bases=("authentik_flows.stage",),
),
]

View File

@ -1,71 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
class TLSMode(models.TextChoices):
"""Modes the TLS Stage can operate in"""
OPTIONAL = "optional"
REQUIRED = "required"
class CertAttributes(models.TextChoices):
"""Certificate attribute used for user matching"""
SUBJECT = "subject"
COMMON_NAME = "common_name"
EMAIL = "email"
class UserAttributes(models.TextChoices):
"""User attribute for user matching"""
USERNAME = "username"
EMAIL = "email"
class MutualTLSStage(Stage):
"""Authenticate/enroll users using a client-certificate."""
mode = models.TextField(choices=TLSMode.choices)
certificate_authorities = models.ManyToManyField(
CertificateKeyPair,
default=None,
blank=True,
help_text=_(
"Configure certificate authorities to validate the certificate against. "
"This option has a higher priority than the `client_certificate` option on `Brand`."
),
)
cert_attribute = models.TextField(choices=CertAttributes.choices)
user_attribute = models.TextField(choices=UserAttributes.choices)
@property
def view(self) -> type[StageView]:
from authentik.enterprise.stages.mtls.stage import MTLSStageView
return MTLSStageView
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.stages.mtls.api import MutualTLSStageSerializer
return MutualTLSStageSerializer
@property
def component(self) -> str:
return "ak-stage-mtls-form"
class Meta:
verbose_name = _("Mutual TLS Stage")
verbose_name_plural = _("Mutual TLS Stages")
permissions = [
("pass_outpost_certificate", _("Permissions to pass Certificates for outposts.")),
]

View File

@ -1,230 +0,0 @@
from binascii import hexlify
from urllib.parse import unquote_plus
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.x509 import (
Certificate,
NameOID,
ObjectIdentifier,
UnsupportedGeneralNameType,
load_pem_x509_certificate,
)
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
from django.utils.translation import gettext_lazy as _
from authentik.brands.models import Brand
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.stages.mtls.models import (
CertAttributes,
MutualTLSStage,
TLSMode,
UserAttributes,
)
from authentik.flows.challenge import AccessDeniedChallenge
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
# All of these headers must only be accepted from "trusted" reverse proxies
# See internal/web/proxy.go:39
HEADER_PROXY_FORWARDED = "X-Forwarded-Client-Cert"
HEADER_NGINX_FORWARDED = "SSL-Client-Cert"
HEADER_TRAEFIK_FORWARDED = "X-Forwarded-TLS-Client-Cert"
HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate"
PLAN_CONTEXT_CERTIFICATE = "certificate"
class MTLSStageView(ChallengeStageView):
def __parse_single_cert(self, raw: str | None) -> list[Certificate]:
"""Helper to parse a single certificate"""
if not raw:
return []
try:
cert = load_pem_x509_certificate(unquote_plus(raw).encode())
return [cert]
except ValueError as exc:
self.logger.info("Failed to parse certificate", exc=exc)
return []
def _parse_cert_xfcc(self) -> list[Certificate]:
"""Parse certificates in the format given to us in
the format of the authentik router/envoy"""
xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED)
if not xfcc_raw:
return []
certs = []
for r_cert in xfcc_raw.split(","):
el = r_cert.split(";")
raw_cert = {k.split("=")[0]: k.split("=")[1] for k in el}
if "Cert" not in raw_cert:
continue
certs.extend(self.__parse_single_cert(raw_cert["Cert"]))
return certs
def _parse_cert_nginx(self) -> list[Certificate]:
"""Parse certificates in the format nginx-ingress gives to us"""
sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED)
return self.__parse_single_cert(sslcc_raw)
def _parse_cert_traefik(self) -> list[Certificate]:
"""Parse certificates in the format traefik gives to us"""
ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED)
return self.__parse_single_cert(ftcc_raw)
def _parse_cert_outpost(self) -> list[Certificate]:
"""Parse certificates in the format outposts give to us. Also authenticates
the outpost to ensure it has the permission to do so"""
user = ClientIPMiddleware.get_outpost_user(self.request)
if not user:
return []
if not user.has_perm(
"pass_outpost_certificate", self.executor.current_stage
) and not user.has_perm("authentik_stages_mtls.pass_outpost_certificate"):
return []
outpost_raw = self.request.headers.get(HEADER_OUTPOST_FORWARDED)
return self.__parse_single_cert(outpost_raw)
def get_authorities(self) -> list[CertificateKeyPair] | None:
# We can't access `certificate_authorities` on `self.executor.current_stage`, as that would
# load the certificate into the directly referenced foreign key, which we have to pickle
# as part of the flow plan, and cryptography certs can't be pickled
stage: MutualTLSStage = (
MutualTLSStage.objects.filter(pk=self.executor.current_stage.pk)
.prefetch_related("certificate_authorities")
.first()
)
if stage.certificate_authorities.exists():
return stage.certificate_authorities.order_by("name")
brand: Brand = self.request.brand
if brand.client_certificates.exists():
return brand.client_certificates.order_by("name")
return None
def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]):
authorities_cert = [x.certificate for x in authorities]
for _cert in certs:
try:
PolicyBuilder().store(Store(authorities_cert)).build_client_verifier().verify(
_cert, []
)
return _cert
except (
InvalidSignature,
TypeError,
ValueError,
VerificationError,
UnsupportedGeneralNameType,
) as exc:
self.logger.warning("Discarding invalid certificate", cert=_cert, exc=exc)
continue
return None
def check_if_user(self, cert: Certificate):
stage: MutualTLSStage = self.executor.current_stage
cert_attr = None
user_attr = None
match stage.cert_attribute:
case CertAttributes.SUBJECT:
cert_attr = cert.subject.rfc4514_string()
case CertAttributes.COMMON_NAME:
cert_attr = self.get_cert_attribute(cert, NameOID.COMMON_NAME)
case CertAttributes.EMAIL:
cert_attr = self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS)
match stage.user_attribute:
case UserAttributes.USERNAME:
user_attr = "username"
case UserAttributes.EMAIL:
user_attr = "email"
if not user_attr or not cert_attr:
return None
return User.objects.filter(**{user_attr: cert_attr}).first()
def _cert_to_dict(self, cert: Certificate) -> dict:
"""Represent a certificate in a dictionary, as certificate objects cannot be pickled"""
return {
"serial_number": str(cert.serial_number),
"subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(),
"fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"),
"fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA1()), ":").decode( # nosec
"utf-8"
),
}
def auth_user(self, user: User, cert: Certificate):
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "mtls")
self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].update(
{"certificate": self._cert_to_dict(cert)}
)
def enroll_prepare_user(self, cert: Certificate):
self.executor.plan.context.setdefault(PLAN_CONTEXT_PROMPT, {})
self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(
{
"email": self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS),
"name": self.get_cert_attribute(cert, NameOID.COMMON_NAME),
}
)
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
def get_cert_attribute(self, cert: Certificate, oid: ObjectIdentifier) -> str | None:
attr = cert.subject.get_attributes_for_oid(oid)
if len(attr) < 1:
return None
return str(attr[0].value)
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
certs = [
*self._parse_cert_xfcc(),
*self._parse_cert_nginx(),
*self._parse_cert_traefik(),
*self._parse_cert_outpost(),
]
authorities = self.get_authorities()
if not authorities:
self.logger.warning("No Certificate authority found")
if stage.mode == TLSMode.OPTIONAL:
return self.executor.stage_ok()
if stage.mode == TLSMode.REQUIRED:
return super().dispatch(request, *args, **kwargs)
cert = self.validate_cert(authorities, certs)
if not cert and stage.mode == TLSMode.REQUIRED:
self.logger.warning("Client certificate required but no certificates given")
return super().dispatch(
request,
*args,
error_message=_("Certificate required but no certificate was given."),
**kwargs,
)
if not cert and stage.mode == TLSMode.OPTIONAL:
self.logger.info("No certificate given, continuing")
return self.executor.stage_ok()
existing_user = self.check_if_user(cert)
if self.executor.flow.designation == FlowDesignation.ENROLLMENT:
self.enroll_prepare_user(cert)
elif existing_user:
self.auth_user(existing_user, cert)
else:
return super().dispatch(
request, *args, error_message=_("No user found for certificate."), **kwargs
)
return self.executor.stage_ok()
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
return AccessDeniedChallenge(
data={
"component": "ak-stage-access-denied",
"error_message": str(error_message or "Unknown error"),
}
)

View File

@ -1,31 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFXDCCA0SgAwIBAgIUBmV7zREyC1SPr72/75/L9zpwV18wDQYJKoZIhvcNAQEL
BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl
bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMDUwWhcNMzUw
MzA3MTgzMDUwWjBGMRowGAYDVQQDDBFhdXRoZW50aWsgVGVzdCBDQTESMBAGA1UE
CgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCAiIwDQYJKoZIhvcN
AQEBBQADggIPADCCAgoCggIBAMc0NxZj7j1mPu0aRToo8oMPdC3T99xgxnqdr18x
LV4pWyi/YLghgZHqNQY2xNP6JIlSeUZD6KFUYT2sPL4Av/zSg5zO8bl+/lf7ckje
O1/Bt5A8xtL0CpmpMDGiI6ibdDElaywM6AohisbxrV29pygSKGq2wugF/urqGtE+
5z4y5Kt6qMdKkd0iXT+WagbQTIUlykFKgB0+qqTLzDl01lVDa/DoLl8Hqp45mVx2
pqrGsSa3TCErLIv9hUlZklF7A8UV4ZB4JL20UKcP8dKzQClviNie17tpsUpOuy3A
SQ6+guWTHTLJNCSdLn1xIqc5q+f5wd2dIDf8zXCTHj+Xp0bJE3Vgaq5R31K9+b+1
2dDWz1KcNJaLEnw2+b0O8M64wTMLxhqOv7QfLUr6Pmg1ZymghjLcZ6bnU9e31Vza
hlPKhxjqYQUC4Kq+oaYF6qdUeJy+dsYf0iDv5tTC+eReZDWIjxTPrNpwA773ZwT7
WVmL7ULGpuP2g9rNvFBcZiN+i6d7CUoN+jd/iRdo79lrI0dfXiyy4bYgW/2HeZfF
HaOsc1xsoqnJdWbWkX/ooyaCjAfm07kS3HiOzz4q3QW4wgGrwV8lEraLPxYYeOQu
YcGMOM8NfnVkjc8gmyXUxedCje5Vz/Tu5fKrQEInnCmXxVsWbwr/LzEjMKAM/ivY
0TXxAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0G
A1UdDgQWBBTa+Ns6QzqlNvnTGszkouQQtZnVJDANBgkqhkiG9w0BAQsFAAOCAgEA
NpJEDMXjuEIzSzafkxSshvjnt5sMYmzmvjNoRlkxgN2YcWvPoxbalGAYzcpyggT2
6xZY8R4tvB1oNTCArqwf860kkofUoJCr88D/pU3Cv4JhjCWs4pmXTsvSqlBSlJbo
+jPBZwbn6it/6jcit6Be3rW2PtHe8tASd9Lf8/2r1ZvupXwPzcR84R4Z10ve2lqV
xxcWlMmBh51CaYI0b1/WTe9Ua+wgkCVkxbf9zNcDQXjxw2ICWK+nR/4ld4nmqVm2
C7nhvXwU8FAHl7ZgR2Z3PLrwPuhd+kd6NXQqNkS9A+n+1vSRLbRjmV8pwIPpdPEq
nslUAGJJBHDUBArxC3gOJSB+WtmaCfzDu2gepMf9Ng1H2ZhwSF/FH3v3fsJqZkzz
NBstT9KuNGQRYiCmAPJaoVAc9BoLa+BFML1govtWtpdmbFk8PZEcuUsP7iAZqFF1
uuldPyZ8huGpQSR6Oq2bILRHowfGY0npTZAyxg0Vs8UMy1HTwNOp9OuRtArMZmsJ
jFIx1QzRf9S1i6bYpOzOudoXj4ARkS1KmVExGjJFcIT0xlFSSERie2fEKSeEYOyG
G+PA2qRt/F51FGOMm1ZscjPXqk2kt3C4BFbz6Vvxsq7D3lmhvFLn4jVA8+OidsM0
YUrVMtWET/RkjEIbADbgRXxNUNo+jtQZDU9C1IiAdfk=
-----END CERTIFICATE-----

View File

@ -1,31 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFWTCCA0GgAwIBAgIUDEnKCSmIXG/akySGes7bhOGrN/8wDQYJKoZIhvcNAQEL
BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl
bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNTE5MTIzODQ2WhcNMjYw
NTE1MTIzODQ2WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQCkPkS1V6l0gj0ulxMznkxkgrw4p9Tjd8teSsGZt02A2Eo6
7D8FbJ7pp3d5fYW/TWuEKVBLWTID6rijW5EGcdgTM5Jxf/QR+aZTEK6umQxUd4yO
mOtp+xVS3KlcsSej2dFpeE5h5VkZizHpvh5xkoAP8W5VtQLOVF0hIeumHnJmaeLj
+mhK9PBFpO7k9SFrYYhd/uLrYbIdANihbIO2Q74rNEJHewhFNM7oNSjjEWzRd/7S
qNdQij9JGrVG7u8YJJscEQHqyHMYFVCEMjxmsge5BO6Vx5OWmUE3wXPzb5TbyTS4
+yg88g9rYTUXrzz+poCyKpaur45qBsdw35lJ8nq69VJj2xJLGQDwoTgGSXRuPciC
3OilQI+Ma+j8qQGJxJ8WJxISlf1cuhp+V4ZUd1lawlM5hAXyXmHRlH4pun4y+g7O
O34+fE3pK25JjVCicMT/rC2A/sb95j/fHTzzJpbB70U0I50maTcIsOkyw6aiF//E
0ShTDz14x22SCMolUc6hxTDZvBB6yrcJHd7d9CCnFH2Sgo13QrtNJ/atXgm13HGh
wBzRwK38XUGl/J4pJaxAupTVCPriStUM3m0EYHNelRRUE91pbyeGT0rvOuv00uLw
Rj7K7hJZR8avTKWmKrVBVpq+gSojGW1DwBS0NiDNkZs0d/IjB1wkzczEgdZjXwID
AQABo3QwcjAfBgNVHSMEGDAWgBTa+Ns6QzqlNvnTGszkouQQtZnVJDAdBgNVHSUE
FjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEQYDVR0RBAowCIIGY2xpZW50MB0GA1Ud
DgQWBBT1xg5sXkypRBwvCxBuyfoanaiZ5jANBgkqhkiG9w0BAQsFAAOCAgEAvUAz
YwIjxY/0KHZDU8owdILVqKChzfLcy9OHNPyEI3TSOI8X6gNtBO+HE6r8aWGcC9vw
zzeIsNQ3UEjvRWi2r+vUVbiPTbFdZboNDSZv6ZmGHxwd85VsjXRGoXV6koCT/9zi
9/lCM1DwqwYSwBphMJdRVFRUMluSYk1oHflGeA18xgGuts4eFivJwhabGm1AdVVQ
/CYvqCuTxd/DCzWZBdyxYpDru64i/kyeJCt1pThKEFDWmpumFdBI4CxJ0OhxVSGp
dOXzK+Y6ULepxCvi6/OpSog52jQ6PnNd1ghiYtq7yO1T4GQz65M1vtHHVvQ3gfBE
AuKYQp6io7ypitRx+LpjsBQenyP4FFGfrq7pm90nLluOBOArfSdF0N+CP2wo/YFV
9BGf89OtvRi3BXCm2NXkE/Sc4We26tY8x7xNLOmNs8YOT0O3r/EQ690W9GIwRMx0
m0r/RXWn5V3o4Jib9r8eH9NzaDstD8g9dECcGfM4fHoM/DAGFaRrNcjMsS1APP3L
jp7+BfBSXtrz9V6rVJ3CBLXlLK0AuSm7bqd1MJsGA9uMLpsVZIUA+KawcmPGdPU+
NxdpBCtzyurQSUyaTLtVqSeP35gMAwaNzUDph8Uh+vHz+kRwgXS19OQvTaud5LJu
nQe4JNS+u5e2VDEBWUxt8NTpu6eShDN0iIEHtxA=
-----END CERTIFICATE-----

View File

@ -1,228 +0,0 @@
from unittest.mock import MagicMock, patch
from urllib.parse import quote_plus
from django.urls import reverse
from guardian.shortcuts import assign_perm
from authentik.core.models import User
from authentik.core.tests.utils import (
create_test_brand,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.stages.mtls.models import (
CertAttributes,
MutualTLSStage,
TLSMode,
UserAttributes,
)
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.outposts.models import Outpost, OutpostType
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
class MTLSStageTests(FlowTestCase):
def setUp(self):
super().setUp()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.ca = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data=load_fixture("fixtures/ca.pem"),
)
self.stage = MutualTLSStage.objects.create(
name=generate_id(),
mode=TLSMode.REQUIRED,
cert_attribute=CertAttributes.COMMON_NAME,
user_attribute=UserAttributes.USERNAME,
)
self.stage.certificate_authorities.add(self.ca)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.client_cert = load_fixture("fixtures/cert_client.pem")
# User matching the certificate
User.objects.filter(username="client").delete()
self.cert_user = create_test_user(username="client")
def test_parse_xfcc(self):
"""Test authentik Proxy/Envoy's XFCC format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-Client-Cert": f"Cert={quote_plus(self.client_cert)}"},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_nginx(self):
"""Test nginx's format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"SSL-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_traefik(self):
"""Test traefik's format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_object(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("pass_outpost_certificate", outpost.user, self.stage)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_global(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("authentik_stages_mtls.pass_outpost_certificate", outpost.user)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_no_perm(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_invalid_cert(self):
"""Test invalid certificate"""
cert = create_test_cert()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(cert.certificate_data)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
self.assertNotIn(PLAN_CONTEXT_PENDING_USER, plan().context)
def test_auth_no_user(self):
"""Test auth with no user"""
User.objects.filter(username="client").delete()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_brand_ca(self):
"""Test using a CA from the brand"""
self.stage.certificate_authorities.clear()
brand = create_test_brand()
brand.client_certificates.add(self.ca)
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_no_ca_optional(self):
"""Test using no CA Set"""
self.stage.mode = TLSMode.OPTIONAL
self.stage.certificate_authorities.clear()
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
def test_no_ca_required(self):
"""Test using no CA Set"""
self.stage.certificate_authorities.clear()
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_no_cert_optional(self):
"""Test using no cert Set"""
self.stage.mode = TLSMode.OPTIONAL
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
def test_enroll(self):
"""Test Enrollment flow"""
self.flow.designation = FlowDesignation.ENROLLMENT
self.flow.save()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PROMPT], {"email": None, "name": "client"})
self.assertEqual(
plan().context[PLAN_CONTEXT_CERTIFICATE],
{
"fingerprint_sha1": "52:39:ca:1e:3a:1f:78:3a:9f:26:3b:c2:84:99:48:68:99:99:81:8a",
"fingerprint_sha256": (
"c1:07:8b:7c:e9:02:57:87:1e:92:e5:81:83:21:bc:92:c7:47:65:e3:97:fb:05:97:6f:36:9e:b5:31:77:98:b7"
),
"issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA",
"serial_number": "70153443448884702681996102271549704759327537151",
"subject": "CN=client",
},
)

View File

@ -1,5 +0,0 @@
"""API URLs"""
from authentik.enterprise.stages.mtls.api import MutualTLSStageViewSet
api_urlpatterns = [("stages/mtls", MutualTLSStageViewSet)]

View File

@ -8,7 +8,6 @@ from django.test import TestCase
from django.utils.timezone import now
from rest_framework.exceptions import ValidationError
from authentik.core.models import User
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import (
THRESHOLD_READ_ONLY_WEEKS,
@ -72,9 +71,9 @@ class TestEnterpriseLicense(TestCase):
)
def test_valid_multiple(self):
"""Check license verification"""
lic = License.objects.create(key=generate_id(), expiry=expiry_valid)
lic = License.objects.create(key=generate_id())
self.assertTrue(lic.status.status().is_valid)
lic2 = License.objects.create(key=generate_id(), expiry=expiry_valid)
lic2 = License.objects.create(key=generate_id())
self.assertTrue(lic2.status.status().is_valid)
total = LicenseKey.get_total()
self.assertEqual(total.internal_users, 200)
@ -233,9 +232,7 @@ class TestEnterpriseLicense(TestCase):
)
def test_expiry_expired(self):
"""Check license verification"""
User.objects.all().delete()
License.objects.all().delete()
License.objects.create(key=generate_id(), expiry=expiry_expired)
License.objects.create(key=generate_id())
self.assertEqual(LicenseKey.get_total().summary().status, LicenseUsageStatus.EXPIRED)
@patch(

View File

@ -57,7 +57,7 @@ class LogEventSerializer(PassiveSerializer):
@contextmanager
def capture_logs(log_default_output=True) -> Generator[list[LogEvent]]:
def capture_logs(log_default_output=True) -> Generator[list[LogEvent], None, None]:
"""Capture log entries created"""
logs = []
cap = LogCapture()

View File

@ -24,7 +24,7 @@ from requests import RequestException
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.brands.utils import DEFAULT_BRAND
from authentik.core.middleware import (
@ -473,7 +473,7 @@ class NotificationTransport(SerializerModel):
"title": notification.body,
"color": "#fd4b2d",
"fields": fields,
"footer": f"authentik {authentik_full_version()}",
"footer": f"authentik {get_full_version()}",
}
],
}

View File

@ -7,7 +7,7 @@ from django.core.mail.backends.locmem import EmailBackend
from django.test import TestCase
from requests_mock import Mocker
from authentik import authentik_full_version
from authentik import get_full_version
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import (
Event,
@ -118,7 +118,7 @@ class TestEventTransports(TestCase):
{"short": True, "title": "Event user", "value": self.user.username},
{"title": "foo", "value": "bar,"},
],
"footer": f"authentik {authentik_full_version()}",
"footer": f"authentik {get_full_version()}",
}
],
},

View File

@ -10,7 +10,7 @@ from django.core.management.base import BaseCommand
from django.test import RequestFactory
from structlog.stdlib import get_logger
from authentik import authentik_version
from authentik import __version__
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
@ -99,7 +99,7 @@ class Command(BaseCommand):
total_min: int = min(min(inner) for inner in values)
total_avg = sum(sum(inner) for inner in values) / sum(len(inner) for inner in values)
print(f"Version: {authentik_version()}")
print(f"Version: {__version__}")
print(f"Processes: {len(values)}")
print(f"\tMax: {total_max * 100}ms")
print(f"\tMin: {total_min * 100}ms")

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-27 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.AddField(
model_name="flowtoken",
name="revoke_on_execution",
field=models.BooleanField(default=True),
),
]

View File

@ -303,10 +303,9 @@ class FlowToken(Token):
flow = models.ForeignKey(Flow, on_delete=models.CASCADE)
_plan = models.TextField()
revoke_on_execution = models.BooleanField(default=True)
@staticmethod
def pickle(plan: "FlowPlan") -> str:
def pickle(plan) -> str:
"""Pickle into string"""
data = dumps(plan)
return b64encode(data).decode()

View File

@ -99,10 +99,9 @@ class ChallengeStageView(StageView):
self.logger.debug("Got StageInvalidException", exc=exc)
return self.executor.stage_invalid()
if not challenge.is_valid():
self.logger.error(
self.logger.warning(
"f(ch): Invalid challenge",
errors=challenge.errors,
challenge=challenge.data,
)
return HttpChallengeResponse(challenge)

View File

@ -15,7 +15,6 @@
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">
<meta name="sentry-trace" content="{{ sentry_trace }}" />
<link rel="prefetch" href="{{ flow_background_url }}" />
{% include "base/header_js.html" %}
<style>
html,
@ -23,7 +22,7 @@
height: 100%;
}
body {
background-image: url("{{ flow_background_url }}");
background-image: url("{{ flow.background_url }}");
background-repeat: no-repeat;
background-size: cover;
}

View File

@ -5,9 +5,9 @@
{% block head_before %}
{{ block.super }}
<link rel="prefetch" href="{{ flow_background_url }}" />
<link rel="prefetch" href="{{ flow.background_url }}" />
{% if flow.compatibility_mode and not inspector %}
<script>ShadyDOM = { force: true };</script>
<script>ShadyDOM = { force: !navigator.webdriver };</script>
{% endif %}
{% include "base/header_js.html" %}
<script>
@ -21,7 +21,7 @@ window.authentik.flow = {
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style>
:root {
--ak-flow-background: url("{{ flow_background_url }}");
--ak-flow-background: url("{{ flow.background_url }}");
}
</style>
{% endblock %}

View File

@ -1,10 +1,7 @@
"""Test helpers"""
from collections.abc import Callable, Generator
from contextlib import contextmanager
from json import loads
from typing import Any
from unittest.mock import MagicMock, patch
from django.http.response import HttpResponse
from django.urls.base import reverse
@ -12,8 +9,6 @@ from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
class FlowTestCase(APITestCase):
@ -49,12 +44,3 @@ class FlowTestCase(APITestCase):
def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
"""Wrapper around assertStageResponse that checks for a redirect"""
return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
@contextmanager
def assertFlowFinishes(self) -> Generator[Callable[[], FlowPlan]]:
"""Capture the flow plan before the flow finishes and return it"""
try:
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
yield lambda: self.client.session.get(SESSION_KEY_PLAN)
finally:
pass

View File

@ -146,8 +146,7 @@ class FlowExecutorView(APIView):
except (AttributeError, EOFError, ImportError, IndexError) as exc:
LOGGER.warning("f(exec): Failed to restore token plan", exc=exc)
finally:
if token.revoke_on_execution:
token.delete()
token.delete()
if not isinstance(plan, FlowPlan):
return None
plan.context[PLAN_CONTEXT_IS_RESTORED] = token

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