mirror of
https://github.com/goauthentik/authentik
synced 2026-04-27 09:57:31 +02:00
Compare commits
168 Commits
dependabot
...
version-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718e8ce93f | ||
|
|
4af088740f | ||
|
|
1ee10c7359 | ||
|
|
4bdfcbb47f | ||
|
|
01d8d7cdbf | ||
|
|
7b0f19465b | ||
|
|
8779d7132c | ||
|
|
4b5050abd5 | ||
|
|
be4ed7c779 | ||
|
|
c385d09dd9 | ||
|
|
4b88c62f9f | ||
|
|
88a056af5d | ||
|
|
8db54884a0 | ||
|
|
32085291f0 | ||
|
|
a1c7c9a163 | ||
|
|
f626d26c56 | ||
|
|
99cff7e93e | ||
|
|
f2188d00f9 | ||
|
|
8a7129d74e | ||
|
|
1e96e0e639 | ||
|
|
13bb8e3145 | ||
|
|
7a52df2901 | ||
|
|
6d8554870f | ||
|
|
aafb7cb7dc | ||
|
|
241e674b64 | ||
|
|
b72e3b55a0 | ||
|
|
f758ed2c17 | ||
|
|
00fad79ea3 | ||
|
|
ac0df081c1 | ||
|
|
bcefa8b7a1 | ||
|
|
fb8cb21967 | ||
|
|
170b6619df | ||
|
|
a8c4c4e70d | ||
|
|
c243fe4914 | ||
|
|
6402010292 | ||
|
|
e35984096d | ||
|
|
b1272150b9 | ||
|
|
e44cf378d7 | ||
|
|
653a0ba794 | ||
|
|
dfa5378804 | ||
|
|
eec0ca4907 | ||
|
|
b95312b13b | ||
|
|
fb708188bc | ||
|
|
e084c629a7 | ||
|
|
cd04a205b4 | ||
|
|
bfdd00a622 | ||
|
|
1358eed96c | ||
|
|
467321f570 | ||
|
|
94f7a6d45d | ||
|
|
c29c0de498 | ||
|
|
f8060de2f0 | ||
|
|
9bf58f9c22 | ||
|
|
0d617e4ad1 | ||
|
|
4adc0eaf8e | ||
|
|
7de405db6d | ||
|
|
50b291d6c4 | ||
|
|
14005fe781 | ||
|
|
591153b6cd | ||
|
|
864856733e | ||
|
|
1b66803a31 | ||
|
|
d8579b02ed | ||
|
|
f98d464323 | ||
|
|
7828facc41 | ||
|
|
ffe2bde51f | ||
|
|
f6dcdd059c | ||
|
|
2629759293 | ||
|
|
1b9bd8d4af | ||
|
|
c0e5ac3127 | ||
|
|
53f4bd613f | ||
|
|
83e41efe07 | ||
|
|
ad569be1d5 | ||
|
|
064866ccc7 | ||
|
|
36593d4700 | ||
|
|
2857e4df95 | ||
|
|
28b4a927ef | ||
|
|
7a20845a03 | ||
|
|
76ca2fbf77 | ||
|
|
e4e8bc57f1 | ||
|
|
15380dee37 | ||
|
|
b4844f8800 | ||
|
|
e9ff4f79ca | ||
|
|
92fb2f0f2b | ||
|
|
f80ce9dd6c | ||
|
|
a233feec29 | ||
|
|
bc9215a2ff | ||
|
|
263a2bca6d | ||
|
|
4cc71ef161 | ||
|
|
f66c535ae0 | ||
|
|
893325a7b7 | ||
|
|
a62c73d6f1 | ||
|
|
483710a59c | ||
|
|
b8b7584e8e | ||
|
|
2fedc3d0a0 | ||
|
|
7f0b45f921 | ||
|
|
3905c281ad | ||
|
|
e6099d43f5 | ||
|
|
a91145bc7b | ||
|
|
3f38d5c7d9 | ||
|
|
c00df0573c | ||
|
|
c3a0edee00 | ||
|
|
8b81ca36ea | ||
|
|
698de68a36 | ||
|
|
db35593b24 | ||
|
|
445fa31b57 | ||
|
|
a9aa1bf2c2 | ||
|
|
d018f0381c | ||
|
|
7dd1cd5c59 | ||
|
|
c219a6804a | ||
|
|
d9310d04b0 | ||
|
|
f471ef0e2e | ||
|
|
31a010c108 | ||
|
|
96e6ab291e | ||
|
|
ebf68311c2 | ||
|
|
fd365b2a09 | ||
|
|
41104da41f | ||
|
|
7edebdec03 | ||
|
|
fb56a54eb1 | ||
|
|
31cd6eb8ce | ||
|
|
092c5eb33c | ||
|
|
3e41bba54d | ||
|
|
9f8fd6eabe | ||
|
|
35fb55da15 | ||
|
|
b1d571a5af | ||
|
|
fb589592b5 | ||
|
|
6468bb5707 | ||
|
|
70406664dc | ||
|
|
c58c194180 | ||
|
|
fad87741e7 | ||
|
|
f6679895e5 | ||
|
|
a573a72ecb | ||
|
|
b72709ebbc | ||
|
|
449742fbc0 | ||
|
|
1b02cc0dae | ||
|
|
b0945ee7e9 | ||
|
|
6682136af1 | ||
|
|
24cb5ae4c1 | ||
|
|
9e272c7121 | ||
|
|
5dc7b7cdae | ||
|
|
2e2c52e49c | ||
|
|
38f1ef0506 | ||
|
|
3517562549 | ||
|
|
cdbe40143d | ||
|
|
5816f0d17c | ||
|
|
907ea8b2e9 | ||
|
|
b38af89960 | ||
|
|
d52db187bf | ||
|
|
2093e0e63f | ||
|
|
2791d87ceb | ||
|
|
fdc3d95b59 | ||
|
|
de7a61cee0 | ||
|
|
f2805b9b8a | ||
|
|
f48a91fbf4 | ||
|
|
f056c0808d | ||
|
|
06a6d45139 | ||
|
|
0e12642f12 | ||
|
|
01406d364e | ||
|
|
b9b16dba59 | ||
|
|
1ef83f3295 | ||
|
|
343506d104 | ||
|
|
aeb4e1057e | ||
|
|
0bcd1c268c | ||
|
|
ecba1ffe94 | ||
|
|
b7d303936c | ||
|
|
c1bc2a4565 | ||
|
|
1422c3aff3 | ||
|
|
d4a77583ea | ||
|
|
78d270bf25 | ||
|
|
6d1c7f90e2 |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -57,7 +57,7 @@ runs:
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
cd web && npm i
|
||||
cd web && npm ci
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: uv run python {0}
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -142,7 +142,9 @@ updates:
|
||||
labels:
|
||||
- dependencies
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
directories:
|
||||
- /
|
||||
- /website
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
|
||||
@@ -67,21 +67,20 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: make empty clients
|
||||
if: ${{ inputs.release }}
|
||||
run: |
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: generate ts client
|
||||
if: ${{ !inputs.release }}
|
||||
run: make gen-client-ts
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
id: push
|
||||
|
||||
1
.github/workflows/api-ts-publish.yml
vendored
1
.github/workflows/api-ts-publish.yml
vendored
@@ -15,7 +15,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
1
.github/workflows/ci-docs-source.yml
vendored
1
.github/workflows/ci-docs-source.yml
vendored
@@ -13,7 +13,6 @@ env:
|
||||
|
||||
jobs:
|
||||
publish-source-docs:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
2
.github/workflows/ci-docs.yml
vendored
2
.github/workflows/ci-docs.yml
vendored
@@ -61,7 +61,6 @@ jobs:
|
||||
working-directory: website/
|
||||
run: npm run build -w integrations
|
||||
build-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
@@ -121,4 +120,3 @@ jobs:
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }}
|
||||
|
||||
1
.github/workflows/ci-main-daily.yml
vendored
1
.github/workflows/ci-main-daily.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
|
||||
jobs:
|
||||
test-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
10
.github/workflows/ci-main.yml
vendored
10
.github/workflows/ci-main.yml
vendored
@@ -80,7 +80,15 @@ jobs:
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Previous stable tag
|
||||
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Current version family based on
|
||||
current_version_family=$(python -c "from authentik import VERSION; print(VERSION)" | grep -vE -- 'rc[0-9]+$')
|
||||
if [[ -n $current_version_family ]]; then
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout $(prev_stable)
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: Setup authentik env (stable)
|
||||
|
||||
1
.github/workflows/ci-outpost.yml
vendored
1
.github/workflows/ci-outpost.yml
vendored
@@ -67,7 +67,6 @@ jobs:
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
|
||||
@@ -13,7 +13,6 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
15
.github/workflows/gh-ghcr-retention.yml
vendored
15
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -5,10 +5,13 @@ on:
|
||||
# schedule:
|
||||
# - cron: "0 0 * * *" # every day at midnight
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run:
|
||||
type: boolean
|
||||
description: Enable dry-run mode
|
||||
|
||||
jobs:
|
||||
clean-ghcr:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
name: Delete old unused container images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -18,12 +21,12 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v2
|
||||
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
|
||||
with:
|
||||
image-names: dev-server,dev-ldap,dev-proxy
|
||||
image-tags: "!gh-next,!gh-main"
|
||||
cut-off: One week ago UTC
|
||||
account-type: org
|
||||
org-name: goauthentik
|
||||
untagged-only: false
|
||||
account: goauthentik
|
||||
tag-selection: untagged
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
skip-tags: gh-next,gh-main
|
||||
dry-run: ${{ inputs.dry-run }}
|
||||
|
||||
1
.github/workflows/packages-npm-publish.yml
vendored
1
.github/workflows/packages-npm-publish.yml
vendored
@@ -19,7 +19,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
1
.github/workflows/release-next-branch.yml
vendored
1
.github/workflows/release-next-branch.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-next:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
|
||||
22
.github/workflows/release-publish.yml
vendored
22
.github/workflows/release-publish.yml
vendored
@@ -84,9 +84,14 @@ jobs:
|
||||
- rac
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -98,10 +103,10 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||
- name: make empty clients
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
@@ -155,10 +160,17 @@ jobs:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build web
|
||||
- name: Install web dependencies
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm run build-proxy
|
||||
- name: Build outpost
|
||||
run: |
|
||||
|
||||
9
.github/workflows/release-tag.yml
vendored
9
.github/workflows/release-tag.yml
vendored
@@ -47,8 +47,14 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
@@ -83,11 +89,12 @@ jobs:
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git pull
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
|
||||
22
.github/workflows/repo-mirror-cleanup.yml
vendored
22
.github/workflows/repo-mirror-cleanup.yml
vendored
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Repo - Cleanup internal mirror
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
to_internal:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ env.MIRROR_KEY != '' }}
|
||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb # 5cf300935bc2e068f73ea69bcc411a8a997208eb
|
||||
with:
|
||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
||||
args: --tags --force --prune
|
||||
env:
|
||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
||||
21
.github/workflows/repo-mirror.yml
vendored
21
.github/workflows/repo-mirror.yml
vendored
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: Repo - Mirror to internal
|
||||
|
||||
on: [push, delete]
|
||||
|
||||
jobs:
|
||||
to_internal:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ env.MIRROR_KEY != '' }}
|
||||
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb # 5cf300935bc2e068f73ea69bcc411a8a997208eb
|
||||
with:
|
||||
target_repo_url: git@github.com:goauthentik/authentik-internal.git
|
||||
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
|
||||
args: --tags --force
|
||||
env:
|
||||
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}
|
||||
1
.github/workflows/repo-stale.yml
vendored
1
.github/workflows/repo-stale.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
@@ -17,7 +17,6 @@ env:
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-trixie-slim@sha256:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe AS node-builder
|
||||
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -44,6 +44,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -57,13 +58,14 @@ COPY ./go.mod /go/src/goauthentik.io/go.mod
|
||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
go build -o /go/authentik ./cmd/server
|
||||
|
||||
# Stage 3: MaxMind GeoIP
|
||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1 AS geoip
|
||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1@sha256:faecdca22579730ab0b7dea5aa9af350bb3c93cb9d39845c173639ead30346d2 AS geoip
|
||||
|
||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
|
||||
ENV GEOIPUPDATE_VERBOSE="1"
|
||||
@@ -76,9 +78,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/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"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.6@sha256:4b96ee9429583983fd172c33a02ecac5242d63fb46bc27804748e38c1cc9ad0d AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips AS python-base
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
ENV VENV_PATH="/ak-root/.venv" \
|
||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||
@@ -139,6 +141,7 @@ ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
|
||||
14
Makefile
14
Makefile
@@ -120,11 +120,11 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
||||
ifndef version
|
||||
$(error Usage: make bump version=20xx.xx.xx )
|
||||
endif
|
||||
$(eval current_version := $(shell cat ${PWD}/internal/constants/VERSION))
|
||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(MAKE) gen-build gen-compose 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)
|
||||
sed -i "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/package-lock.json ${PWD}/web/package.json ${PWD}/web/package-lock.json
|
||||
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
||||
|
||||
#########################
|
||||
@@ -189,23 +189,15 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
||||
|
||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||
mkdir -p ${PWD}/${GEN_API_PY}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_PY}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_PY} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||
|
||||
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
|
||||
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
|
||||
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
||||
|
||||
gen-dev-config: ## Generate a local development config file
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.10.0-rc1"
|
||||
VERSION = "2025.10.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Test brands"""
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.brands.api import Themes
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application
|
||||
@@ -23,6 +26,7 @@ class TestBrands(APITestCase):
|
||||
_flag = flag()
|
||||
if _flag.visibility == "public":
|
||||
self.default_flags[_flag.key] = _flag.get()
|
||||
Brand.objects.all().delete()
|
||||
|
||||
def test_current_brand(self):
|
||||
"""Test Current brand API"""
|
||||
@@ -44,7 +48,6 @@ class TestBrands(APITestCase):
|
||||
|
||||
def test_brand_subdomain(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.all().delete()
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
@@ -65,7 +68,6 @@ class TestBrands(APITestCase):
|
||||
|
||||
def test_fallback(self):
|
||||
"""Test fallback brand"""
|
||||
Brand.objects.all().delete()
|
||||
self.assertJSONEqual(
|
||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||
{
|
||||
@@ -81,6 +83,109 @@ class TestBrands(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
@apply_blueprint("default/default-brand.yaml")
|
||||
def test_blueprint(self):
|
||||
"""Test Current brand API"""
|
||||
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
@apply_blueprint("default/default-brand.yaml")
|
||||
def test_blueprint_with_other_brand(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_same_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom-strong",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_other_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="other.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom-weak",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_create_default_multiple(self):
|
||||
"""Test attempted creation of multiple default brands"""
|
||||
Brand.objects.create(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.db.models import Case, F, IntegerField, Q, Value, When
|
||||
from django.db.models.functions import Length
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -19,15 +19,36 @@ DEFAULT_BRAND = Brand(domain="fallback")
|
||||
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
db_brands = (
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||
.order_by("default")
|
||||
|
||||
brand = (
|
||||
Brand.objects.annotate(
|
||||
host_domain=Value(request.get_host()),
|
||||
domain_length=Length("domain"),
|
||||
match_priority=Case(
|
||||
When(
|
||||
condition=Q(host_domain__iendswith=F("domain")),
|
||||
then=F("domain_length"),
|
||||
),
|
||||
default=Value(-1),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
is_default_fallback=Case(
|
||||
When(
|
||||
condition=Q(default=True),
|
||||
then=Value(0),
|
||||
),
|
||||
default=Value(-2),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.filter(Q(match_priority__gt=-1) | Q(default=True))
|
||||
.order_by("-match_priority", "-is_default_fallback")
|
||||
.first()
|
||||
)
|
||||
brands = list(db_brands.all())
|
||||
if len(brands) < 1:
|
||||
|
||||
if brand is None:
|
||||
return DEFAULT_BRAND
|
||||
return brands[0]
|
||||
return brand
|
||||
|
||||
|
||||
def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
|
||||
@@ -4,7 +4,8 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -23,6 +24,7 @@ from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
@@ -63,9 +65,21 @@ class ApplicationSerializer(ModelSerializer):
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
user = None
|
||||
user_data = None
|
||||
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
return app.get_launch_url(user)
|
||||
|
||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
||||
# for multiple applications. UserSerializer accesses user.ak_groups which
|
||||
# would otherwise trigger a query for each application.
|
||||
if user is not None:
|
||||
if "_cached_user_data" not in self.context:
|
||||
# Prefetch groups to avoid N+1
|
||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
||||
user_data = self.context["_cached_user_data"]
|
||||
|
||||
return app.get_launch_url(user, user_data=user_data)
|
||||
|
||||
def validate_slug(self, slug: str) -> str:
|
||||
if slug in Application.reserved_slugs:
|
||||
@@ -158,8 +172,23 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
applications.append(application)
|
||||
return applications
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
Re-fetch with proper prefetching for serialization
|
||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
||||
during serialization when get_provider() is called
|
||||
"""
|
||||
if not applications:
|
||||
return self.get_queryset().none()
|
||||
pks = [app.pk for app in applications]
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(pk__in=pks)
|
||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, paginated_apps: Iterator[Application]
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in paginated_apps:
|
||||
@@ -262,6 +291,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@@ -280,6 +311,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
@@ -18,10 +18,14 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||
assigned_application_name = ReadOnlyField(source="application.name")
|
||||
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
||||
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||
assigned_backchannel_application_slug = ReadOnlyField(
|
||||
source="backchannel_application.slug", allow_null=True
|
||||
)
|
||||
assigned_backchannel_application_name = ReadOnlyField(
|
||||
source="backchannel_application.name", allow_null=True
|
||||
)
|
||||
|
||||
component = SerializerMethodField()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from uuid import uuid4
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.translation import override
|
||||
@@ -47,7 +47,7 @@ async def aget_user(request):
|
||||
|
||||
|
||||
class AuthenticationMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
def process_request(self, request: HttpRequest) -> HttpResponseBadRequest | None:
|
||||
if not hasattr(request, "session"):
|
||||
raise ImproperlyConfigured(
|
||||
"The Django authentication middleware requires session "
|
||||
@@ -62,7 +62,8 @@ class AuthenticationMiddleware(MiddlewareMixin):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and not user.is_active:
|
||||
logout(request)
|
||||
raise AssertionError()
|
||||
return HttpResponseBadRequest()
|
||||
return None
|
||||
|
||||
|
||||
class ImpersonateMiddleware:
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.db import models
|
||||
from django.db.models import Q, QuerySet, options
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.http import HttpRequest
|
||||
from django.utils.functional import SimpleLazyObject, cached_property
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_cte import CTE, with_cte
|
||||
@@ -524,6 +524,10 @@ class ApplicationQuerySet(QuerySet):
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
# Also prefetch/select through each subclass path to ensure casted instances have access
|
||||
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
|
||||
qs = qs.select_related(f"provider__{subclass}__application")
|
||||
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
|
||||
return qs
|
||||
|
||||
|
||||
@@ -583,20 +587,28 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
|
||||
return self.meta_icon.url
|
||||
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
def get_launch_url(
|
||||
self, user: Optional["User"] = None, user_data: dict | None = None
|
||||
) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
|
||||
|
||||
Args:
|
||||
user: User instance for formatting the URL
|
||||
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
|
||||
"""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
if self.meta_launch_url:
|
||||
url = self.meta_launch_url
|
||||
elif provider := self.get_provider():
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
if isinstance(user, SimpleLazyObject):
|
||||
user._setup()
|
||||
user = user._wrapped
|
||||
try:
|
||||
return url % user.__dict__
|
||||
|
||||
# Use pre-serialized data if available, otherwise serialize now
|
||||
if user_data is None:
|
||||
user_data = UserSerializer(instance=user).data
|
||||
return url % user_data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
@@ -17,6 +19,8 @@ from authentik.core.models import (
|
||||
User,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
@@ -47,6 +51,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
if session:
|
||||
session.save()
|
||||
|
||||
if not RefreshOtherFlowsAfterAuthentication().get():
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
async_to_sync(layer.group_send)(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=AuthenticatedSession)
|
||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||
|
||||
@@ -35,8 +35,13 @@ def clean_expired_models():
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
clear_expired_cache()
|
||||
Message.delete_expired()
|
||||
GroupChannel.delete_expired()
|
||||
for cls in [Message, GroupChannel]:
|
||||
objects = cls.objects.all().filter(expires__lt=now())
|
||||
amount = objects.count()
|
||||
for obj in chunked_queryset(objects):
|
||||
obj.delete()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
|
||||
|
||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
||||
|
||||
@@ -194,6 +194,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
@@ -248,6 +250,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
|
||||
@@ -28,8 +28,8 @@ from authentik.core.views.interface import (
|
||||
)
|
||||
from authentik.flows.views.interface import FlowInterfaceView
|
||||
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
from authentik.root.ws.consumer import MessageConsumer
|
||||
from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from binascii import hexlify
|
||||
from hashlib import md5
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from textwrap import wrap
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@@ -20,6 +22,11 @@ from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def format_cert(raw_pam: str) -> str:
|
||||
"""Format a PEM certificate that is either missing its header/footer or is in a single line"""
|
||||
return "\n".join([PEM_HEADER, *wrap(raw_pam.replace("\n", ""), 64), PEM_FOOTER])
|
||||
|
||||
|
||||
def fingerprint_sha256(cert: Certificate) -> str:
|
||||
"""Get SHA256 Fingerprint of certificate"""
|
||||
return hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8")
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"""Enterprise app config"""
|
||||
|
||||
from django.conf import settings
|
||||
from prometheus_client import Gauge
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
|
||||
GAUGE_LICENSE_USAGE = Gauge(
|
||||
"authentik_enterprise_license_usage",
|
||||
"Enterprise license usage (percentage per user type).",
|
||||
["user_type"],
|
||||
)
|
||||
GAUGE_LICENSE_EXPIRY = Gauge(
|
||||
"authentik_enterprise_license_expiry_seconds", "Duration until license expires, in seconds."
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseConfig(ManagedAppConfig):
|
||||
"""Base app config for all enterprise apps"""
|
||||
|
||||
@@ -217,7 +217,7 @@ class LicenseKey:
|
||||
def summary(self) -> LicenseSummary:
|
||||
"""Summary of license status"""
|
||||
status = self.status()
|
||||
latest_valid = datetime.fromtimestamp(self.exp)
|
||||
latest_valid = datetime.fromtimestamp(self.exp).replace(tzinfo=UTC)
|
||||
return LicenseSummary(
|
||||
latest_valid=latest_valid,
|
||||
internal_users=self.internal_users,
|
||||
|
||||
@@ -42,6 +42,8 @@ def send_ssf_events(
|
||||
for stream in Stream.objects.filter(**stream_filter):
|
||||
event_data = stream.prepare_event_payload(event_type, data, **extra_data)
|
||||
events_data[stream.uuid] = event_data
|
||||
if not events_data:
|
||||
return
|
||||
ssf_events_dispatch.send(events_data)
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
"""Enterprise signals"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_delete, post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
|
||||
from authentik.enterprise.license import CACHE_KEY_ENTERPRISE_LICENSE
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.apps import GAUGE_LICENSE_EXPIRY, GAUGE_LICENSE_USAGE
|
||||
from authentik.enterprise.license import CACHE_KEY_ENTERPRISE_LICENSE, LicenseKey
|
||||
from authentik.enterprise.models import License, LicenseUsageStatus
|
||||
from authentik.enterprise.tasks import enterprise_update_usage
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
|
||||
|
||||
@receiver(monitoring_set)
|
||||
def monitoring_set_enterprise(sender, **kwargs):
|
||||
"""set enterprise gauges"""
|
||||
summary = LicenseKey.cached_summary()
|
||||
if summary.status == LicenseUsageStatus.UNLICENSED:
|
||||
return
|
||||
percentage_internal = (
|
||||
0
|
||||
if summary.internal_users <= 0
|
||||
else LicenseKey.get_internal_user_count() / (summary.internal_users / 100)
|
||||
)
|
||||
percentage_external = (
|
||||
0
|
||||
if summary.external_users <= 0
|
||||
else LicenseKey.get_external_user_count() / (summary.external_users / 100)
|
||||
)
|
||||
GAUGE_LICENSE_USAGE.labels(user_type="internal").set(percentage_internal)
|
||||
GAUGE_LICENSE_USAGE.labels(user_type="external").set(percentage_external)
|
||||
GAUGE_LICENSE_EXPIRY.set((summary.latest_valid.replace(tzinfo=UTC) - now()).total_seconds())
|
||||
|
||||
|
||||
@receiver(pre_save, sender=License)
|
||||
def pre_save_license(sender: type[License], instance: License, **_):
|
||||
"""Extract data from license jwt and save it into model"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from binascii import hexlify
|
||||
from enum import IntFlag, auto
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
@@ -17,7 +18,7 @@ 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, fingerprint_sha256
|
||||
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256, format_cert
|
||||
from authentik.enterprise.stages.mtls.models import (
|
||||
CertAttributes,
|
||||
MutualTLSStage,
|
||||
@@ -43,14 +44,28 @@ HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate"
|
||||
PLAN_CONTEXT_CERTIFICATE = "certificate"
|
||||
|
||||
|
||||
class ParseOptions(IntFlag):
|
||||
|
||||
# URL unquote the string
|
||||
UNQUOTE = auto()
|
||||
# Re-add PEM Header & footer, and chunk it into 64 character lines
|
||||
FORMAT = auto()
|
||||
|
||||
|
||||
class MTLSStageView(ChallengeStageView):
|
||||
|
||||
def __parse_single_cert(self, raw: str | None) -> list[Certificate]:
|
||||
def __parse_single_cert(self, raw: str | None, *options: ParseOptions) -> list[Certificate]:
|
||||
"""Helper to parse a single certificate"""
|
||||
if not raw:
|
||||
return []
|
||||
for opt in options:
|
||||
match opt:
|
||||
case ParseOptions.FORMAT:
|
||||
raw = format_cert(raw)
|
||||
case ParseOptions.UNQUOTE:
|
||||
raw = unquote_plus(raw)
|
||||
try:
|
||||
cert = load_pem_x509_certificate(unquote_plus(raw).encode())
|
||||
cert = load_pem_x509_certificate(raw.encode())
|
||||
return [cert]
|
||||
except ValueError as exc:
|
||||
self.logger.info("Failed to parse certificate", exc=exc)
|
||||
@@ -59,6 +74,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
def _parse_cert_xfcc(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format given to us in
|
||||
the format of the authentik router/envoy"""
|
||||
# https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
|
||||
xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED)
|
||||
if not xfcc_raw:
|
||||
return []
|
||||
@@ -68,18 +84,26 @@ class MTLSStageView(ChallengeStageView):
|
||||
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"]))
|
||||
certs.extend(self.__parse_single_cert(raw_cert["Cert"], ParseOptions.UNQUOTE))
|
||||
return certs
|
||||
|
||||
def _parse_cert_nginx(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format nginx-ingress gives to us"""
|
||||
# https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#client-certificate-authentication
|
||||
# https://github.com/kubernetes/ingress-nginx/blob/78f593b24494a0674b362faf551079f06d71b5a9/rootfs/etc/nginx/template/nginx.tmpl#L1096
|
||||
sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED)
|
||||
return self.__parse_single_cert(sslcc_raw)
|
||||
return self.__parse_single_cert(sslcc_raw, ParseOptions.UNQUOTE)
|
||||
|
||||
def _parse_cert_traefik(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format traefik gives to us"""
|
||||
# https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/passtlsclientcert/
|
||||
ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED)
|
||||
return self.__parse_single_cert(ftcc_raw)
|
||||
if not ftcc_raw:
|
||||
return []
|
||||
certs = []
|
||||
for cert in ftcc_raw.split(","):
|
||||
certs.extend(self.__parse_single_cert(cert, ParseOptions.UNQUOTE, ParseOptions.FORMAT))
|
||||
return certs
|
||||
|
||||
def _parse_cert_outpost(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format outposts give to us. Also authenticates
|
||||
@@ -92,7 +116,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
) 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)
|
||||
return self.__parse_single_cert(outpost_raw, ParseOptions.UNQUOTE)
|
||||
|
||||
def get_authorities(self) -> list[CertificateKeyPair] | None:
|
||||
# We can't access `certificate_authorities` on `self.executor.current_stage`, as that would
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
@@ -51,6 +52,10 @@ class MTLSStageTests(FlowTestCase):
|
||||
User.objects.filter(username="client").delete()
|
||||
self.cert_user = create_test_user(username="client")
|
||||
|
||||
def _format_traefik(self, cert: str | None = None):
|
||||
cert = cert if cert else self.client_cert
|
||||
return quote_plus(cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", ""))
|
||||
|
||||
def test_parse_xfcc(self):
|
||||
"""Test authentik Proxy/Envoy's XFCC format"""
|
||||
with self.assertFlowFinishes() as plan:
|
||||
@@ -78,7 +83,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -138,7 +143,9 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={
|
||||
"X-Forwarded-TLS-Client-Cert": self._format_traefik(cert.certificate_data)
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -149,7 +156,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -163,7 +170,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -176,7 +183,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -187,7 +194,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -209,7 +216,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
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)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
|
||||
49
authentik/enterprise/tests/test_metrics.py
Normal file
49
authentik/enterprise/tests/test_metrics.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Enterprise metrics tests"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.tests.test_license import expiry_valid
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
|
||||
class TestEnterpriseMetrics(TestCase):
|
||||
"""Enterprise metrics tests"""
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_usage_empty(self):
|
||||
"""Test usage (no users)"""
|
||||
License.objects.create(key=generate_id())
|
||||
User.objects.all().delete()
|
||||
create_test_user()
|
||||
monitoring_set.send_robust(self)
|
||||
self.assertEqual(
|
||||
REGISTRY.get_sample_value(
|
||||
"authentik_enterprise_license_usage", {"user_type": "internal"}
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
REGISTRY.get_sample_value(
|
||||
"authentik_enterprise_license_usage", {"user_type": "external"}
|
||||
),
|
||||
0,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
@@ -28,7 +28,7 @@ class LogEvent:
|
||||
def from_event_dict(item: EventDict) -> "LogEvent":
|
||||
event = item.pop("event")
|
||||
log_level = item.pop("level").lower()
|
||||
timestamp = datetime.fromisoformat(item.pop("timestamp"))
|
||||
timestamp = datetime.fromisoformat(item.pop("timestamp")).replace(tzinfo=UTC)
|
||||
item.pop("pid", None)
|
||||
# Sometimes log entries have both `level` and `log_level` set, but `level` is always set
|
||||
item.pop("log_level", None)
|
||||
|
||||
@@ -4,6 +4,7 @@ from prometheus_client import Gauge, Histogram
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
GAUGE_FLOWS_CACHED = Gauge(
|
||||
"authentik_flows_cached",
|
||||
@@ -22,6 +23,12 @@ HIST_FLOWS_PLAN_TIME = Histogram(
|
||||
)
|
||||
|
||||
|
||||
class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
|
||||
|
||||
class AuthentikFlowsConfig(ManagedAppConfig):
|
||||
"""authentik flows app config"""
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ worker:
|
||||
consumer_listen_timeout: "seconds=30"
|
||||
task_max_retries: 5
|
||||
task_default_time_limit: "minutes=10"
|
||||
lock_purge_interval: "minutes=1"
|
||||
task_purge_interval: "days=1"
|
||||
task_expiration: "days=30"
|
||||
scheduler_interval: "seconds=60"
|
||||
|
||||
@@ -41,7 +41,7 @@ ARG_SANITIZE = re.compile(r"[:.-]")
|
||||
|
||||
|
||||
def sanitize_arg(arg_name: str) -> str:
|
||||
return re.sub(ARG_SANITIZE, "_", arg_name)
|
||||
return re.sub(ARG_SANITIZE, "_", slugify(arg_name))
|
||||
|
||||
|
||||
class BaseEvaluator:
|
||||
@@ -299,7 +299,9 @@ class BaseEvaluator:
|
||||
|
||||
def wrap_expression(self, expression: str) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
|
||||
handler_signature = ",".join(
|
||||
[x for x in [sanitize_arg(x) for x in self._context.keys()] if x]
|
||||
)
|
||||
full_expression = ""
|
||||
full_expression += f"def handler({handler_signature}):\n"
|
||||
full_expression += indent(expression, " ")
|
||||
|
||||
@@ -28,6 +28,8 @@ def register_signals(
|
||||
# This primarily happens during user login
|
||||
if sender == User and update_fields == {"last_login"}:
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
class_to_path(instance.__class__),
|
||||
instance.pk,
|
||||
@@ -39,6 +41,8 @@ def register_signals(
|
||||
|
||||
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
class_to_path(instance.__class__),
|
||||
instance.pk,
|
||||
@@ -54,6 +58,8 @@ def register_signals(
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse)
|
||||
|
||||
m2m_changed.connect(model_m2m_changed, User.ak_groups.through, dispatch_uid=uid, weak=False)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Evaluator base functions"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
@@ -239,3 +240,18 @@ class TestEvaluator(TestCase):
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
evaluator.evaluate("return ak_send_email(123, 'Test', body='Body')")
|
||||
self.assertIn("Address must be a string or list of strings", str(cm.exception))
|
||||
|
||||
def test_expr_arg_escape(self):
|
||||
"""Test escaping of arguments"""
|
||||
eval = BaseEvaluator()
|
||||
eval._context = {
|
||||
'z=getattr(getattr(__import__("os"), "popen")("id > /tmp/test"), "read")()': "bar",
|
||||
"@@": "baz",
|
||||
"{{": "baz",
|
||||
"aa@@": "baz",
|
||||
}
|
||||
res = eval.evaluate("return locals()")
|
||||
self.assertEqual(
|
||||
res, {"zgetattrgetattr__import__os_popenid_tmptest_read": "bar", "aa": "baz"}
|
||||
)
|
||||
self.assertFalse(Path("/tmp/test").exists()) # nosec
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""authentik database utilities"""
|
||||
|
||||
import gc
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db import reset_queries
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Model, QuerySet
|
||||
|
||||
|
||||
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
|
||||
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
|
||||
if not queryset.exists():
|
||||
return []
|
||||
|
||||
def get_chunks(qs: QuerySet):
|
||||
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
|
||||
qs = qs.order_by("pk")
|
||||
pks = qs.values_list("pk", flat=True)
|
||||
start_pk = pks[0]
|
||||
|
||||
@@ -47,7 +47,9 @@ class OutpostSerializer(ModelSerializer):
|
||||
)
|
||||
providers_obj = ProviderSerializer(source="providers", many=True, read_only=True)
|
||||
service_connection_obj = ServiceConnectionSerializer(
|
||||
source="service_connection", read_only=True
|
||||
source="service_connection",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
refresh_interval_s = SerializerMethodField()
|
||||
|
||||
|
||||
@@ -203,6 +203,12 @@ class DockerController(BaseController):
|
||||
"labels": self._get_labels(),
|
||||
"restart_policy": {"Name": "unless-stopped"},
|
||||
"network": self.outpost.config.docker_network,
|
||||
"healthcheck": {
|
||||
"test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
|
||||
"interval": 5 * 1_000 * 1_000_000,
|
||||
"retries": 20,
|
||||
"start_period": 3 * 1_000 * 1_000_000,
|
||||
},
|
||||
}
|
||||
if self.outpost.config.docker_map_ports:
|
||||
container_args["ports"] = {
|
||||
|
||||
@@ -49,6 +49,9 @@ def outpost_m2m_changed(sender, instance: Outpost | Provider, action: str, **_):
|
||||
if action not in ["post_add", "post_remove", "post_clear"]:
|
||||
return
|
||||
if isinstance(instance, Outpost):
|
||||
# Rebuild permissions when providers change
|
||||
LOGGER.debug("Rebuilding outpost service account permissions", outpost=instance)
|
||||
instance.build_user_permissions(instance.user)
|
||||
outpost_controller.send_with_options(
|
||||
args=(instance.pk,),
|
||||
rel_obj=instance.service_connection,
|
||||
@@ -92,6 +95,15 @@ def outpost_post_save(sender, instance: Outpost, created: bool, **_):
|
||||
|
||||
def outpost_related_post_save(sender, instance: OutpostServiceConnection | OutpostModel, **_):
|
||||
for outpost in instance.outpost_set.all():
|
||||
# Rebuild permissions in case provider's required objects changed
|
||||
if isinstance(instance, OutpostModel):
|
||||
LOGGER.info(
|
||||
"Provider changed, rebuilding permissions and sending update",
|
||||
outpost=outpost.name,
|
||||
provider=instance.name if hasattr(instance, "name") else str(instance),
|
||||
)
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
LOGGER.debug("Sending update to outpost", outpost=outpost.name, trigger="provider_change")
|
||||
outpost_send_update.send_with_options(
|
||||
args=(outpost.pk,),
|
||||
rel_obj=outpost,
|
||||
|
||||
@@ -386,11 +386,18 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
def __str__(self):
|
||||
return f"OAuth2 Provider {self.name}"
|
||||
|
||||
def encode(self, payload: dict[str, Any]) -> str:
|
||||
"""Represent the ID Token as a JSON Web Token (JWT)."""
|
||||
def encode(self, payload: dict[str, Any], jwt_type: str | None = None) -> str:
|
||||
"""Represent the ID Token as a JSON Web Token (JWT).
|
||||
|
||||
:param payload The payload to encode into the JWT
|
||||
:param jwt_type The type of the JWT. This will be put in the JWT header using the `typ`
|
||||
parameter. See RFC7515 Section 4.1.9. If not set fallback to the default of `JWT`.
|
||||
"""
|
||||
headers = {}
|
||||
if self.signing_key:
|
||||
headers["kid"] = self.signing_key.kid
|
||||
if jwt_type is not None:
|
||||
headers["typ"] = jwt_type
|
||||
key, alg = self.jwt_key
|
||||
encoded = encode(payload, key, algorithm=alg, headers=headers)
|
||||
if self.encryption_key:
|
||||
|
||||
@@ -109,7 +109,7 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
"""Revoke tokens upon user logout"""
|
||||
LOGGER.debug("Sending back-channel logout notifications signal!", session=instance)
|
||||
|
||||
access_tokens = AccessToken.objects.filter(
|
||||
access_tokens = AccessToken.objects.select_related("provider").filter(
|
||||
user=instance.user,
|
||||
session__session__session_key=instance.session.session_key,
|
||||
)
|
||||
@@ -128,7 +128,8 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
and token.provider.logout_method == OAuth2LogoutMethod.BACKCHANNEL
|
||||
]
|
||||
|
||||
backchannel_logout_notification_dispatch.send(revocations=backchannel_tokens)
|
||||
if backchannel_tokens:
|
||||
backchannel_logout_notification_dispatch.send(revocations=backchannel_tokens)
|
||||
|
||||
access_tokens.delete()
|
||||
|
||||
|
||||
@@ -113,11 +113,16 @@ class TestBackChannelLogout(OAuthTestCase):
|
||||
|
||||
def _decode_token(self, token, provider=None):
|
||||
"""Helper to decode and validate a JWT token"""
|
||||
decoded = self._decode_token_complete(token, provider)
|
||||
return decoded["payload"]
|
||||
|
||||
def _decode_token_complete(self, token, provider=None):
|
||||
"""Helper to decode and validate a JWT token into a header, and payload dict"""
|
||||
provider = provider or self.provider
|
||||
key, alg = provider.jwt_key
|
||||
if alg != "HS256":
|
||||
key = provider.signing_key.public_key
|
||||
return jwt.decode(
|
||||
return jwt.decode_complete(
|
||||
token, key, algorithms=[alg], options={"verify_exp": False, "verify_aud": False}
|
||||
)
|
||||
|
||||
@@ -155,6 +160,16 @@ class TestBackChannelLogout(OAuthTestCase):
|
||||
self.assertEqual(decoded3["sub"], sub)
|
||||
self.assertIn("events", decoded3)
|
||||
|
||||
def test_create_logout_token_header_type(self):
|
||||
"""Test creating logout tokens and checking if the token header type is correct"""
|
||||
session_id = "test-session-123"
|
||||
token1 = self._create_logout_token(session_id=session_id)
|
||||
|
||||
decoded = self._decode_token_complete(token1)
|
||||
|
||||
self.assertIsNotNone(decoded["header"])
|
||||
self.assertEqual(decoded["header"]["typ"], "logout+jwt")
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_send_backchannel_logout_request_scenarios(self, mock_get_session):
|
||||
"""Test various scenarios for backchannel logout request task"""
|
||||
|
||||
@@ -126,6 +126,30 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_deactivate(self):
|
||||
"""test deactivated user"""
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"username": "sa",
|
||||
"password": self.token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": TokenError.errors["invalid_grant"],
|
||||
"request_id": response.headers["X-authentik-id"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_permission_denied(self):
|
||||
"""test permission denied"""
|
||||
group = Group.objects.create(name="foo")
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from base64 import b64decode, urlsafe_b64encode
|
||||
from binascii import Error
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -206,7 +207,9 @@ def authenticate_provider(request: HttpRequest) -> OAuth2Provider | None:
|
||||
provider, client_id, client_secret = provider_from_request(request)
|
||||
if not provider:
|
||||
return None
|
||||
if client_id != provider.client_id or client_secret != provider.client_secret:
|
||||
if not compare_digest(client_id, provider.client_id) or not compare_digest(
|
||||
client_secret, provider.client_secret
|
||||
):
|
||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||
return None
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
@@ -259,4 +262,4 @@ def create_logout_token(
|
||||
if session_key:
|
||||
payload["sid"] = hash_session_key(session_key)
|
||||
# Encode the token
|
||||
return provider.encode(payload)
|
||||
return provider.encode(payload, jwt_type="logout+jwt")
|
||||
|
||||
@@ -4,6 +4,7 @@ from base64 import b64decode
|
||||
from binascii import Error
|
||||
from dataclasses import InitVar, dataclass
|
||||
from datetime import datetime
|
||||
from hmac import compare_digest
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Any
|
||||
@@ -161,9 +162,8 @@ class TokenParams:
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
if (
|
||||
self.provider.client_type == ClientTypes.CONFIDENTIAL
|
||||
and self.provider.client_secret != self.client_secret
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Invalid client secret",
|
||||
@@ -336,7 +336,7 @@ class TokenParams:
|
||||
self, request: HttpRequest, username: str, password: str
|
||||
):
|
||||
# Authenticate user based on credentials
|
||||
user = User.objects.filter(username=username).first()
|
||||
user = User.objects.filter(username=username, is_active=True).first()
|
||||
if not user:
|
||||
raise TokenError("invalid_grant")
|
||||
token: Token = Token.filter_not_expired(
|
||||
@@ -378,9 +378,11 @@ class TokenParams:
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to parse JWT for kid lookup", exc=exc)
|
||||
raise TokenError("invalid_grant") from None
|
||||
expected_kid = decode_unvalidated["header"]["kid"]
|
||||
fallback_alg = decode_unvalidated["header"]["alg"]
|
||||
expected_kid = decode_unvalidated["header"].get("kid")
|
||||
fallback_alg = decode_unvalidated["header"].get("alg")
|
||||
token = source = None
|
||||
if not expected_kid or not fallback_alg:
|
||||
return None, None
|
||||
for source in self.provider.jwt_federation_sources.filter(
|
||||
oidc_jwks__keys__contains=[{"kid": expected_kid}]
|
||||
):
|
||||
|
||||
@@ -75,6 +75,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"verbose_name": "RAC Provider",
|
||||
"verbose_name_plural": "RAC Providers",
|
||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
||||
@@ -126,6 +128,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"connection_expiry": "hours=8",
|
||||
"delete_token_on_disconnect": False,
|
||||
"verbose_name": "RAC Provider",
|
||||
@@ -155,6 +159,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"connection_expiry": "hours=8",
|
||||
"delete_token_on_disconnect": False,
|
||||
"verbose_name": "RAC Provider",
|
||||
|
||||
@@ -9,10 +9,9 @@ from defusedxml.lxml import fromstring
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.models import CertificateKeyPair, format_cert
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
@@ -24,18 +23,6 @@ from authentik.sources.saml.processors.constants import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def format_pem_certificate(unformatted_cert: str) -> str:
|
||||
"""Format single, inline certificate into PEM Format"""
|
||||
# Ensure that all linebreaks are gone
|
||||
unformatted_cert = unformatted_cert.replace("\n", "")
|
||||
chunks, chunk_size = len(unformatted_cert), 64
|
||||
lines = [PEM_HEADER]
|
||||
for i in range(0, chunks, chunk_size):
|
||||
lines.append(unformatted_cert[i : i + chunk_size])
|
||||
lines.append(PEM_FOOTER)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServiceProviderMetadata:
|
||||
"""SP Metadata Dataclass"""
|
||||
@@ -87,7 +74,7 @@ class ServiceProviderMetadataParser:
|
||||
)
|
||||
if len(signing_certs) < 1:
|
||||
return None
|
||||
raw_cert = format_pem_certificate(signing_certs[0])
|
||||
raw_cert = format_cert(signing_certs[0])
|
||||
# sanity check, make sure the certificate is valid.
|
||||
load_pem_x509_certificate(raw_cert.encode("utf-8"), default_backend())
|
||||
return CertificateKeyPair(
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
PEM_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||
PEM_FOOTER = "-----END CERTIFICATE-----"
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
|
||||
|
||||
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Group client"""
|
||||
|
||||
from itertools import batched
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from orjson import dumps
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.sync.outgoing.base import Direction
|
||||
from authentik.lib.sync.outgoing.exceptions import (
|
||||
@@ -113,10 +116,23 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
self._patch_add_users(connection, users)
|
||||
return connection
|
||||
|
||||
def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
|
||||
"""Check if a group is different than what we last wrote to the remote system.
|
||||
Returns true if there is a difference in data."""
|
||||
local_known = connection.attributes
|
||||
local_updated = {}
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_known)
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_created)
|
||||
return dumps(local_updated) != dumps(local_known)
|
||||
|
||||
def update(self, group: Group, connection: SCIMProviderGroup):
|
||||
"""Update existing group"""
|
||||
scim_group = self.to_schema(group, connection)
|
||||
scim_group.id = connection.scim_id
|
||||
payload = scim_group.model_dump(mode="json", exclude_unset=True)
|
||||
if not self.diff(payload, connection):
|
||||
self.logger.debug("Skipping group write as data has not changed")
|
||||
return self.patch_compare_users(group)
|
||||
try:
|
||||
if self._config.patch.supported:
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
|
||||
@@ -83,7 +83,7 @@ class EnterpriseUser(BaseModel):
|
||||
class User(BaseUser):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
model_config = ConfigDict(serialize_by_alias=True, extra="allow")
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_USER_SCHEMA]
|
||||
@@ -106,6 +106,8 @@ class User(BaseUser):
|
||||
class Group(BaseGroup):
|
||||
"""Modified Group schema with added externalId field"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_GROUP_SCHEMA]
|
||||
externalId: str | None = None
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""User client"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.http import urlencode
|
||||
from orjson import dumps
|
||||
from pydantic import ValidationError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync
|
||||
from authentik.policies.utils import delete_none_values
|
||||
@@ -92,17 +96,30 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
||||
provider=self.provider, user=user, scim_id=scim_id, attributes=response
|
||||
)
|
||||
|
||||
def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
|
||||
"""Check if a user is different than what we last wrote to the remote system.
|
||||
Returns true if there is a difference in data."""
|
||||
local_known = connection.attributes
|
||||
local_updated = {}
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_known)
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_created)
|
||||
return dumps(local_updated) != dumps(local_known)
|
||||
|
||||
def update(self, user: User, connection: SCIMProviderUser):
|
||||
"""Update existing user"""
|
||||
scim_user = self.to_schema(user, connection)
|
||||
scim_user.id = connection.scim_id
|
||||
payload = scim_user.model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
if not self.diff(payload, connection):
|
||||
self.logger.debug("Skipping user write as data has not changed")
|
||||
return
|
||||
response = self._request(
|
||||
"PUT",
|
||||
f"/Users/{connection.scim_id}",
|
||||
json=scim_user.model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
),
|
||||
json=payload,
|
||||
)
|
||||
connection.attributes = response
|
||||
connection.save()
|
||||
|
||||
@@ -9,7 +9,7 @@ from requests_mock import Mocker
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderGroup
|
||||
|
||||
|
||||
class SCIMGroupTests(TestCase):
|
||||
@@ -106,6 +106,7 @@ class SCIMGroupTests(TestCase):
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
group.name = generate_id()
|
||||
group.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
@@ -148,3 +149,56 @@ class SCIMGroupTests(TestCase):
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "DELETE")
|
||||
self.assertEqual(mock.request_history[3].url, f"https://localhost/Groups/{scim_id}")
|
||||
|
||||
@Mocker()
|
||||
def test_group_create_update_noop(self, mock: Mocker):
|
||||
"""Test group creation and update"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
body = loads(mock.request_history[1].body)
|
||||
with open("schemas/scim-group.schema.json", encoding="utf-8") as schema:
|
||||
validate(body, loads(schema.read()))
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
conn = SCIMProviderGroup.objects.filter(group=group).first()
|
||||
conn.attributes = {
|
||||
"id": scim_id,
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
}
|
||||
conn.save()
|
||||
group.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
|
||||
@@ -10,7 +10,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.sync.outgoing.base import SAFE_METHODS
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderUser
|
||||
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -95,7 +95,12 @@ class SCIMUserTests(TestCase):
|
||||
"""Test user creation with custom schema"""
|
||||
schema = SCIMMapping.objects.create(
|
||||
name="custom_schema",
|
||||
expression="""return {"schemas": ["foo"]}""",
|
||||
expression="""return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
|
||||
"startDate": "2024-04-10T00:00:00+0000",
|
||||
},
|
||||
}""",
|
||||
)
|
||||
self.provider.property_mappings.add(schema)
|
||||
scim_id = generate_id()
|
||||
@@ -121,7 +126,10 @@ class SCIMUserTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "foo"],
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
|
||||
],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
@@ -138,6 +146,9 @@ class SCIMUserTests(TestCase):
|
||||
},
|
||||
"displayName": f"{uid} {uid}",
|
||||
"userName": uid,
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
|
||||
"startDate": "2024-04-10T00:00:00+0000",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -262,6 +273,8 @@ class SCIMUserTests(TestCase):
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
# Update user
|
||||
user.name = "foo bar"
|
||||
user.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
@@ -444,3 +457,85 @@ class SCIMUserTests(TestCase):
|
||||
self.assertIsNotNone(log.attributes["url"])
|
||||
self.assertIsNotNone(log.attributes["body"])
|
||||
self.assertIsNotNone(log.attributes["method"])
|
||||
|
||||
@Mocker()
|
||||
def test_user_create_update_noop(self, mock: Mocker):
|
||||
"""Test user creation and update"""
|
||||
scim_id = generate_id()
|
||||
mock: Mocker
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=f"{uid} {uid}",
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
body = loads(mock.request_history[1].body)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"displayName": f"{uid} {uid}",
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": uid,
|
||||
"formatted": f"{uid} {uid}",
|
||||
"givenName": uid,
|
||||
},
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
conn = SCIMProviderUser.objects.filter(user=user).first()
|
||||
conn.attributes = {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"displayName": f"{uid} {uid}",
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": uid,
|
||||
"formatted": f"{uid} {uid}",
|
||||
"givenName": uid,
|
||||
},
|
||||
"userName": uid,
|
||||
"id": scim_id,
|
||||
}
|
||||
conn.save()
|
||||
user.save()
|
||||
self.assertEqual(mock.call_count, 3)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
# No PUT request
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.db import transaction
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -16,11 +17,16 @@ class UseTokenView(View):
|
||||
|
||||
def get(self, request: HttpRequest, key: str) -> HttpResponse:
|
||||
"""Check if token exists, log user in and delete token."""
|
||||
tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY)
|
||||
if not tokens.exists():
|
||||
raise Http404
|
||||
token = tokens.first()
|
||||
login(request, token.user, backend=BACKEND_INBUILT)
|
||||
token.delete()
|
||||
with transaction.atomic():
|
||||
tokens = (
|
||||
Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY)
|
||||
.select_for_update()
|
||||
.select_related("user")
|
||||
)
|
||||
token = tokens.first()
|
||||
if token is None:
|
||||
raise Http404
|
||||
login(request, token.user, backend=BACKEND_INBUILT)
|
||||
token.delete()
|
||||
messages.warning(request, _("Used recovery-link to authenticate."))
|
||||
return redirect("authentik_core:if-user")
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""websocket Message consumer"""
|
||||
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.cache import cache
|
||||
|
||||
from authentik.root.messages.storage import CACHE_PREFIX
|
||||
|
||||
|
||||
class MessageConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
session_key: str
|
||||
|
||||
def connect(self):
|
||||
self.accept()
|
||||
self.session_key = self.scope["session"].session_key
|
||||
if not self.session_key:
|
||||
return
|
||||
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
|
||||
|
||||
def disconnect(self, code):
|
||||
cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}")
|
||||
|
||||
def event_update(self, event: dict):
|
||||
"""Event handler which is called by Messages Storage backend"""
|
||||
self.send_json(event)
|
||||
@@ -6,6 +6,7 @@ from hashlib import sha512
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
from django.utils import http as utils_http
|
||||
from sentry_sdk import set_tag
|
||||
from xmlsec import enable_debug_trace
|
||||
|
||||
@@ -248,7 +249,7 @@ SESSION_COOKIE_AGE = timedelta_from_string(
|
||||
).total_seconds()
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
|
||||
MESSAGE_STORAGE = "authentik.root.ws.storage.ChannelsStorage"
|
||||
|
||||
MIDDLEWARE_FIRST = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
@@ -379,9 +380,6 @@ DRAMATIQ = {
|
||||
"broker_class": "authentik.tasks.broker.Broker",
|
||||
"channel_prefix": "authentik",
|
||||
"task_model": "authentik.tasks.models.Task",
|
||||
"lock_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.lock_purge_interval")
|
||||
).total_seconds(),
|
||||
"task_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.task_purge_interval")
|
||||
).total_seconds(),
|
||||
@@ -429,6 +427,7 @@ DRAMATIQ = {
|
||||
},
|
||||
),
|
||||
("dramatiq.results.middleware.Results", {"store_results": True}),
|
||||
("authentik.tasks.middleware.StartupSignalsMiddleware", {}),
|
||||
("authentik.tasks.middleware.CurrentTask", {}),
|
||||
("authentik.tasks.middleware.TenantMiddleware", {}),
|
||||
("authentik.tasks.middleware.ModelDataMiddleware", {}),
|
||||
@@ -471,6 +470,12 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
|
||||
# Django 5.2.8 and CVE-2025-64458 added a strong enforcement of 2048 characters
|
||||
# as the maximum for a URL to redirect to, mostly for running on windows.
|
||||
# However our URLs can easily exceed that with OAuth/SAML Query parameters or hash values
|
||||
# 8192 should cover most cases..
|
||||
utils_http.MAX_URL_LENGTH = utils_http.MAX_URL_LENGTH * 4
|
||||
|
||||
|
||||
# Media files
|
||||
if CONFIG.get("storage.media.backend", "file") == "s3":
|
||||
|
||||
57
authentik/root/ws/consumer.py
Normal file
57
authentik/root/ws/consumer.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""websocket Message consumer"""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
from authentik.root.ws.storage import CACHE_PREFIX
|
||||
|
||||
|
||||
def build_session_group(session_key: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_session_{str(session_key)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_device_group(session_key: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_device_{str(session_key)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
class MessageConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
session_key: str
|
||||
device_cookie: str | None = None
|
||||
|
||||
def connect(self):
|
||||
self.accept()
|
||||
self.session_key = self.scope["session"].session_key
|
||||
if self.session_key:
|
||||
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
|
||||
if device_cookie := self.scope["cookies"].get("authentik_device", None):
|
||||
self.device_cookie = device_cookie
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
|
||||
def disconnect(self, code):
|
||||
if self.session_key:
|
||||
cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}")
|
||||
if self.device_cookie:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
|
||||
def event_message(self, event: dict):
|
||||
"""Event handler which is called by Messages Storage backend"""
|
||||
self.send_json(event)
|
||||
|
||||
def event_session_authenticated(self, event: dict):
|
||||
"""Event handler post user authentication"""
|
||||
self.send_json({"message_type": "session.authenticated"})
|
||||
@@ -31,7 +31,7 @@ class ChannelsStorage(SessionStorage):
|
||||
async_to_sync(self.channel.send)(
|
||||
uid,
|
||||
{
|
||||
"type": "event.update",
|
||||
"type": "event.message",
|
||||
"message_type": "message",
|
||||
"level": message.level_tag,
|
||||
"tags": message.tags,
|
||||
@@ -298,6 +298,16 @@ class LDAPSource(ScheduledModel, Source):
|
||||
side_effect=pglock.Return,
|
||||
)
|
||||
|
||||
def get_ldap_server_info(self, srv: Server) -> dict[str, str]:
|
||||
info = {
|
||||
"vendor": _("N/A"),
|
||||
"version": _("N/A"),
|
||||
}
|
||||
if srv.info:
|
||||
info["vendor"] = str(flatten(srv.info.vendor_name))
|
||||
info["version"] = str(flatten(srv.info.vendor_version))
|
||||
return info
|
||||
|
||||
def check_connection(self) -> dict[str, dict[str, str]]:
|
||||
"""Check LDAP Connection"""
|
||||
servers = self.server()
|
||||
@@ -308,9 +318,8 @@ class LDAPSource(ScheduledModel, Source):
|
||||
try:
|
||||
conn = self.connection(server=server)
|
||||
server_info[server.host] = {
|
||||
"vendor": str(flatten(conn.server.info.vendor_name)),
|
||||
"version": str(flatten(conn.server.info.vendor_version)),
|
||||
"status": "ok",
|
||||
**self.get_ldap_server_info(conn.server),
|
||||
}
|
||||
except LDAPException as exc:
|
||||
server_info[server.host] = {
|
||||
@@ -320,9 +329,8 @@ class LDAPSource(ScheduledModel, Source):
|
||||
try:
|
||||
conn = self.connection()
|
||||
server_info["__all__"] = {
|
||||
"vendor": str(flatten(conn.server.info.vendor_name)),
|
||||
"version": str(flatten(conn.server.info.vendor_version)),
|
||||
"status": "ok",
|
||||
**self.get_ldap_server_info(conn.server),
|
||||
}
|
||||
except LDAPException as exc:
|
||||
server_info["__all__"] = {
|
||||
|
||||
@@ -143,7 +143,7 @@ class OAuth2Client(BaseOAuthClient):
|
||||
if self.source.source_type.urls_customizable and self.source.pkce:
|
||||
pkce_mode = self.source.pkce
|
||||
if pkce_mode != PKCEMethod.NONE:
|
||||
verifier = generate_id()
|
||||
verifier = generate_id(length=128)
|
||||
self.request.session[SESSION_KEY_OAUTH_PKCE] = verifier
|
||||
# https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
|
||||
if pkce_mode == PKCEMethod.PLAIN:
|
||||
|
||||
@@ -205,6 +205,7 @@ class TestOAuthSource(APITestCase):
|
||||
session = self.client.session
|
||||
state = session[f"oauth-client-{self.source.name}-request-state"]
|
||||
verifier = session[SESSION_KEY_OAUTH_PKCE]
|
||||
self.assertEqual(len(verifier), 128)
|
||||
challenge = pkce_s256_challenge(verifier)
|
||||
|
||||
self.assertEqual(qs["redirect_uri"], ["http://testserver/source/oauth/callback/test/"])
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.http import HttpRequest
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from lxml.etree import _Element # nosec
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import (
|
||||
@@ -217,9 +218,8 @@ class SAMLSource(Source):
|
||||
def property_mapping_type(self) -> type[PropertyMapping]:
|
||||
return SAMLSourcePropertyMapping
|
||||
|
||||
def get_base_user_properties(self, root: Any, name_id: Any, **kwargs):
|
||||
def get_base_user_properties(self, root: _Element, assertion: _Element, name_id: Any, **kwargs):
|
||||
attributes = {}
|
||||
assertion = root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
if assertion is None:
|
||||
raise ValueError("Assertion element not found")
|
||||
attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||
|
||||
@@ -63,6 +63,8 @@ class ResponseProcessor:
|
||||
|
||||
_http_request: HttpRequest
|
||||
|
||||
_assertion: "Element | None" = None
|
||||
|
||||
def __init__(self, source: SAMLSource, request: HttpRequest):
|
||||
self._source = source
|
||||
self._http_request = request
|
||||
@@ -113,6 +115,7 @@ class ResponseProcessor:
|
||||
index_of,
|
||||
decrypted_assertion,
|
||||
)
|
||||
self._assertion = decrypted_assertion
|
||||
|
||||
def _verify_signed(self):
|
||||
"""Verify SAML Response's Signature"""
|
||||
@@ -154,6 +157,10 @@ class ResponseProcessor:
|
||||
raise InvalidSignature() from exc
|
||||
LOGGER.debug("Successfully verified signature")
|
||||
|
||||
parent = signature_node.getparent()
|
||||
if parent is not None and parent.tag == f"{{{NS_SAML_ASSERTION}}}Assertion":
|
||||
self._assertion = parent
|
||||
|
||||
def _verify_request_id(self):
|
||||
if self._source.allow_idp_initiated:
|
||||
# If IdP-initiated SSO flows are enabled, we want to cache the Response ID
|
||||
@@ -217,14 +224,21 @@ class ResponseProcessor:
|
||||
identifier=str(name_id.text),
|
||||
user_info={
|
||||
"root": self._root,
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={},
|
||||
)
|
||||
|
||||
def get_assertion(self) -> "Element | None":
|
||||
"""Get assertion element, if we have a signed assertion"""
|
||||
if self._assertion is not None:
|
||||
return self._assertion
|
||||
return self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
|
||||
def _get_name_id(self) -> "Element":
|
||||
"""Get NameID Element"""
|
||||
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
assertion = self.get_assertion()
|
||||
if assertion is None:
|
||||
raise ValueError("Assertion element not found")
|
||||
subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject")
|
||||
@@ -277,6 +291,7 @@ class ResponseProcessor:
|
||||
identifier=str(name_id.text),
|
||||
user_info={
|
||||
"root": self._root,
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={
|
||||
|
||||
68
authentik/sources/saml/tests/fixtures/response_signed_assertion_dup.xml
vendored
Normal file
68
authentik/sources/saml/tests/fixtures/response_signed_assertion_dup.xml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_8e8dc5f69a98cc4c1ff3427e5ce34606fd672f91e6" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685">
|
||||
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer>
|
||||
<samlp:Status>
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||
</samlp:Status>
|
||||
<saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="_other_id_pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
|
||||
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer>
|
||||
<saml:Subject>
|
||||
<saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">bad</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
<saml:AttributeStatement>
|
||||
<saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||
<saml:AttributeValue xsi:type="xs:string">bad</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||
<saml:AttributeValue xsi:type="xs:string">bad</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>
|
||||
<saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
|
||||
<saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||
<ds:Reference URI="#pfxa06693ef-cec7-f4a6-cb7f-ad074445a1a3"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>zNDuGxwP4gVkv/Dzt7kiKo/4gzk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>GLP/vE8uxerB0uDpPslUgLPBL6ePQB619MoQ0I2Y5lAtFE6CB1zh8BnzChRx/bFjNy4byfOe8mFfM0r7WUi1PJOFWyUPoatdLl7wHHBIRTnPpYmu3Tb2Gz0sOP0F8wW7JkBft5gJfVw49nk5si9/3Q3o52jnJZ7dPtqfIOh8uNeopikK0HLF6sU05qCCtjcXfniEnLQFNBFMo9uY5GQqmR5n3nqPz1wYyyfFOAbVmGgBIoO2PfGX2GVLQhltc9qf2JMhks4jgZsZ8iLUIiH1lcLGWZEEs94k8k0P6gSv1uZ7Vbhksd/N9Jq9pCVuEJ/jRPcAdVjzbxqKQAj6ELwr8O6fepTzA+CAdwEolBnx/C6TmSbVZ+IWk6QUGe4x4+IAukC+0hkKENlO0ELOScksvyhpgHbxNA4rp+DhGupCaO/I2RrsQkmvavbqm+wSEspK7scK112SDunjDvqPHsPYgukD33T/97PxTLorg2kKP9HHJwPJKoXXeyOGcA6vwK+RqrAlZ2dLGAgcXo+sJcdCLuvxDNz9VXofBjBZIKVKdmYhm0QJaPYHtuQsAyFavQhdOBOmGHb7QX3YE3Xy4dX4LymtT+Jlb1I4FJSht/9HUIHW1FdhfDak4f7gUgjuMamMddLD0jVgeESupSREzFv/gj2IrctkbgjAO0iuuiBgKMg=</ds:SignatureValue>
|
||||
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIFUzCCAzugAwIBAgIRAL6tbNcE9Ej9gNlbGKswfFMwDQYJKoZIhvcNAQELBQAwHTEbMBkGA1UEAwwSYXV0aGVudGlrIDIwMjUuNi4zMB4XDTI1MDcxNTE4MDQzNloXDTI2MDcxNjE4MDQzNlowVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjmut/+bBRLlyrbf+WIfg8ZTw9t6VnsiU1n04nPTulpRAz4nBOoOHNRIruSpZyFeFa6x9jwn4Ma5EFUH7HqnRvhoujm8U17OglXWZt0DLCZ6S5xPmdMogFXjJDmg9okIcI/cb9VbR6I8uvm1oiaOWCr36RTiqZ6rmdjQcuUPLr1+V/LxWQI463S+5QA2HZxAGalp45MJAz2sa9iczktKMgyYlfjj1cruFARxxeheu5qIK7aQWfyPj1QlMb9mi4VQaxUwGrAui4Tq614ivRJY2SkZb0Aq/LLSQoQWYHtYyQIasrOXJm0JuPDqhINPBDowyhu8DihC3uzOpmTXLKc5UoIQk+Q1h5iH74A3/kxOJUw13FXzRiDxC/yGthPYLyFHsDiJolscMKSCqlDvEMcpM4mxFeud9sKUb71SZr8sqmJl3qtvZmKpkR4y8pN2c00p10t0htqONmr5kyPxmhz0HCrosiPYB4olNjaydKviNTtPJ7TtnPyeA3iXGzCP1e80XzUoJrDqON5/GcpYgqsP/kGj8Qvqesa4Fez+1+5pAGHN2VzQbkHAgK3s4YRXrGLTs7wg27F9T0RE28Mm0RYBkYpdp4/5PuTTulthB9mkUBSJMgENmQAYkapvonFDsJkTi39qnsddbZusOLT4z3hsA38eFEwRqnbNZVUGPIp/O1SsCAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDRUZ4QXVLRzV6SlVUTWpWNTJoMkRJMUQ5MXdLblZKaXFwNmpwRTRTTy5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAYLThxDVpA1OIAVK/buueRJExIWr6y4s6NtpuR8UQEcfq5hfoc4zMFGHR5+u1WFIb5siK25xh/OnS7bLdLic6AkjZSrx91+0v2Jn9gfUqbs5AJ040XzAAdx/Mb4s0+537yhB+/JXPylR1QxhGbO7koXQ5JDhAXWKCw2O1C+80mN8dbhQvDkEtsXrHrtXclcqf2TT89XAzc5HAC8NmP4SF+FafAREQB1KdaG4QAbc/gnjsX2YJD89SDL+3jMp6F7R1Ym+bWt5oWqx2tkm6HGXd3fbpfQlnfrRN60tMjjLmw1cDMhOhpdragY5zokniEUL2pKVtrxFp7V1ZpoMI0Kt5MKkOXrezi542NWSgkGehlsDLD9wtuCNem2arR0mNnMLdYkMG7G0dpAq3Tl32dgfMfyKnNyE2O/6/EeEuzUH2NfTU1p7AUQfLrf4rtNcJEs9OAPuC9vy7w9YEpF997T+FhR2Ub1C423NQj4bwlS/9f7MIBkSi1EgnQuiSGB5epxAKI3oOVrmzOpTuvr6wZXV9pM3zdfbcoGuFWP6Ix7W8G5vg+0WvoSjc2fwGXYlidEK3xlQSMAaQ4CMClpPsKLScRq1nrQGzPYoiL1DYubsOWx9ohll6+jNjKI6f79WwbHYrW4EeRIOz38+m46EDjAWZBMgrE7J/3DhgeLEVJYBA5K0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
|
||||
<saml:Subject>
|
||||
<saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
<saml:AttributeStatement>
|
||||
<saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||
<saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||
<saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
<saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
|
||||
<saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue>
|
||||
<saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue>
|
||||
</saml:Attribute>
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>
|
||||
</samlp:Response>
|
||||
@@ -37,7 +37,9 @@ class TestPropertyMappings(TestCase):
|
||||
|
||||
def test_user_base_properties(self):
|
||||
"""Test user base properties"""
|
||||
properties = self.source.get_base_user_properties(root=ROOT, name_id=NAME_ID)
|
||||
properties = self.source.get_base_user_properties(
|
||||
root=ROOT, assertion=ROOT.find(f"{{{NS_SAML_ASSERTION}}}Assertion"), name_id=NAME_ID
|
||||
)
|
||||
self.assertEqual(
|
||||
properties,
|
||||
{
|
||||
@@ -50,7 +52,11 @@ class TestPropertyMappings(TestCase):
|
||||
|
||||
def test_group_base_properties(self):
|
||||
"""Test group base properties"""
|
||||
properties = self.source.get_base_user_properties(root=ROOT_GROUPS, name_id=NAME_ID)
|
||||
properties = self.source.get_base_user_properties(
|
||||
root=ROOT_GROUPS,
|
||||
assertion=ROOT_GROUPS.find(f"{{{NS_SAML_ASSERTION}}}Assertion"),
|
||||
name_id=NAME_ID,
|
||||
)
|
||||
self.assertEqual(properties["groups"], ["group 1", "group 2"])
|
||||
for group_id in ["group 1", "group 2"]:
|
||||
properties = self.source.get_base_group_properties(root=ROOT, group_id=group_id)
|
||||
|
||||
@@ -152,6 +152,31 @@ class TestResponseProcessor(TestCase):
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
|
||||
def test_verification_assertion_duplicate(self):
|
||||
"""Test verifying signature inside assertion, where the response has another assertion
|
||||
before our signed assertion"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
kp = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data=key,
|
||||
)
|
||||
self.source.verification_kp = kp
|
||||
self.source.signed_assertion = True
|
||||
self.source.signed_response = False
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
"SAMLResponse": b64encode(
|
||||
load_fixture("fixtures/response_signed_assertion_dup.xml").encode()
|
||||
).decode()
|
||||
},
|
||||
)
|
||||
|
||||
parser = ResponseProcessor(self.source, request)
|
||||
parser.parse()
|
||||
self.assertNotEqual(parser._get_name_id().text, "bad")
|
||||
self.assertEqual(parser._get_name_id().text, "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
|
||||
|
||||
def test_verification_response(self):
|
||||
"""Test verifying signature inside response"""
|
||||
key = load_fixture("fixtures/signature_cert.pem")
|
||||
|
||||
@@ -11,10 +11,10 @@ from authentik.stages.invitation.models import Invitation, InvitationStage
|
||||
from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
INVITATION_TOKEN_KEY_CONTEXT = "token" # nosec
|
||||
INVITATION_TOKEN_KEY = "itoken" # nosec
|
||||
INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
INVITATION = "invitation"
|
||||
QS_INVITATION_TOKEN_KEY = "itoken" # nosec
|
||||
PLAN_CONTEXT_INVITATION_TOKEN = "token" # nosec
|
||||
PLAN_CONTEXT_INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
PLAN_CONTEXT_INVITATION = "invitation"
|
||||
|
||||
|
||||
class InvitationStageView(StageView):
|
||||
@@ -23,13 +23,13 @@ class InvitationStageView(StageView):
|
||||
def get_token(self) -> str | None:
|
||||
"""Get token from saved get-arguments or prompt_data"""
|
||||
# Check for ?token= and ?itoken=
|
||||
if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY]
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
if QS_INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][QS_INVITATION_TOKEN_KEY]
|
||||
if PLAN_CONTEXT_INVITATION_TOKEN in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][PLAN_CONTEXT_INVITATION_TOKEN]
|
||||
# Check for {'token': ''} in the context
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
if PLAN_CONTEXT_INVITATION_TOKEN in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][PLAN_CONTEXT_INVITATION_TOKEN]
|
||||
return None
|
||||
|
||||
def get_invite(self) -> Invitation | None:
|
||||
@@ -38,7 +38,7 @@ class InvitationStageView(StageView):
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
invite: Invitation = Invitation.objects.filter(pk=token).first()
|
||||
invite: Invitation | None = Invitation.filter_not_expired(pk=token).first()
|
||||
except ValidationError:
|
||||
self.logger.debug("invalid invitation", token=token)
|
||||
return None
|
||||
@@ -60,8 +60,8 @@ class InvitationStageView(StageView):
|
||||
return self.executor.stage_ok()
|
||||
return self.executor.stage_invalid(_("Invalid invite/invite not found"))
|
||||
|
||||
self.executor.plan.context[INVITATION_IN_EFFECT] = True
|
||||
self.executor.plan.context[INVITATION] = invite
|
||||
self.executor.plan.context[PLAN_CONTEXT_INVITATION_IN_EFFECT] = True
|
||||
self.executor.plan.context[PLAN_CONTEXT_INVITATION] = invite
|
||||
|
||||
context = {}
|
||||
always_merger.merge(context, self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""invitation tests"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -16,9 +18,9 @@ from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.invitation.models import Invitation, InvitationStage
|
||||
from authentik.stages.invitation.stage import (
|
||||
INVITATION_TOKEN_KEY,
|
||||
INVITATION_TOKEN_KEY_CONTEXT,
|
||||
PLAN_CONTEXT_INVITATION_TOKEN,
|
||||
PLAN_CONTEXT_PROMPT,
|
||||
QS_INVITATION_TOKEN_KEY,
|
||||
)
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
@@ -77,6 +79,31 @@ class TestInvitationStage(FlowTestCase):
|
||||
self.stage.continue_flow_without_invitation = False
|
||||
self.stage.save()
|
||||
|
||||
def test_with_invitation_expired(self):
|
||||
"""Test with invitation, expired"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
data = {"foo": "bar"}
|
||||
invite = Invitation.objects.create(
|
||||
created_by=get_anonymous_user(),
|
||||
fixed_data=data,
|
||||
expires=now() - timedelta(hours=1),
|
||||
)
|
||||
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-access-denied",
|
||||
)
|
||||
|
||||
def test_with_invitation_get(self):
|
||||
"""Test with invitation, check data in session"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
@@ -89,7 +116,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
|
||||
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
session = self.client.session
|
||||
@@ -114,7 +141,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
|
||||
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
session = self.client.session
|
||||
@@ -134,7 +161,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {INVITATION_TOKEN_KEY_CONTEXT: invite.pk.hex}
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
@@ -261,7 +261,9 @@ class Prompt(SerializerModel):
|
||||
|
||||
return value
|
||||
|
||||
def field(self, default: Any | None, choices: list[Any] | None = None) -> CharField:
|
||||
def field( # noqa PLR0915
|
||||
self, default: Any | None, choices: list[Any] | None = None
|
||||
) -> CharField:
|
||||
"""Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
|
||||
field_class = CharField
|
||||
kwargs = {
|
||||
@@ -275,6 +277,7 @@ class Prompt(SerializerModel):
|
||||
field_class = ReadOnlyField
|
||||
# required can't be set for ReadOnlyField
|
||||
kwargs["required"] = False
|
||||
kwargs["allow_blank"] = True
|
||||
case FieldTypes.EMAIL:
|
||||
field_class = EmailField
|
||||
kwargs["allow_blank"] = not self.required
|
||||
@@ -306,7 +309,14 @@ class Prompt(SerializerModel):
|
||||
|
||||
if self.type in CHOICE_FIELDS:
|
||||
field_class = ChoiceField
|
||||
kwargs["choices"] = choices or []
|
||||
kwargs["choices"] = []
|
||||
if choices:
|
||||
for choice in choices:
|
||||
label, value = choice, choice
|
||||
if isinstance(choice, dict):
|
||||
label = choice.get("label", "")
|
||||
value = choice.get("value", "")
|
||||
kwargs["choices"].append((value, label))
|
||||
|
||||
if default:
|
||||
kwargs["default"] = default
|
||||
|
||||
@@ -23,6 +23,7 @@ from authentik import authentik_full_version
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.root.signals import post_startup, pre_startup, startup
|
||||
from authentik.tasks.models import Task, TaskLog, TaskStatus, WorkerStatus
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
@@ -32,6 +33,14 @@ HEALTHCHECK_LOGGER = get_logger("authentik.worker").bind()
|
||||
DB_ERRORS = (OperationalError, Error)
|
||||
|
||||
|
||||
class StartupSignalsMiddleware(Middleware):
|
||||
def after_process_boot(self, broker: Broker):
|
||||
_startup_sender = type("WorkerStartup", (object,), {})
|
||||
pre_startup.send(sender=_startup_sender)
|
||||
startup.send(sender=_startup_sender)
|
||||
post_startup.send(sender=_startup_sender)
|
||||
|
||||
|
||||
class CurrentTask(BaseCurrentTask):
|
||||
@classmethod
|
||||
def get_task(cls) -> Task:
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_dramatiq_postgres.models import TaskBase, TaskState
|
||||
|
||||
from authentik.events.logs import LogEvent
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -174,7 +175,7 @@ class TaskLog(models.Model):
|
||||
log_level=log_event.log_level,
|
||||
logger=log_event.logger,
|
||||
timestamp=log_event.timestamp,
|
||||
attributes=log_event.attributes,
|
||||
attributes=sanitize_item(log_event.attributes),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -193,7 +194,7 @@ class TaskLog(models.Model):
|
||||
log_level=log_event.log_level,
|
||||
logger=log_event.logger,
|
||||
timestamp=log_event.timestamp,
|
||||
attributes=log_event.attributes,
|
||||
attributes=sanitize_item(log_event.attributes),
|
||||
)
|
||||
for log_event in log_events
|
||||
]
|
||||
|
||||
@@ -2,9 +2,10 @@ import pickle # nosec
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from dramatiq.actor import Actor
|
||||
from psqlextra.query import ConflictAction
|
||||
from psqlextra.types import ConflictAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
@@ -15,7 +16,7 @@ class ScheduleSpec:
|
||||
actor: Actor
|
||||
crontab: str
|
||||
paused: bool = False
|
||||
identifier: str | None = None
|
||||
identifier: str | UUID | None = None
|
||||
uid: str | None = None
|
||||
|
||||
args: Iterable[Any] = field(default_factory=tuple)
|
||||
@@ -41,6 +42,8 @@ class ScheduleSpec:
|
||||
return pickle.dumps(options)
|
||||
|
||||
def update_or_create(self) -> "Schedule":
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
|
||||
update_values = {
|
||||
@@ -50,10 +53,12 @@ class ScheduleSpec:
|
||||
"kwargs": self.get_kwargs(),
|
||||
"options": self.get_options(),
|
||||
}
|
||||
if self.rel_obj is not None:
|
||||
update_values["rel_obj_content_type"] = ContentType.objects.get_for_model(self.rel_obj)
|
||||
update_values["rel_obj_id"] = str(self.rel_obj.pk)
|
||||
create_values = {
|
||||
**update_values,
|
||||
"crontab": self.crontab,
|
||||
"rel_obj": self.rel_obj,
|
||||
}
|
||||
|
||||
schedule = Schedule.objects.on_conflict(
|
||||
@@ -62,7 +67,7 @@ class ScheduleSpec:
|
||||
update_values=update_values,
|
||||
).insert_and_get(
|
||||
actor_name=self.actor.actor_name,
|
||||
identifier=self.identifier,
|
||||
identifier=str(self.identifier),
|
||||
**create_values,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ def post_save_scheduled_model(sender, instance, **_):
|
||||
return
|
||||
for spec in instance.schedule_specs:
|
||||
spec.rel_obj = instance
|
||||
spec.identifier = instance.pk
|
||||
schedule = spec.update_or_create()
|
||||
if spec.send_on_save:
|
||||
schedule.send()
|
||||
|
||||
@@ -5,10 +5,3 @@ setup()
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
||||
from authentik.root.signals import post_startup, pre_startup, startup # noqa: E402
|
||||
|
||||
_startup_sender = type("WorkerStartup", (object,), {})
|
||||
pre_startup.send(sender=_startup_sender)
|
||||
startup.send(sender=_startup_sender)
|
||||
post_startup.send(sender=_startup_sender)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from random import choice
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import DEFAULT_DB_ALIAS, connections
|
||||
|
||||
|
||||
class FailoverRouter:
|
||||
@@ -10,16 +11,22 @@ class FailoverRouter:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.database_aliases = set(settings.DATABASES.keys())
|
||||
self.read_replica_aliases = list(self.database_aliases - {"default"})
|
||||
self.read_replica_aliases = list(self.database_aliases - {DEFAULT_DB_ALIAS})
|
||||
self.replica_enabled = len(self.read_replica_aliases) > 0
|
||||
|
||||
def db_for_read(self, model, **hints):
|
||||
if not self.replica_enabled:
|
||||
return "default"
|
||||
return DEFAULT_DB_ALIAS
|
||||
# Stay on primary for the entire transaction to maintain consistency.
|
||||
# Reading from a replica mid-transaction would give a different snapshot,
|
||||
# breaking transactional semantics (not just read-your-writes, but the
|
||||
# entire consistent point-in-time view that a transaction provides).
|
||||
if connections[DEFAULT_DB_ALIAS].in_atomic_block:
|
||||
return DEFAULT_DB_ALIAS
|
||||
return choice(self.read_replica_aliases) # nosec
|
||||
|
||||
def db_for_write(self, model, **hints):
|
||||
return "default"
|
||||
return DEFAULT_DB_ALIAS
|
||||
|
||||
def allow_relation(self, obj1, obj2, **hints):
|
||||
"""Relations between objects are allowed if both objects are
|
||||
|
||||
@@ -5,7 +5,8 @@ from json import loads
|
||||
from django.urls import reverse
|
||||
from django_tenants.utils import get_public_schema_name
|
||||
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.core.models import Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -21,7 +22,7 @@ class TestRecovery(TenantAPITestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.tenant = Tenant.objects.get(schema_name=get_public_schema_name())
|
||||
self.user: User = User.objects.create_user(username="recovery-test-user")
|
||||
self.user = create_test_user()
|
||||
|
||||
@CONFIG.patch("outposts.disable_embedded_outpost", True)
|
||||
@CONFIG.patch("tenants.enabled", True)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.10.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.10.4 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
||||
@@ -60,22 +60,6 @@ func checkServer() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitHostPort(address string) (host, port string) {
|
||||
lastColon := strings.LastIndex(address, ":")
|
||||
if lastColon == -1 {
|
||||
return address, ""
|
||||
}
|
||||
|
||||
host = address[:lastColon]
|
||||
port = address[lastColon+1:]
|
||||
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
func checkWorker() int {
|
||||
pidB, err := os.ReadFile(workerPidFile)
|
||||
if err != nil {
|
||||
@@ -98,41 +82,6 @@ func checkWorker() int {
|
||||
log.WithError(err).Warning("failed to signal worker process")
|
||||
return 1
|
||||
}
|
||||
h := &http.Client{
|
||||
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
|
||||
}
|
||||
|
||||
host, port := splitHostPort(config.Get().Listen.HTTP)
|
||||
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "::1", port)
|
||||
_, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "127.0.0.1", port)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
|
||||
return 1
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", host, port)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to send healthcheck request")
|
||||
return 1
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("successfully checked health")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.4}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.4}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,8 +1,6 @@
|
||||
module goauthentik.io
|
||||
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.6
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.10.0-rc1
|
||||
2025.10.4
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func OpensslVersion() string {
|
||||
cmd := exec.Command("openssl", "version")
|
||||
cmd := exec.Command("/usr/bin/openssl", "version")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
err := cmd.Run()
|
||||
|
||||
@@ -83,7 +83,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
// The service account this token belongs to should only have access to a single outpost
|
||||
outposts, _ := retry.DoWithData[*api.PaginatedOutpostList](
|
||||
func() (*api.PaginatedOutpostList, error) {
|
||||
outposts, _, err := apiClient.OutpostsApi.OutpostsInstancesList(context.Background()).Execute()
|
||||
outposts, _, err := apiClient.OutpostsAPI.OutpostsInstancesList(context.Background()).Execute()
|
||||
return outposts, err
|
||||
},
|
||||
retry.Attempts(0),
|
||||
@@ -93,13 +93,13 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
}),
|
||||
)
|
||||
if len(outposts.Results) < 1 {
|
||||
log.Panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
|
||||
log.Panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost")
|
||||
}
|
||||
outpost := outposts.Results[0]
|
||||
|
||||
log.WithField("name", outpost.Name).Debug("Fetched outpost configuration")
|
||||
|
||||
akConfig, _, err := apiClient.RootApi.RootConfigRetrieve(context.Background()).Execute()
|
||||
akConfig, _, err := apiClient.RootAPI.RootConfigRetrieve(context.Background()).Execute()
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to fetch global configuration")
|
||||
return nil
|
||||
@@ -122,6 +122,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
eventHandlers: []EventHandler{},
|
||||
refreshHandlers: make([]func(), 0),
|
||||
}
|
||||
ac.logger.WithField("embedded", ac.IsEmbedded()).Info("Outpost mode")
|
||||
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
|
||||
err = ac.initEvent(akURL, outpost.Pk)
|
||||
if err != nil {
|
||||
@@ -135,6 +136,13 @@ func (a *APIController) Log() *log.Entry {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIController) IsEmbedded() bool {
|
||||
if m := a.Outpost.Managed.Get(); m != nil {
|
||||
return *m == "goauthentik.io/outposts/embedded"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start Starts all handlers, non-blocking
|
||||
func (a *APIController) Start() error {
|
||||
err := a.Server.Refresh()
|
||||
@@ -180,7 +188,7 @@ func (a *APIController) Token() string {
|
||||
func (a *APIController) OnRefresh() error {
|
||||
// Because we don't know the outpost UUID, we simply do a list and pick the first
|
||||
// The service account this token belongs to should only have access to a single outpost
|
||||
outposts, _, err := a.Client.OutpostsApi.OutpostsInstancesList(context.Background()).Execute()
|
||||
outposts, _, err := a.Client.OutpostsAPI.OutpostsInstancesList(context.Background()).Execute()
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to fetch outpost configuration")
|
||||
return err
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type CryptoStore struct {
|
||||
api *api.CryptoApiService
|
||||
api *api.CryptoAPIService
|
||||
|
||||
log *log.Entry
|
||||
|
||||
@@ -19,7 +19,7 @@ type CryptoStore struct {
|
||||
certificates map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
func NewCryptoStore(cryptoApi *api.CryptoApiService) *CryptoStore {
|
||||
func NewCryptoStore(cryptoApi *api.CryptoAPIService) *CryptoStore {
|
||||
return &CryptoStore{
|
||||
api: cryptoApi,
|
||||
log: log.WithField("logger", "authentik.outpost.cryptostore"),
|
||||
|
||||
@@ -148,7 +148,7 @@ func (fe *FlowExecutor) SetSession(s *http.Cookie) {
|
||||
func (fe *FlowExecutor) WarmUp() error {
|
||||
gcsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.get_challenge")
|
||||
defer gcsp.Finish()
|
||||
req := fe.api.FlowsApi.FlowsExecutorGet(gcsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
req := fe.api.FlowsAPI.FlowsExecutorGet(gcsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
_, _, err := req.Execute()
|
||||
return err
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (fe *FlowExecutor) Execute() (bool, error) {
|
||||
func (fe *FlowExecutor) getInitialChallenge() (*api.ChallengeTypes, error) {
|
||||
// Get challenge
|
||||
gcsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.get_challenge")
|
||||
req := fe.api.FlowsApi.FlowsExecutorGet(gcsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
req := fe.api.FlowsAPI.FlowsExecutorGet(gcsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
challenge, _, err := req.Execute()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,7 +188,7 @@ func (fe *FlowExecutor) getInitialChallenge() (*api.ChallengeTypes, error) {
|
||||
func (fe *FlowExecutor) solveFlowChallenge(challenge *api.ChallengeTypes, depth int) (bool, error) {
|
||||
// Resole challenge
|
||||
scsp := sentry.StartSpan(fe.Context, "authentik.outposts.flow_executor.solve_challenge")
|
||||
responseReq := fe.api.FlowsApi.FlowsExecutorSolve(scsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
responseReq := fe.api.FlowsAPI.FlowsExecutorSolve(scsp.Context(), fe.flowSlug).Query(fe.Params.Encode())
|
||||
i := challenge.GetActualInstance()
|
||||
if i == nil {
|
||||
return false, errors.New("response request instance was null")
|
||||
|
||||
@@ -59,7 +59,7 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
access, _, err := fe.ApiClient().OutpostsApi.OutpostsLdapAccessCheck(
|
||||
access, _, err := fe.ApiClient().OutpostsAPI.OutpostsLdapAccessCheck(
|
||||
req.Context(), db.si.GetProviderID(),
|
||||
).AppSlug(db.si.GetAppSlug()).Execute()
|
||||
if !access.Access.Passing {
|
||||
@@ -85,7 +85,7 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||
req.Log().Info("User has access")
|
||||
uisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.bind.user_info")
|
||||
// Get user info to store in context
|
||||
userInfo, _, err := fe.ApiClient().CoreApi.CoreUsersMeRetrieve(context.Background()).Execute()
|
||||
userInfo, _, err := fe.ApiClient().CoreAPI.CoreUsersMeRetrieve(context.Background()).Execute()
|
||||
if err != nil {
|
||||
metrics.RequestsRejected.With(prometheus.Labels{
|
||||
"outpost_name": db.si.GetOutpostName(),
|
||||
|
||||
@@ -32,7 +32,7 @@ func NewServer(ac *ak.APIController) ak.Outpost {
|
||||
ls := &LDAPServer{
|
||||
log: log.WithField("logger", "authentik.outpost.ldap"),
|
||||
ac: ac,
|
||||
cs: ak.NewCryptoStore(ac.Client.CryptoApi),
|
||||
cs: ak.NewCryptoStore(ac.Client.CryptoAPI),
|
||||
providers: []*ProviderInstance{},
|
||||
connections: map[string]net.Conn{},
|
||||
connectionsSync: sync.Mutex{},
|
||||
|
||||
@@ -31,7 +31,7 @@ func (ls *LDAPServer) getCurrentProvider(pk int32) *ProviderInstance {
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) Refresh() error {
|
||||
apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsApi.OutpostsLdapList(context.Background()), ak.PaginatorOptions{
|
||||
apiProviders, err := ak.Paginator(ls.ac.Client.OutpostsAPI.OutpostsLdapList(context.Background()), ak.PaginatorOptions{
|
||||
PageSize: 100,
|
||||
Logger: ls.log,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user