Compare commits

...

41 Commits

Author SHA1 Message Date
authentik-automation[bot]
80d9577173 flows: remove link to overview for non-internal user (cherry-pick #22362 to version-2026.5) (#22371)
flows: remove link to overview for non-internal user (#22362)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-15 01:20:59 +02:00
authentik-automation[bot]
a745f0331e providers/saml: Add sls to saml overview (cherry-pick #22183 to version-2026.5) (#22368)
providers/saml: Add sls to saml overview (#22183)

* providers/saml: clean up provider overview page

* clean up logout option rendering

Co-authored-by: Connor Peshek <connor@connorpeshek.me>
2026-05-14 11:54:21 -05:00
authentik-automation[bot]
5f754d1fda website/docs, integrations: SAML docs endpoint updates (cherry-pick #22197 to version-2026.5) (#22347)
website/docs, integrations: SAML docs endpoint updates (#22197)

* docs/integrations: Update docs for new unified saml endpoint

* Update zabbix cert generation wording

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Dominic R <dominic@goauthentik.io>
2026-05-14 16:21:41 +00:00
authentik-automation[bot]
ddcde9596d website/docs: Add invitation wizard docs (cherry-pick #22069 to version-2026.5) (#22316)
website/docs: Add invitation wizard docs (#22069)

* Add invitation wizard docs

* Apply suggestions from code review




* Apply suggestion from @dominic-r




* Add title to info box

* Apply suggestion from @dominic-r



* Apply suggestions from code review




---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Signed-off-by: Dominic R <dominic@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-05-14 17:53:30 +02:00
authentik-automation[bot]
6211162a12 web: Fix issue where default user path is not preferred. (cherry-pick #22139 to version-2026.5) (#22364)
web: Fix issue where default user path is not preferred. (#22139)

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-05-14 16:34:45 +02:00
authentik-automation[bot]
f95eec1d05 website/docs: 2026.5 release notes: fix performance improvements wording (cherry-pick #22307 to version-2026.5) (#22309)
website/docs: 2026.5 release notes: fix performance improvements wording (#22307)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-14 15:05:20 +02:00
authentik-automation[bot]
9f847da62b website/integrations: remove sp binding field (cherry-pick #22200 to version-2026.5) (#22348)
website/integrations: remove sp binding field (#22200)

docs/integrations: remove sp binding field

Co-authored-by: Connor Peshek <connor@connorpeshek.me>
2026-05-14 00:21:20 -05:00
authentik-automation[bot]
833918ba74 website/integrations: Update all guides to match auto generated issuer (cherry-pick #22180 to version-2026.5) (#22346)
website/integrations: Update all guides to match auto generated issuer (#22180)

* docs/integrations: Update all guides to match auto generated issuer

* clean up audience mismatches

* clean up more

* update saml providers page

* fix url breaking build

* clean up pipeline errors

* Apply suggestion from @dominic-r



---------

Signed-off-by: Dominic R <dominic@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Dominic R <dominic@goauthentik.io>
2026-05-14 03:09:55 +00:00
authentik-automation[bot]
bd59bf4678 website/docs: fix typos and style issues (cherry-pick #22141 to version-2026.5) (#22189)
* Cherry-pick #22141 to version-2026.5 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #22141
Original commit: 2c452306cf

* website/docs: resolve SonarQube cherry-pick conflict

---------

Co-authored-by: Dominic R <dominic@goauthentik.io>
2026-05-14 02:37:47 +00:00
authentik-automation[bot]
5ee261ee23 enterprise/providers/scim: add support for interactive OAuth2 (cherry-pick #22072 to version-2026.5) (#22337)
enterprise/providers/scim: add support for interactive OAuth2 (#22072)

* enterprise/providers/scim: add support for interactive OAuth2



* prep different oauth mode



* implement it



* add data to API



* update ui



* fixes



* cleanup



* start adding tests



* add more tests



* remove not-needed migration



* fixup



* fix last_updated not being updated



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-13 22:36:03 +02:00
authentik-automation[bot]
e9b3951c66 website/docs: fix email link in CVE-2026-40166 (cherry-pick #22331 to version-2026.5) (#22334)
website/docs: fix email link in CVE-2026-40166 (#22331)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-13 16:59:52 +02:00
authentik-automation[bot]
d627de304a website/docs: release notes 2026.5: add section about package reduction (cherry-pick #22308 to version-2026.5) (#22324)
website/docs: release notes 2026.5: add section about package reduction (#22308)

* Add section about package reduction

* Suggestion from marc



---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-13 14:38:27 +02:00
authentik-automation[bot]
5b7f58deec endpoints: remove print line (cherry-pick #22325 to version-2026.5) (#22327)
endpoints: remove `print` line (#22325)

Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2026-05-13 13:50:46 +02:00
authentik-automation[bot]
66c4874590 release: 2026.5.0-rc2 2026-05-13 08:40:16 +00:00
authentik-automation[bot]
a83db7a533 stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (cherry-pick #22322 to version-2026.5) (#22323)
stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#22322)

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-13 10:00:08 +02:00
authentik-automation[bot]
24b6e86eb2 website/docs: release notes for 2025.12.5 and 2026.2.3 (cherry-pick #22310 to version-2026.5) (#22313)
website/docs: release notes for 2025.12.5 and 2026.2.3 (#22310)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-12 21:11:49 +02:00
authentik-automation[bot]
06f4b47271 internal: Automated internal backport: CVE-2026-40166.sec.patch to authentik-2026.5 (#22291)
* Automated internal backport of patch CVE-2026-40166.sec.patch to authentik-2026.5

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-12 20:23:36 +02:00
authentik-automation[bot]
fb2634aafb internal: Automated internal backport: CVE-2026-41569.sec.patch to authentik-2026.5 (#22293)
* Automated internal backport of patch CVE-2026-41569.sec.patch to authentik-2026.5

* fix spell

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-12 20:23:24 +02:00
authentik-automation[bot]
80c8c9004c internal: Automated internal backport: CVE-2026-42849.sec.patch to authentik-2026.5 (#22295)
* Automated internal backport of patch CVE-2026-42849.sec.patch to authentik-2026.5

* fix spellcheck

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-12 20:23:10 +02:00
authentik-automation[bot]
f5cad68c5b internal: Automated internal backport: GHSA-973w-j457-rp2m.sec.patch to authentik-2026.5 (#22297)
* Automated internal backport of patch GHSA-973w-j457-rp2m.sec.patch to authentik-2026.5

* gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-12 20:22:57 +02:00
authentik-automation[bot]
c8a6665a8f internal: Automated internal backport: GHSA-5wcc-hf24-rf5h.sec.patch to authentik-2026.5 (#22296)
Automated internal backport of patch GHSA-5wcc-hf24-rf5h.sec.patch to authentik-2026.5

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-12 19:37:21 +02:00
authentik-automation[bot]
dfa639365f internal: Automated internal backport: CVE-2026-40165.sec.patch to authentik-2026.5 (#22290)
Automated internal backport of patch CVE-2026-40165.sec.patch to authentik-2026.5

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-12 19:34:44 +02:00
authentik-automation[bot]
5674bac6f0 internal: Automated internal backport: CVE-2026-40172.sec.patch to authentik-2026.5 (#22292)
Automated internal backport of patch CVE-2026-40172.sec.patch to authentik-2026.5

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-12 19:34:37 +02:00
authentik-automation[bot]
1852e97b0e internal: Automated internal backport: CVE-2026-41577.sec.patch to authentik-2026.5 (#22294)
Automated internal backport of patch CVE-2026-41577.sec.patch to authentik-2026.5

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-12 19:34:31 +02:00
authentik-automation[bot]
b896eea0c3 enterprise: fix account lockdown target handling (cherry-pick #22246 to version-2026.5) (#22252)
enterprise: fix account lockdown target handling (#22246)

- Use the pending lockdown target in the example blueprint warning and avoid repeating the username when email/name is not distinct.

- Hide the admin Account Lockdown action for internal service accounts.

Co-authored-by: Dominic R <dominic@goauthentik.io>
2026-05-12 16:03:05 +02:00
authentik-automation[bot]
4023d38b06 core: bump tokio from 1.52.2 to 1.52.3 (cherry-pick #22262 to version-2026.5) (#22268)
core: bump tokio from 1.52.2 to 1.52.3 (#22262)

Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.2 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.2...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 15:17:49 +02:00
authentik-automation[bot]
1cd80616b7 core: bump tokio from 1.52.1 to 1.52.2 (cherry-pick #22160 to version-2026.5) (#22267)
core: bump tokio from 1.52.1 to 1.52.2 (#22160)

Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.2.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.2)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 15:16:15 +02:00
authentik-automation[bot]
eda847061b root: refreshed icon (cherry-pick #22265 to version-2026.5) (#22266)
root: refreshed icon (#22265)

* root: refresh icon



* update pride



* update



* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-12 15:15:11 +02:00
authentik-automation[bot]
6d871022e7 web/admin: fix user wizard close button (cherry-pick #22222 to version-2026.5) (#22243)
web/admin: fix user wizard close button (#22222)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-12 02:43:31 +02:00
authentik-automation[bot]
c8e2d8b3c9 web/admin: User wizard label adjust and deactivate navigation when wizard is finished (cherry-pick #22133 to version-2026.5) (#22191)
web/admin: User wizard label adjust and deactivate navigation when wizard is finished (#22133)

* elements/wizard: deactivate side nav buttons if wizard is done



* adjust labels for service account



* Fix wizards to use consistent height.

* remove justify



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-05-12 01:50:25 +02:00
authentik-automation[bot]
1775ef4476 core: bump python-kadmin-rs from 0.7.1 to 0.7.2 (cherry-pick #22234 to version-2026.5) (#22238)
core: bump python-kadmin-rs from 0.7.1 to 0.7.2 (#22234)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-11 17:49:23 +00:00
authentik-automation[bot]
a638558133 release: 2026.5.0-rc1 2026-05-11 15:23:50 +00:00
Marc 'risson' Schmitt
c60537bc28 ci: fix make gen in release workflows (cherry-pick #22235 to version-2026.5) (#22236)
ci: fix make gen in release workflows

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-11 16:26:12 +02:00
Marc 'risson' Schmitt
8c89f65ad7 ci: run make gen when tagging a new release (cherry-pick #22229 to version-2026.5) (#22231)
ci: run make gen when tagging a new release

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-11 14:53:40 +02:00
authentik-automation[bot]
cb07c148f6 core: bump python-kadmin-rs from 0.7.0 to 0.7.1 (cherry-pick #22205 to version-2026.5) (#22227)
core: bump python-kadmin-rs from 0.7.0 to 0.7.1 (#22205)

Bumps [python-kadmin-rs](https://github.com/authentik-community/kadmin-rs) from 0.7.0 to 0.7.1.
- [Release notes](https://github.com/authentik-community/kadmin-rs/releases)
- [Commits](https://github.com/authentik-community/kadmin-rs/compare/kadmin/version/0.7.0...kadmin/version/0.7.1)

---
updated-dependencies:
- dependency-name: python-kadmin-rs
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 14:47:01 +02:00
authentik-automation[bot]
b22ce29f8f lifecycle/ak: Add manage support (cherry-pick #22176 to version-2026.5) (#22221)
lifecycle/ak: Add manage support (#22176)

Co-authored-by: Connor Peshek <connor@connorpeshek.me>
2026-05-11 13:10:18 +02:00
authentik-automation[bot]
fd6bf9fd05 providers/saml: make unified saml endpoint (cherry-pick #20026 to version-2026.5) (#22187)
providers/saml: make unified saml endpoint (#20026)

* providers/saml: make unified saml endpoint

Co-authored-by: Connor Peshek <connor@connorpeshek.me>
2026-05-09 10:48:50 -05:00
authentik-automation[bot]
5e0448b541 providers/saml: make issuer url metadata url (cherry-pick #22178 to version-2026.5) (#22184)
providers/saml: make issuer url metadata url (#22178)

Co-authored-by: Connor Peshek <connor@connorpeshek.me>
2026-05-09 15:36:26 +02:00
authentik-automation[bot]
52c37992bd tenants: fix system flags removeable (cherry-pick #22163 to version-2026.5) (#22182)
tenants: fix system flags removeable (#22163)

* tenants: fix system flags removeable



* lint and fix test



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-09 15:14:01 +02:00
authentik-automation[bot]
45b78f78b8 web/admin: legacy modal fixes and fix log viewer in form layout (cherry-pick #22168 to version-2026.5) (#22173)
web/admin: legacy modal fixes and fix log viewer in form layout (#22168)

* web/admin: fix log-viewer layout again

I thought I only recently fixed this...?



* switch closeAfterSuccessfulSubmit -> keepOpenAfterSubmit with correct attribute name and false as default



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-05-09 00:24:15 +02:00
authentik-automation[bot]
7900399a97 blueprints: fix mismatched API schema and implementation (cherry-pick #22087 to version-2026.5) (#22171)
blueprints: fix mismatched API schema and implementation (#22087)

align blueprint import schema with 200 result response

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2026-05-08 20:58:06 +02:00
1431 changed files with 3680 additions and 2479 deletions

View File

@@ -25,7 +25,7 @@ runs:
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
with:
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext libclang-dev libkadm5clnt-mit12 libkadm5clnt7t64-heimdal libkrb5-dev krb5-kdc krb5-user krb5-admin-server
update: true
upgrade: false
install-recommends: false

View File

@@ -68,6 +68,8 @@ jobs:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: "system,python,go,node,runtime,rust-nightly"
- name: Run migrations
run: make migrate
- name: Bump version

View File

@@ -82,10 +82,14 @@ jobs:
token: "${{ steps.app-token.outputs.token }}"
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: "system,python,go,node,runtime,rust-nightly"
- name: Run migrations
run: make migrate
- name: Bump version
run: "make bump version=${{ inputs.version }}"
- name: Re-generate API Clients
run: make gen
- name: Commit and push
run: |
# ID from https://api.github.com/users/authentik-automation[bot]

12
Cargo.lock generated
View File

@@ -171,7 +171,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "authentik"
version = "2026.5.0-rc1"
version = "2026.5.0-rc2"
dependencies = [
"arc-swap",
"argh",
@@ -196,7 +196,7 @@ dependencies = [
[[package]]
name = "authentik-axum"
version = "2026.5.0-rc1"
version = "2026.5.0-rc2"
dependencies = [
"authentik-common",
"axum",
@@ -216,7 +216,7 @@ dependencies = [
[[package]]
name = "authentik-client"
version = "2026.5.0-rc1"
version = "2026.5.0-rc2"
dependencies = [
"aws-lc-rs",
"reqwest",
@@ -232,7 +232,7 @@ dependencies = [
[[package]]
name = "authentik-common"
version = "2026.5.0-rc1"
version = "2026.5.0-rc2"
dependencies = [
"arc-swap",
"authentik-client",
@@ -3934,9 +3934,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",

View File

@@ -8,7 +8,7 @@ members = [
resolver = "3"
[workspace.package]
version = "2026.5.0-rc1"
version = "2026.5.0-rc2"
authors = ["authentik Team <hello@goauthentik.io>"]
description = "Making authentication simple."
edition = "2024"
@@ -97,7 +97,7 @@ sqlx = { version = "= 0.8.6", default-features = false, features = [
tempfile = "= 3.27.0"
thiserror = "= 2.0.18"
time = { version = "= 0.3.47", features = ["macros"] }
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
tokio = { version = "= 1.52.3", features = ["full", "tracing"] }
tokio-retry2 = "= 0.9.1"
tokio-rustls = "= 0.26.4"
tokio-util = { version = "= 0.7.18", features = ["full"] }
@@ -115,9 +115,9 @@ url = "= 2.5.8"
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
which = "= 8.0.2"
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false }
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc2", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.5.0-rc2", path = "./packages/client-rust" }
ak-common = { package = "authentik-common", version = "2026.5.0-rc2", path = "./packages/ak-common", default-features = false }
[workspace.lints.rust]
ambiguous_negative_literals = "warn"

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2026.5.0-rc1"
VERSION = "2026.5.0-rc2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -42,11 +42,29 @@ def validate_auth(header: bytes, format="bearer") -> str | None:
return auth_credentials
class IPCUser(AnonymousUser):
class VirtualUser(AnonymousUser):
is_active = True
@property
def type(self):
return UserTypes.INTERNAL_SERVICE_ACCOUNT
@property
def is_anonymous(self):
return False
@property
def is_authenticated(self):
return True
def all_roles(self):
return []
class IPCUser(VirtualUser):
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
username = "authentik:system"
is_active = True
is_superuser = True
@property
@@ -62,17 +80,6 @@ class IPCUser(AnonymousUser):
def has_module_perms(self, module):
return True
@property
def is_anonymous(self):
return False
@property
def is_authenticated(self):
return True
def all_roles(self):
return []
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""

View File

@@ -217,10 +217,7 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
@extend_schema(
request={"multipart/form-data": BlueprintUploadSerializer},
responses={
204: BlueprintImportResultSerializer,
400: BlueprintImportResultSerializer,
},
responses={200: BlueprintImportResultSerializer},
)
@action(url_path="import", detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
@validate(
@@ -247,21 +244,13 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
import_response = self.BlueprintImportResultSerializer(
data={
"logs": [],
"success": False,
"logs": [LogEventSerializer(log).data for log in logs],
"success": valid,
}
)
import_response.is_valid(raise_exception=True)
import_response.initial_data["logs"] = [LogEventSerializer(log).data for log in logs]
import_response.initial_data["success"] = valid
import_response.is_valid()
if not valid:
return Response(data=import_response.initial_data, status=200)
successful = importer.apply()
import_response.initial_data["success"] = successful
import_response.is_valid()
if not successful:
return Response(data=import_response.initial_data, status=200)
if valid:
import_response.initial_data["success"] = importer.apply()
import_response.is_valid()
return Response(data=import_response.initial_data, status=200)

View File

@@ -3,6 +3,7 @@
from json import dumps, loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import dump
@@ -141,6 +142,20 @@ class TestBlueprintsV1API(APITestCase):
)
self.assertEqual(res.status_code, 200)
def test_api_import_invalid_blueprint_returns_result_payload(self):
"""Invalid blueprint content returns a result payload instead of a 400 response."""
file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"file": file},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.assertFalse(res.json()["success"])
self.assertGreater(len(res.json()["logs"]), 0)
def test_api_import_unknown_path(self):
"""Path not in available blueprints is rejected (covers api.py:56)."""
res = self.client.post(

View File

@@ -246,6 +246,25 @@ class GroupSerializer(ModelSerializer):
)
return superuser
def validate_users(self, users: list) -> list:
"""Require add_user_to_group permission when adding new members via group PATCH."""
request: Request = self.context.get("request", None)
if not request:
return users
if not self.instance:
return users
# BulkManyRelatedField returns raw PKs, not model instances
current_user_pks = set(self.instance.users.values_list("pk", flat=True))
new_users = [u for u in users if u not in current_user_pks]
if not new_users:
return users
has_perm = request.user.has_perm(
"authentik_core.add_user_to_group"
) or request.user.has_perm("authentik_core.add_user_to_group", self.instance)
if not has_perm:
raise ValidationError(_("User does not have permission to add members to this group."))
return users
class Meta:
model = Group
fields = [

View File

@@ -297,6 +297,36 @@ class UserSerializer(ModelSerializer):
raise ValidationError(_("Setting a user to internal service account is not allowed."))
return user_type
def validate_groups(self, groups: list) -> list:
"""Require enable_group_superuser permission when adding a user to a superuser group."""
request: Request = self.context.get("request", None)
if not request:
return groups
current_groups = set(self.instance.groups.all()) if self.instance else set()
for group in groups:
if not group.is_superuser:
continue
if group in current_groups:
continue
if not request.user.has_perm("authentik_core.enable_group_superuser"):
raise ValidationError(
_("User does not have permission to add members to a superuser group.")
)
return groups
def validate_roles(self, roles: list) -> list:
"""Require change_role permission when assigning new roles to a user."""
request: Request = self.context.get("request", None)
if not request:
return roles
current_roles = set(self.instance.roles.all()) if self.instance else set()
new_roles = [r for r in roles if r not in current_roles]
if not new_roles:
return roles
if not request.user.has_perm("authentik_rbac.change_role"):
raise ValidationError(_("User does not have permission to assign roles."))
return roles
def validate(self, attrs: dict) -> dict:
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError(_("Can't modify internal service account users"))

View File

@@ -158,3 +158,58 @@ class TestGroupsAPI(APITestCase):
data={"name": generate_id(), "is_superuser": True},
)
self.assertEqual(res.status_code, 201)
def test_patch_users_no_perm(self):
"""PATCH group with new users without add_user_to_group must be rejected."""
group = Group.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={"users": [self.user.pk]},
content_type="application/json",
)
self.assertEqual(res.status_code, 400)
def test_patch_users_with_global_perm(self):
"""PATCH group with new users with global add_user_to_group must succeed."""
group = Group.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group")
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={"users": [self.user.pk]},
content_type="application/json",
)
self.assertEqual(res.status_code, 200)
def test_patch_users_with_obj_perm(self):
"""PATCH group with new users with object-level add_user_to_group must succeed."""
group = Group.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={"users": [self.user.pk]},
content_type="application/json",
)
self.assertEqual(res.status_code, 200)
def test_patch_existing_users_no_perm(self):
"""PATCH group keeping existing membership without add_user_to_group must succeed."""
group = Group.objects.create(name=generate_id())
group.users.add(self.user)
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={"users": [self.user.pk]},
content_type="application/json",
)
self.assertEqual(res.status_code, 200)

View File

@@ -12,6 +12,7 @@ from authentik.brands.models import Brand
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession,
Group,
Session,
Token,
User,
@@ -25,6 +26,7 @@ from authentik.core.tests.utils import (
)
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
from authentik.lib.generators import generate_id, generate_key
from authentik.rbac.models import Role
from authentik.stages.email.models import EmailStage
INVALID_PASSWORD_HASH = "not-a-valid-hash"
@@ -939,3 +941,79 @@ class TestUsersAPI(APITestCase):
self.assertIn(user2.pk, pks)
# Verify user2 comes before user1 in descending order
self.assertLess(pks.index(user2.pk), pks.index(user1.pk))
class TestUsersAPIGroupRoleValidation(APITestCase):
"""Test that PATCH /api/v3/core/users/{pk}/ enforces group and role permission checks."""
def setUp(self) -> None:
self.actor = create_test_user()
self.target = create_test_user()
def _patch(self, data: dict):
self.client.force_login(self.actor)
return self.client.patch(
reverse("authentik_api:user-detail", kwargs={"pk": self.target.pk}),
data=data,
content_type="application/json",
)
def test_patch_superuser_group_no_perm(self):
"""Assigning a superuser group without enable_group_superuser must be rejected."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
group = Group.objects.create(name=generate_id(), is_superuser=True)
res = self._patch({"groups": [str(group.pk)]})
self.assertEqual(res.status_code, 400)
def test_patch_superuser_group_with_perm(self):
"""Assigning a superuser group with enable_group_superuser must succeed."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
self.actor.assign_perms_to_managed_role("authentik_core.enable_group_superuser")
group = Group.objects.create(name=generate_id(), is_superuser=True)
res = self._patch({"groups": [str(group.pk)]})
self.assertEqual(res.status_code, 200)
def test_patch_non_superuser_group_no_perm(self):
"""Assigning a non-superuser group without special permission must succeed."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
group = Group.objects.create(name=generate_id(), is_superuser=False)
res = self._patch({"groups": [str(group.pk)]})
self.assertEqual(res.status_code, 200)
def test_patch_existing_superuser_group_no_perm(self):
"""Keeping an existing superuser group membership without the permission must succeed."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
group = Group.objects.create(name=generate_id(), is_superuser=True)
self.target.groups.add(group)
res = self._patch({"groups": [str(group.pk)]})
self.assertEqual(res.status_code, 200)
def test_patch_role_no_perm(self):
"""Assigning a new role without change_role must be rejected."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
role = Role.objects.create(name=generate_id())
res = self._patch({"roles": [str(role.pk)]})
self.assertEqual(res.status_code, 400)
def test_patch_role_with_perm(self):
"""Assigning a new role with change_role must succeed."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
self.actor.assign_perms_to_managed_role("authentik_rbac.change_role")
role = Role.objects.create(name=generate_id())
res = self._patch({"roles": [str(role.pk)]})
self.assertEqual(res.status_code, 200)
def test_patch_existing_role_no_perm(self):
"""Keeping an existing role without change_role must succeed."""
self.actor.assign_perms_to_managed_role("authentik_core.view_user")
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.target)
role = Role.objects.create(name=generate_id())
self.target.roles.add(role)
res = self._patch({"roles": [str(role.pk)]})
self.assertEqual(res.status_code, 200)

View File

@@ -7,7 +7,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_sche
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import ChoiceField
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
@@ -44,7 +44,6 @@ from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_ME
class AgentConnectorSerializer(ConnectorSerializer):
class Meta(ConnectorSerializer.Meta):
model = AgentConnector
fields = ConnectorSerializer.Meta.fields + [
@@ -63,7 +62,6 @@ class AgentConnectorSerializer(ConnectorSerializer):
class MDMConfigSerializer(PassiveSerializer):
platform = ChoiceField(choices=OSFamily.choices)
enrollment_token = PrimaryKeyRelatedField(
queryset=EnrollmentToken.objects.including_expired().all()
@@ -89,7 +87,6 @@ class AgentConnectorViewSet(
UsedByMixin,
ModelViewSet,
):
queryset = AgentConnector.objects.all()
serializer_class = AgentConnectorSerializer
search_fields = ["name"]
@@ -121,6 +118,8 @@ class AgentConnectorViewSet(
methods=["POST"],
detail=False,
authentication_classes=[AgentEnrollmentAuth],
# Permissions are handled via AgentEnrollmentAuth
permission_classes=[AllowAny],
)
def enroll(self, request: Request):
token: EnrollmentToken = request.auth
@@ -151,7 +150,13 @@ class AgentConnectorViewSet(
request=OpenApiTypes.NONE,
responses=AgentConfigSerializer(),
)
@action(methods=["GET"], detail=False, authentication_classes=[AgentAuth])
@action(
methods=["GET"],
detail=False,
authentication_classes=[AgentAuth],
# Permissions are handled via AgentAuth
permission_classes=[AllowAny],
)
def agent_config(self, request: Request):
token: DeviceToken = request.auth
connector: AgentConnector = token.device.connector.agentconnector
@@ -165,7 +170,13 @@ class AgentConnectorViewSet(
request=DeviceFacts(),
responses={204: OpenApiResponse(description="Successfully checked in")},
)
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
@action(
methods=["POST"],
detail=False,
authentication_classes=[AgentAuth],
# Permissions are handled via AgentAuth
permission_classes=[AllowAny],
)
def check_in(self, request: Request):
token: DeviceToken = request.auth
data = DeviceFacts(data=request.data)

View File

@@ -1,5 +1,6 @@
from typing import Any
from django.db.models import Model
from django.http import HttpRequest
from django.utils.timezone import now
from drf_spectacular.extensions import OpenApiAuthenticationExtension
@@ -9,7 +10,7 @@ from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.api.authentication import IPCUser, validate_auth
from authentik.api.authentication import VirtualUser, validate_auth
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import User
from authentik.crypto.apps import MANAGED_KEY
@@ -25,9 +26,18 @@ LOGGER = get_logger()
PLATFORM_ISSUER = "goauthentik.io/platform"
class DeviceUser(IPCUser):
class DeviceUser(VirtualUser):
username = "authentik:endpoints:device"
def has_perm(self, perm: str, obj: Model | None = None) -> bool:
if perm in [
"authentik_core.view_user",
"authentik_core.view_group",
]:
return True
return False
class AgentEnrollmentAuth(BaseAuthentication):

View File

@@ -223,3 +223,17 @@ class TestAgentAPI(APITestCase):
data={"platform": OSFamily.macOS, "enrollment_token": self.token.pk},
)
self.assertEqual(res.status_code, 200)
def test_users_list(self):
response = self.client.get(
reverse("authentik_api:user-list"),
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 200)
def test_other_api_forbidden(self):
response = self.client.get(
reverse("authentik_api:application-list"),
HTTP_AUTHORIZATION=f"Bearer+agent {self.device_token.key}",
)
self.assertEqual(response.status_code, 403)

View File

@@ -2,6 +2,7 @@ from django.urls import reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from structlog.stdlib import get_logger
@@ -25,7 +26,13 @@ class AgentConnectorViewSetMixin:
request=OpenApiTypes.NONE,
responses=AgentAuthenticationResponse(),
)
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
@action(
methods=["POST"],
detail=False,
authentication_classes=[AgentAuth],
# Permissions are handled via AgentAuth
permission_classes=[AllowAny],
)
@enterprise_action
def auth_ia(self, request: Request) -> Response:
token: DeviceToken = request.auth

View File

@@ -1,14 +1,72 @@
from datetime import datetime
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework.exceptions import ValidationError
from authentik.enterprise.license import LicenseKey
from authentik.providers.scim.models import SCIMAuthenticationMode
from authentik.providers.scim.models import SCIMAuthenticationMode, SCIMProvider
from authentik.sources.oauth.models import UserOAuthSourceConnection
class SCIMProviderSerializerMixin:
def _get_token(self, instance: SCIMProvider) -> UserOAuthSourceConnection | None:
user = instance.auth_oauth_user
conn = UserOAuthSourceConnection.objects.filter(
user=user, source=instance.auth_oauth
).first()
return conn
def get_auth_oauth_token_last_updated(self, instance: SCIMProvider) -> datetime | None:
conn = self._get_token(instance)
return conn.last_updated if conn else None
def get_auth_oauth_token_expires(self, instance: SCIMProvider) -> datetime | None:
conn = self._get_token(instance)
return conn.expires if conn else None
def get_auth_oauth_url_callback(self, instance: SCIMProvider) -> str | None:
if (
instance.auth_mode
in [
SCIMAuthenticationMode.TOKEN,
SCIMAuthenticationMode.OAUTH_SILENT,
]
or not instance.backchannel_application
):
return None
relative_url = reverse(
"authentik_enterprise_providers_scim:callback",
kwargs={"application_slug": instance.backchannel_application.slug},
)
if "request" not in self.context:
return relative_url
return self.context["request"].build_absolute_uri(relative_url)
def get_auth_oauth_url_start(self, instance: SCIMProvider) -> str | None:
if (
instance.auth_mode
in [
SCIMAuthenticationMode.TOKEN,
SCIMAuthenticationMode.OAUTH_SILENT,
]
or not instance.backchannel_application
):
return None
relative_url = reverse(
"authentik_enterprise_providers_scim:start",
kwargs={"application_slug": instance.backchannel_application.slug},
)
if "request" not in self.context:
return relative_url
return self.context["request"].build_absolute_uri(relative_url)
def validate_auth_mode(self, auth_mode: SCIMAuthenticationMode) -> SCIMAuthenticationMode:
if auth_mode == SCIMAuthenticationMode.OAUTH:
if auth_mode in [
SCIMAuthenticationMode.OAUTH_SILENT,
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
]:
if not LicenseKey.cached_summary().status.is_valid:
raise ValidationError(_("Enterprise is required to use the OAuth mode."))
return auth_mode

View File

@@ -7,3 +7,4 @@ class AuthentikEnterpriseProviderSCIMConfig(EnterpriseConfig):
label = "authentik_enterprise_providers_scim"
verbose_name = "authentik Enterprise.Providers.SCIM"
default = True
mountpoint = "application/scim/"

View File

@@ -1,12 +1,14 @@
from datetime import timedelta
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from django.utils.timezone import now
from requests import Request, RequestException
from structlog.stdlib import get_logger
from authentik.common.oauth.constants import GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN
from authentik.providers.scim.clients.exceptions import SCIMRequestException
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
from authentik.providers.scim.models import SCIMAuthenticationMode
from authentik.sources.oauth.clients.base import BaseOAuthClient
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
if TYPE_CHECKING:
@@ -18,23 +20,26 @@ class SCIMOAuthException(SCIMRequestException):
class SCIMOAuthAuth:
def __init__(self, provider: SCIMProvider):
self.provider = provider
self.user = provider.auth_oauth_user
self.logger = get_logger().bind()
self.connection = self.get_connection()
def retrieve_token(self):
if not self.provider.auth_oauth:
return None
def retrieve_token(self, conn: UserOAuthSourceConnection | None) -> dict[str, Any]:
source: OAuthSource = self.provider.auth_oauth
client = OAuth2Client(source, None)
client: BaseOAuthClient = source.source_type.callback_view(request=None).get_client(source)
access_token_url = source.source_type.access_token_url or ""
if source.source_type.urls_customizable and source.access_token_url:
access_token_url = source.access_token_url
data = client.get_access_token_args(None, None)
data["grant_type"] = "password"
if self.provider.auth_mode == SCIMAuthenticationMode.OAUTH_SILENT:
data["grant_type"] = GRANT_TYPE_PASSWORD
elif self.provider.auth_mode == SCIMAuthenticationMode.OAUTH_INTERACTIVE:
data["grant_type"] = GRANT_TYPE_REFRESH_TOKEN
if not conn:
raise SCIMOAuthException(None, "Could not refresh SCIM OAuth token")
data["refresh_token"] = conn.refresh_token
data.update(self.provider.auth_oauth_params)
try:
response = client.do_request(
@@ -54,12 +59,14 @@ class SCIMOAuthAuth:
raise SCIMOAuthException(exc.response, message="Failed to get OAuth token") from exc
def get_connection(self):
token = UserOAuthSourceConnection.objects.filter(
source=self.provider.auth_oauth, user=self.user, expires__gt=now()
if not self.provider.auth_oauth:
return None
conn = UserOAuthSourceConnection.objects.filter(
source=self.provider.auth_oauth, user=self.user
).first()
if token and token.access_token:
return token
token = self.retrieve_token()
if conn and conn.access_token and conn.expires > now():
return conn
token = self.retrieve_token(conn)
access_token = token["access_token"]
expires_in = int(token.get("expires_in", 0))
token, _ = UserOAuthSourceConnection.objects.update_or_create(
@@ -67,7 +74,10 @@ class SCIMOAuthAuth:
user=self.user,
defaults={
"access_token": access_token,
"refresh_token": token.get("refresh_token"),
"expires": now() + timedelta(seconds=expires_in),
# When using `update_or_create`, `last_updated` is not updated
"last_updated": now(),
},
)
return token

View File

@@ -14,7 +14,10 @@ def scim_provider_post_save(sender: type[Model], instance: SCIMProvider, created
"""Create service account before provider is saved"""
identifier = f"ak-providers-scim-{instance.pk}"
with audit_ignore():
if instance.auth_mode == SCIMAuthenticationMode.OAUTH:
if instance.auth_mode in [
SCIMAuthenticationMode.OAUTH_SILENT,
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
]:
user, user_created = User.objects.update_or_create(
username=identifier,
defaults={

View File

@@ -0,0 +1,73 @@
"""SCIM OAuth tests"""
from unittest.mock import MagicMock, PropertyMock, patch
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_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.sources.oauth.models import OAuthSource
class TestSCIMOAuthAPI(APITestCase):
"""SCIM User tests"""
def setUp(self):
self.source = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
access_token_url="http://localhost/token", # nosec
consumer_key=generate_id(),
consumer_secret=generate_id(),
provider_type="openidconnect",
)
@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_api_create(self):
License.objects.create(key=generate_id())
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse("authentik_api:scimprovider-list"),
{
"name": generate_id(),
"url": "http://localhost",
"auth_mode": "oauth",
"auth_oauth": str(self.source.pk),
},
)
self.assertEqual(res.status_code, 201)
@patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
PropertyMock(return_value=False),
)
def test_api_create_no_license(self):
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse("authentik_api:scimprovider-list"),
{
"name": generate_id(),
"url": "http://localhost",
"auth_mode": "oauth",
"auth_oauth": str(self.source.pk),
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"auth_mode": ["Enterprise is required to use the OAuth mode."]}
)

View File

@@ -0,0 +1,100 @@
"""SCIM OAuth tests"""
from requests_mock import Mocker
from rest_framework.test import APITestCase
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 SCIMAuthenticationMode, SCIMMapping, SCIMProvider
from authentik.sources.oauth.models import OAuthSource
from authentik.tenants.models import Tenant
class TestSCIMOAuthAuth(APITestCase):
"""SCIM User tests"""
@apply_blueprint("system/providers-scim.yaml")
def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple users
Tenant.objects.update(avatars="none")
User.objects.all().exclude_anonymous().delete()
Group.objects.all().delete()
self.source = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
access_token_url="http://localhost/token", # nosec
consumer_key=generate_id(),
consumer_secret=generate_id(),
provider_type="openidconnect",
)
self.provider = SCIMProvider.objects.create(
name=generate_id(),
url="https://localhost",
auth_mode=SCIMAuthenticationMode.OAUTH_SILENT,
auth_oauth=self.source,
auth_oauth_params={
"foo": "bar",
},
exclude_users_service_account=True,
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
)
self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
)
self.provider.property_mappings_group.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
)
@Mocker()
def test_user_create(self, mock: Mocker):
"""Test user creation"""
scim_id = generate_id()
token = generate_id()
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
mock.get(
"https://localhost/ServiceProviderConfig",
json={},
)
mock.post(
"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, 3)
self.assertEqual(mock.request_history[1].method, "GET")
self.assertEqual(mock.request_history[2].method, "POST")
self.assertJSONEqual(
mock.request_history[2].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"active": True,
"emails": [
{
"primary": True,
"type": "other",
"value": f"{uid}@goauthentik.io",
}
],
"externalId": user.uid,
"name": {
"familyName": uid,
"formatted": f"{uid} {uid}",
"givenName": uid,
},
"displayName": f"{uid} {uid}",
"userName": uid,
},
)

View File

@@ -2,7 +2,7 @@
from base64 import b64encode
from datetime import timedelta
from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import parse_qs, urlencode, urlparse
from django.urls import reverse
from django.utils.timezone import now
@@ -11,17 +11,14 @@ from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.core.tests.utils import create_test_admin_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.providers.scim.models import SCIMAuthenticationMode, SCIMMapping, SCIMProvider
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.tenants.models import Tenant
from tests.live import create_test_admin_user
class SCIMOAuthTests(APITestCase):
class TestSCIMOAuthToken(APITestCase):
"""SCIM User tests"""
@apply_blueprint("system/providers-scim.yaml")
@@ -42,7 +39,7 @@ class SCIMOAuthTests(APITestCase):
self.provider = SCIMProvider.objects.create(
name=generate_id(),
url="https://localhost",
auth_mode=SCIMAuthenticationMode.OAUTH,
auth_mode=SCIMAuthenticationMode.OAUTH_SILENT,
auth_oauth=self.source,
auth_oauth_params={
"foo": "bar",
@@ -60,8 +57,9 @@ class SCIMOAuthTests(APITestCase):
self.provider.property_mappings_group.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
)
self.admin = create_test_admin_user()
def test_retrieve_token(self):
def test_retrieve_token_silent(self):
"""Test token retrieval"""
with Mocker() as mocker:
token = generate_id()
@@ -86,6 +84,44 @@ class SCIMOAuthTests(APITestCase):
)
self.assertEqual(mocker.request_history[0].body, "grant_type=password&foo=bar")
def test_retrieve_token_interactive(self):
"""Test token retrieval"""
self.provider.auth_mode = SCIMAuthenticationMode.OAUTH_INTERACTIVE
self.provider.save()
refresh_token = generate_id()
access_token = generate_id()
UserOAuthSourceConnection.objects.create(
user=self.provider.auth_oauth_user,
source=self.source,
refresh_token=refresh_token,
access_token=access_token,
)
with Mocker() as mocker:
token = generate_id()
mocker.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
self.provider.scim_auth()
conn = UserOAuthSourceConnection.objects.filter(
source=self.source,
user=self.provider.auth_oauth_user,
).first()
self.assertIsNotNone(conn)
self.assertTrue(conn.is_valid)
auth = (
b64encode(
b":".join((self.source.consumer_key.encode(), self.source.consumer_secret.encode()))
)
.strip()
.decode()
)
self.assertEqual(
mocker.request_history[0].headers["Authorization"],
f"Basic {auth}",
)
self.assertEqual(
mocker.request_history[0].body,
f"grant_type=refresh_token&refresh_token={refresh_token}&foo=bar",
)
def test_existing_token(self):
"""Test existing token"""
UserOAuthSourceConnection.objects.create(
@@ -98,96 +134,54 @@ class SCIMOAuthTests(APITestCase):
self.provider.scim_auth()
self.assertEqual(len(mocker.request_history), 0)
@Mocker()
def test_user_create(self, mock: Mocker):
"""Test user creation"""
scim_id = generate_id()
token = generate_id()
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
mock.get(
"https://localhost/ServiceProviderConfig",
json={},
)
mock.post(
"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, 3)
self.assertEqual(mock.request_history[1].method, "GET")
self.assertEqual(mock.request_history[2].method, "POST")
self.assertJSONEqual(
mock.request_history[2].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"active": True,
"emails": [
{
"primary": True,
"type": "other",
"value": f"{uid}@goauthentik.io",
}
],
"externalId": user.uid,
"name": {
"familyName": uid,
"formatted": f"{uid} {uid}",
"givenName": uid,
def test_interactive_start(self):
self.client.force_login(self.admin)
res = self.client.get(
reverse(
"authentik_enterprise_providers_scim:start",
kwargs={
"application_slug": self.app.slug,
},
"displayName": f"{uid} {uid}",
"userName": uid,
},
)
@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_api_create(self):
License.objects.create(key=generate_id())
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse("authentik_api:scimprovider-list"),
{
"name": generate_id(),
"url": "http://localhost",
"auth_mode": "oauth",
"auth_oauth": str(self.source.pk),
},
)
self.assertEqual(res.status_code, 201)
self.assertEqual(res.status_code, 302)
query = parse_qs(urlparse(res.url).query)
self.assertEqual(query["client_id"], [self.source.consumer_key])
self.assertEqual(
query["redirect_uri"],
[f"http://testserver/application/scim/{self.app.slug}/oauth2/callback/"],
)
self.assertEqual(query["response_type"], ["code"])
@patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
PropertyMock(return_value=False),
)
def test_api_create_no_license(self):
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse("authentik_api:scimprovider-list"),
{
"name": generate_id(),
"url": "http://localhost",
"auth_mode": "oauth",
"auth_oauth": str(self.source.pk),
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"auth_mode": ["Enterprise is required to use the OAuth mode."]}
def test_interactive_callback(self):
self.client.force_login(self.admin)
res = self.client.get(
reverse(
"authentik_enterprise_providers_scim:start",
kwargs={
"application_slug": self.app.slug,
},
)
)
self.assertEqual(res.status_code, 302)
query = parse_qs(urlparse(res.url).query)
with Mocker() as mock:
token = generate_id()
mock.post("http://localhost/token", json={"access_token": token, "expires_in": 3600})
res = self.client.get(
reverse(
"authentik_enterprise_providers_scim:callback",
kwargs={
"application_slug": self.app.slug,
},
)
+ "?"
+ urlencode({"state": query["state"][0], "code": generate_id()})
)
self.assertEqual(res.status_code, 302)
conn = UserOAuthSourceConnection.objects.filter(source=self.source).first()
self.assertIsNotNone(conn)
self.assertTrue(conn.is_valid)

View File

@@ -0,0 +1,10 @@
from django.urls import path
from authentik.enterprise.providers.scim.views import SCIMOAuthStart, SCIMRedirectCallback
urlpatterns = [
path("<slug:application_slug>/oauth2/start/", SCIMOAuthStart.as_view(), name="start"),
path(
"<slug:application_slug>/oauth2/callback/", SCIMRedirectCallback.as_view(), name="callback"
),
]

View File

@@ -0,0 +1,70 @@
from datetime import timedelta
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import now
from authentik.core.models import Application
from authentik.providers.scim.models import SCIMProvider
from authentik.sources.oauth.clients.base import BaseOAuthClient
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from authentik.sources.oauth.types.registry import RequestKind, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.sources.oauth.views.redirect import OAuthRedirect
class SCIMOAuthViewMixin:
provider: SCIMProvider
def get_client(self, source: OAuthSource, **kwargs) -> BaseOAuthClient:
source: OAuthSource = self.provider.auth_oauth
source_cls = registry.find(source.provider_type, kind=RequestKind.CALLBACK)
if not source_cls.client_class:
return super().get_client(source, **kwargs)
return source_cls.client_class(source, self.request, **kwargs)
def _get_scim_provider(self, app_slug: str):
app = Application.objects.filter(slug=app_slug).first()
if not app:
return None
provider = SCIMProvider.objects.filter(backchannel_application=app)
return provider.first()
def dispatch(self, request: HttpRequest, application_slug: str):
if not request.user.is_authenticated:
raise PermissionDenied()
provider = self._get_scim_provider(application_slug)
if not provider or not provider.auth_oauth:
raise PermissionDenied()
if not request.user.has_perm(
"authentik_providers_scim.change_scimprovider",
provider,
):
raise PermissionDenied()
self.provider = provider
return super().dispatch(request, source_slug=provider.auth_oauth.slug)
class SCIMOAuthStart(SCIMOAuthViewMixin, OAuthRedirect):
def get_callback_url(self, source: OAuthSource):
return reverse("authentik_enterprise_providers_scim:callback", kwargs=self.kwargs)
class SCIMRedirectCallback(SCIMOAuthViewMixin, OAuthCallback):
def redirect_flow_manager(self, client: BaseOAuthClient):
expires_in = int(self.token.get("expires_in", 0))
UserOAuthSourceConnection.objects.update_or_create(
source=self.provider.auth_oauth,
user=self.provider.auth_oauth_user,
defaults={
"access_token": self.token.get("access_token"),
"refresh_token": self.token.get("refresh_token"),
"expires": now() + timedelta(seconds=expires_in),
},
)
return redirect("authentik_core:if-admin")

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
from urllib.parse import urlparse
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
@@ -55,7 +56,9 @@ class SignInRequest:
_, provider = req.get_app_provider()
if not req.wreply:
req.wreply = provider.acs_url
if not req.wreply.startswith(provider.acs_url):
reply = urlparse(req.wreply)
configured = urlparse(provider.acs_url)
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
raise ValueError("Invalid wreply")
return req

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
from urllib.parse import urlparse
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
@@ -32,7 +33,9 @@ class SignOutRequest:
_, provider = req.get_app_provider()
if not req.wreply:
req.wreply = provider.acs_url
if not req.wreply.startswith(provider.acs_url):
reply = urlparse(req.wreply)
configured = urlparse(provider.acs_url)
if not (reply[:2] == configured[:2] and reply.path.startswith(configured.path)):
raise ValueError("Invalid wreply")
return req

View File

@@ -27,12 +27,27 @@ class TestWSFedSignIn(TestCase):
name=generate_id(),
authorization_flow=self.flow,
signing_kp=self.cert,
acs_url="https://t.goauthentik.io",
audience="foo",
)
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.provider
)
self.factory = RequestFactory()
def test_wreply(self):
request = self.factory.get(
"/?wreply=https://t.goauthentik.io/foo&wa=wsignin1.0&wtrealm=foo",
user=get_anonymous_user(),
)
SignInRequest.parse(request)
with self.assertRaises(ValueError):
request = self.factory.get(
"/?wreply=https://t.goauthentik.io.invalid.com&wa=wsignin1.0&wtrealm=foo",
user=get_anonymous_user(),
)
SignInRequest.parse(request)
def test_token_gen(self):
request = self.factory.get("/", user=get_anonymous_user())
proc = SignInProcessor(

View File

@@ -128,6 +128,7 @@ class SessionEndChallenge(WithUserInfoChallenge):
application_launch_url = CharField(required=False)
invalidation_flow_url = CharField(required=False)
overview_url = CharField(required=False)
brand_name = CharField(required=True)

View File

@@ -16,7 +16,7 @@ from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger
from authentik.common.oauth.constants import PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI
from authentik.core.models import Application, User
from authentik.core.models import Application, User, UserTypes
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
@@ -331,6 +331,10 @@ class SessionEndStage(ChallengeStageView):
"component": "ak-stage-session-end",
"brand_name": self.request.brand.branding_title,
}
if self.get_pending_user().type == UserTypes.INTERNAL:
data["overview_url"] = self.request.build_absolute_uri(
reverse("authentik_core:root-redirect")
)
if application:
data["application_name"] = application.name
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())

View File

@@ -9,10 +9,10 @@ from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import GenericViewSet
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 MetaNameSerializer, ModelSerializer
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
@@ -20,7 +20,7 @@ class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
"""Serializer for BaseGrantModel and ExpiringBaseGrant"""
user = UserSerializer()
provider = OAuth2ProviderSerializer()
provider = ProviderSerializer()
scope = ListField(child=CharField())
class Meta:

View File

@@ -61,6 +61,11 @@ class SAMLProviderSerializer(ProviderSerializer):
url_download_metadata = SerializerMethodField()
url_issuer = SerializerMethodField()
# Unified SAML endpoint (primary)
url_unified = SerializerMethodField()
url_unified_init = SerializerMethodField()
# Legacy endpoints (for backward compatibility)
url_sso_post = SerializerMethodField()
url_sso_redirect = SerializerMethodField()
url_sso_init = SerializerMethodField()
@@ -100,13 +105,43 @@ class SAMLProviderSerializer(ProviderSerializer):
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
"authentik_providers_saml:metadata-download",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return DEFAULT_ISSUER
def get_url_unified(self, instance: SAMLProvider) -> str:
"""Get unified SAML endpoint URL (handles SSO and SLO)"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_unified_init(self, instance: SAMLProvider) -> str:
"""Get IdP-initiated SAML URL"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:init",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL"""
if "request" not in self._context:
@@ -243,6 +278,8 @@ class SAMLProviderSerializer(ProviderSerializer):
"default_name_id_policy",
"url_download_metadata",
"url_issuer",
"url_unified",
"url_unified_init",
"url_sso_post",
"url_sso_redirect",
"url_sso_init",

View File

@@ -241,7 +241,7 @@ class SAMLProvider(Provider):
"""Use IDP-Initiated SAML flow as launch URL"""
try:
return reverse(
"authentik_providers_saml:sso-init",
"authentik_providers_saml:init",
kwargs={"application_slug": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:

View File

@@ -147,7 +147,7 @@ class AssertionProcessor:
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
"authentik_providers_saml:metadata-download",
kwargs={"application_slug": self.provider.application.slug},
)
)

View File

@@ -48,7 +48,7 @@ class MetadataProcessor:
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
"authentik_providers_saml:metadata-download",
kwargs={"application_slug": self.provider.application.slug},
)
)
@@ -81,54 +81,35 @@ class MetadataProcessor:
element.text = name_id_format
yield element
def _get_unified_url(self) -> str:
"""Get the unified SAML endpoint URL"""
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
def get_sso_bindings(self) -> Iterator[Element]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
"""Get all SSO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
element.attrib["Binding"] = binding
element.attrib["Location"] = url
element.attrib["Location"] = unified_url
yield element
def get_slo_bindings(self) -> Iterator[Element]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
"""Get all SLO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService")
element.attrib["Binding"] = binding
element.attrib["Location"] = url
element.attrib["Location"] = unified_url
yield element
def _prepare_signature(self, entity_descriptor: _Element):

View File

@@ -4,19 +4,26 @@ from django.urls import path
from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet
from authentik.providers.saml.api.providers import SAMLProviderViewSet
from authentik.providers.saml.views import metadata, sso
from authentik.providers.saml.views import metadata, sso, unified
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
urlpatterns = [
# Base path for Issuer/Entity ID
# Unified Endpoint - handles SSO and SLO based on message type
path(
"<slug:application_slug>/",
sso.SAMLSSOBindingRedirectView.as_view(),
unified.SAMLUnifiedView.as_view(),
name="base",
),
# IdP-initiated
path(
"<slug:application_slug>/init/",
sso.SAMLSSOBindingInitView.as_view(),
name="init",
),
# LEGACY Endpoints (backward compatibility)
# SSO Bindings
path(
"<slug:application_slug>/sso/binding/redirect/",

View File

@@ -0,0 +1,118 @@
"""Unified SAML endpoint - handles SSO and SLO based on message type"""
from base64 import b64decode
from defusedxml.lxml import fromstring
from django.http import HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.constants import NS_MAP
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.providers.saml.views.flows import (
REQUEST_KEY_SAML_REQUEST,
REQUEST_KEY_SAML_RESPONSE,
)
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
from authentik.providers.saml.views.sso import (
SAMLSSOBindingPOSTView,
SAMLSSOBindingRedirectView,
)
LOGGER = get_logger()
# SAML message type constants
SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest"
SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest"
def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None:
"""Parse SAML request to determine if AuthnRequest or LogoutRequest."""
try:
if is_post_binding:
decoded_xml = b64decode(saml_request.encode())
else:
decoded_xml = decode_base64_and_inflate(saml_request)
root = fromstring(decoded_xml)
if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_AUTHN_REQUEST
if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_LOGOUT_REQUEST
return None
except Exception: # noqa: BLE001
return None
@method_decorator(xframe_options_sameorigin, name="dispatch")
@method_decorator(csrf_exempt, name="dispatch")
class SAMLUnifiedView(View):
"""Unified SAML endpoint - handles SSO and SLO based on message type.
The operation type is determined by parsing
the incoming SAML message:
- AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView)
- LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
- LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
"""
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Route the request based on SAML message type."""
# ak user was not logged in, redirected to login, and is back w POST payload in session
if SESSION_KEY_POST in request.session:
return self._delegate_to_sso(request, application_slug, is_post_binding=True)
# Determine binding from HTTP method
is_post_binding = request.method == "POST"
data = request.POST if is_post_binding else request.GET
# LogoutResponse - delegate to SLO view (handles it in dispatch)
if REQUEST_KEY_SAML_RESPONSE in data:
return self._delegate_to_slo(request, application_slug, is_post_binding)
# Check for SAML request
if REQUEST_KEY_SAML_REQUEST not in data:
LOGGER.info("SAML payload missing")
return bad_request_message(request, "The SAML request payload is missing.")
# Detect message type and delegate
saml_request = data[REQUEST_KEY_SAML_REQUEST]
message_type = detect_saml_message_type(saml_request, is_post_binding)
if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST:
return self._delegate_to_sso(request, application_slug, is_post_binding)
elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST:
return self._delegate_to_slo(request, application_slug, is_post_binding)
else:
LOGGER.warning("Unknown SAML message type", message_type=message_type)
return bad_request_message(
request, f"Unsupported SAML message type: {message_type or 'unknown'}"
)
def _delegate_to_sso(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SSO view."""
if is_post_binding:
view = SAMLSSOBindingPOSTView.as_view()
else:
view = SAMLSSOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)
def _delegate_to_slo(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SLO view."""
if is_post_binding:
view = SPInitiatedSLOBindingPOSTView.as_view()
else:
view = SPInitiatedSLOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)

View File

@@ -1,5 +1,6 @@
"""SCIM Provider API Views"""
from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
@@ -16,6 +17,11 @@ class SCIMProviderSerializer(
):
"""SCIMProvider Serializer"""
auth_oauth_token_last_updated = SerializerMethodField()
auth_oauth_token_expires = SerializerMethodField()
auth_oauth_url_callback = SerializerMethodField()
auth_oauth_url_start = SerializerMethodField()
class Meta:
model = SCIMProvider
fields = [
@@ -35,6 +41,10 @@ class SCIMProviderSerializer(
"auth_mode",
"auth_oauth",
"auth_oauth_params",
"auth_oauth_token_last_updated",
"auth_oauth_token_expires",
"auth_oauth_url_callback",
"auth_oauth_url_start",
"compatibility_mode",
"service_provider_config_cache_timeout",
"exclude_users_service_account",

View File

@@ -102,4 +102,16 @@ class Migration(migrations.Migration):
verbose_name="SCIM Compatibility Mode",
),
),
migrations.AlterField(
model_name="scimprovider",
name="auth_mode",
field=models.TextField(
choices=[
("token", "Token"),
("oauth", "OAuth (Silent)"),
("oauth_interactive", "OAuth (interactive)"),
],
default="token",
),
),
]

View File

@@ -72,7 +72,8 @@ class SCIMAuthenticationMode(models.TextChoices):
"""SCIM authentication modes"""
TOKEN = "token", _("Token")
OAUTH = "oauth", _("OAuth")
OAUTH_SILENT = "oauth", _("OAuth (Silent)")
OAUTH_INTERACTIVE = "oauth_interactive", _("OAuth (interactive)")
class SCIMCompatibilityMode(models.TextChoices):
@@ -144,7 +145,10 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
)
def scim_auth(self) -> AuthBase:
if self.auth_mode == SCIMAuthenticationMode.OAUTH:
if self.auth_mode in [
SCIMAuthenticationMode.OAUTH_SILENT,
SCIMAuthenticationMode.OAUTH_INTERACTIVE,
]:
try:
from authentik.enterprise.providers.scim.auth_oauth2 import SCIMOAuthAuth

View File

@@ -1,6 +1,5 @@
"""Source type manager"""
from collections.abc import Callable
from enum import Enum
from typing import Any
@@ -114,7 +113,7 @@ class SourceTypeRegistry:
)
return found_type
def find(self, type_name: str, kind: RequestKind) -> Callable:
def find(self, type_name: str, kind: RequestKind) -> type[OAuthCallback | OAuthRedirect]:
"""Find fitting Source Type"""
found_type = self.find_type(type_name)
if kind == RequestKind.CALLBACK:

View File

@@ -15,6 +15,7 @@ from structlog.stdlib import get_logger
from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.events.models import Event, EventAction
from authentik.sources.oauth.clients.base import BaseOAuthClient
from authentik.sources.oauth.models import (
GroupOAuthSourceConnection,
OAuthSource,
@@ -29,7 +30,7 @@ class OAuthCallback(OAuthClientMixin, View):
"Base OAuth callback view."
source: OAuthSource
token: dict | None = None
token: dict[str, Any] | None = None
def dispatch(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
"""View Get handler"""
@@ -49,20 +50,31 @@ class OAuthCallback(OAuthClientMixin, View):
if "error" in self.token:
return self.handle_login_failure(self.token["error"])
# Fetch profile info
try:
res = self.redirect_flow_manager(client)
except ValueError as exc:
# if we're authenticated and not in a source stage and this new flag is enabled,
# just continue
if self.request.user.is_authenticated:
pass
return self.handle_login_failure(exc.args[0])
return res
def redirect_flow_manager(self, client: BaseOAuthClient) -> HttpResponse:
try:
raw_info = client.get_profile_info(self.token)
if raw_info is None:
return self.handle_login_failure("Could not retrieve profile.")
raise ValueError("Could not retrieve profile.")
except JSONDecodeError as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
message="Failed to JSON-decode profile.",
raw_profile=exc.doc,
).from_http(self.request)
return self.handle_login_failure("Could not retrieve profile.")
raise ValueError("Could not retrieve profile.") from None
identifier = self.get_user_id(info=raw_info)
if identifier is None:
return self.handle_login_failure("Could not determine id.")
raise ValueError("Could not determine id.")
sfm = OAuthSourceFlowManager(
source=self.source,
request=self.request,

View File

@@ -1,6 +1,7 @@
"""authentik saml source processor"""
from base64 import b64decode
from datetime import UTC, datetime
from time import mktime
from typing import TYPE_CHECKING
@@ -40,6 +41,7 @@ from authentik.sources.saml.exceptions import (
InvalidSignature,
MismatchedRequestID,
MissingSAMLResponse,
SAMLException,
UnsupportedNameIDFormat,
)
from authentik.sources.saml.models import (
@@ -95,6 +97,7 @@ class ResponseProcessor:
self._verify_request_id()
self._verify_status()
self._verify_conditions()
def _decrypt_response(self):
"""Decrypt SAMLResponse EncryptedAssertion Element"""
@@ -126,6 +129,20 @@ class ResponseProcessor:
)
self._assertion = decrypted_assertion
def _verify_conditions(self):
conditions = self.get_assertion().find(f"{{{NS_SAML_ASSERTION}}}Conditions")
if conditions is None:
return
_now = now()
before = conditions.attrib.get("NotBefore")
if before:
if datetime.fromisoformat(before).replace(tzinfo=UTC) > _now:
raise SAMLException("Assertion is not valid yet or expired.")
on_or_after = conditions.attrib.get("NotOnOrAfter")
if on_or_after:
if datetime.fromisoformat(on_or_after).replace(tzinfo=UTC) < _now:
raise SAMLException("Assertion is not valid yet or expired.")
def _verify_signature(self, signature_node: _Element):
"""Verify a single signature node"""
xmlsec.tree.add_ids(self._root, ["ID"])
@@ -215,10 +232,9 @@ class ResponseProcessor:
user has an attribute that refers to our Source for cleanup. The user is also deleted
on logout and periodically."""
# Create a temporary User
name_id = self._get_name_id()
username = name_id.text
name_id_el, name_id = self._get_name_id()
# trim username to ensure it is max 150 chars
username = f"ak-{username[: USERNAME_MAX_LENGTH - 14]}-transient"
username = f"ak-{name_id[: USERNAME_MAX_LENGTH - 14]}-transient"
expiry = mktime(
(now() + timedelta_from_string(self._source.temporary_user_delete_after)).timetuple()
)
@@ -234,20 +250,18 @@ class ResponseProcessor:
},
path=self._source.get_user_path(),
)
LOGGER.debug("Created temporary user for NameID Transient", username=name_id.text)
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
user.set_unusable_password()
user.save()
UserSAMLSourceConnection.objects.create(
source=self._source, user=user, identifier=name_id.text
)
UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id)
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
identifier=str(name_id.text),
identifier=str(name_id),
user_info={
"root": self._root,
"assertion": self.get_assertion(),
"name_id": name_id,
"name_id": name_id_el,
},
policy_context={},
)
@@ -258,7 +272,7 @@ class ResponseProcessor:
return self._assertion
return self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
def _get_name_id(self) -> Element:
def _get_name_id(self) -> tuple[Element, str]:
"""Get NameID Element"""
assertion = self.get_assertion()
if assertion is None:
@@ -269,12 +283,11 @@ class ResponseProcessor:
name_id = subject.find(f"{{{NS_SAML_ASSERTION}}}NameID")
if name_id is None:
raise ValueError("NameID element not found")
return name_id
return name_id, "".join(name_id.itertext())
def _get_name_id_filter(self) -> dict[str, str]:
"""Returns the subject's NameID as a Filter for the `User`"""
name_id_el = self._get_name_id()
name_id = name_id_el.text
name_id_el, name_id = self._get_name_id()
if not name_id:
raise UnsupportedNameIDFormat("Subject's NameID is empty.")
_format = name_id_el.attrib["Format"]
@@ -295,26 +308,26 @@ class ResponseProcessor:
def prepare_flow_manager(self) -> SourceFlowManager:
"""Prepare flow plan depending on whether or not the user exists"""
name_id = self._get_name_id()
name_id_el, name_id = self._get_name_id()
# Sanity check, show a warning if NameIDPolicy doesn't match what we go
if self._source.name_id_policy != name_id.attrib["Format"]:
if self._source.name_id_policy != name_id_el.attrib["Format"]:
LOGGER.warning(
"NameID from IdP doesn't match our policy",
expected=self._source.name_id_policy,
got=name_id.attrib["Format"],
got=name_id_el.attrib["Format"],
)
# transient NameIDs are handled separately as they don't have to go through flows.
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
if name_id_el.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
return self._handle_name_id_transient()
return SAMLSourceFlowManager(
source=self._source,
request=self._http_request,
identifier=str(name_id.text),
identifier=str(name_id),
user_info={
"root": self._root,
"assertion": self.get_assertion(),
"name_id": name_id,
"name_id": name_id_el,
},
policy_context={
"saml_response": etree.tostring(self._root),

View File

@@ -4,6 +4,7 @@ from base64 import b64encode
from defusedxml.lxml import fromstring
from django.test import TestCase
from freezegun import freeze_time
from authentik.common.saml.constants import NS_SAML_ASSERTION
from authentik.core.tests.utils import RequestFactory, create_test_flow
@@ -34,6 +35,7 @@ class TestPropertyMappings(TestCase):
pre_authentication_flow=create_test_flow(),
)
@freeze_time("2022-10-14T14:15:00")
def test_user_base_properties(self):
"""Test user base properties"""
properties = self.source.get_base_user_properties(
@@ -61,6 +63,7 @@ class TestPropertyMappings(TestCase):
properties = self.source.get_base_group_properties(root=ROOT, group_id=group_id)
self.assertEqual(properties, {"name": group_id})
@freeze_time("2022-10-14T14:15:00")
def test_user_property_mappings(self):
"""Test user property mappings"""
self.source.user_property_mappings.add(
@@ -94,6 +97,7 @@ class TestPropertyMappings(TestCase):
},
)
@freeze_time("2022-10-14T14:15:00")
def test_group_property_mappings(self):
"""Test group property mappings"""
self.source.group_property_mappings.add(

View File

@@ -3,6 +3,7 @@
from base64 import b64encode
from django.test import TestCase
from freezegun import freeze_time
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
from authentik.crypto.models import CertificateKeyPair
@@ -46,6 +47,7 @@ class TestResponseProcessor(TestCase):
):
ResponseProcessor(self.source, request).parse()
@freeze_time("2022-10-14T14:15:00")
def test_success(self):
"""Test success"""
request = self.factory.post(
@@ -72,6 +74,7 @@ class TestResponseProcessor(TestCase):
},
)
@freeze_time("2022-10-14T14:16:40Z")
def test_success_with_status_message_and_detail(self):
"""Test success with StatusMessage and StatusDetail present (should not raise error)"""
request = self.factory.post(
@@ -88,6 +91,7 @@ class TestResponseProcessor(TestCase):
sfm = parser.prepare_flow_manager()
self.assertEqual(sfm.user_properties["username"], "jens@goauthentik.io")
@freeze_time("2022-10-14T14:16:40Z")
def test_error_with_message_and_detail(self):
"""Test error status with StatusMessage and StatusDetail includes both in error"""
request = self.factory.post(
@@ -105,6 +109,7 @@ class TestResponseProcessor(TestCase):
self.assertIn("User account is disabled", str(ctx.exception))
self.assertIn("Authentication failed", str(ctx.exception))
@freeze_time("2024-08-07T15:48:09.325Z")
def test_encrypted_correct(self):
"""Test encrypted"""
key = load_fixture("fixtures/encrypted-key.pem")
@@ -142,6 +147,7 @@ class TestResponseProcessor(TestCase):
with self.assertRaises(InvalidEncryption):
parser.parse()
@freeze_time("2022-10-14T14:16:40Z")
def test_verification_assertion(self):
"""Test verifying signature inside assertion"""
key = load_fixture("fixtures/signature_cert.pem")
@@ -164,6 +170,7 @@ class TestResponseProcessor(TestCase):
parser = ResponseProcessor(self.source, request)
parser.parse()
@freeze_time("2014-07-17T01:02:18Z")
def test_verification_assertion_duplicate(self):
"""Test verifying signature inside assertion, where the response has another assertion
before our signed assertion"""
@@ -186,9 +193,35 @@ class TestResponseProcessor(TestCase):
parser = ResponseProcessor(self.source, request)
parser.parse()
self.assertNotEqual(parser._get_name_id().text, "bad")
self.assertEqual(parser._get_name_id().text, "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
self.assertNotEqual(parser._get_name_id()[1], "bad")
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
@freeze_time("2022-10-14T14:15:00")
def test_name_id_comment(self):
"""Test comment in name ID"""
fixture = load_fixture("fixtures/response_signed_assertion_dup.xml")
fixture = fixture.replace(
"_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7",
"_ce3d2948b4cf20146dee0a0b3dd6f<!--x-->69b6cf86f62d7",
)
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(fixture.encode()).decode()},
)
parser = ResponseProcessor(self.source, request)
parser.parse()
self.assertEqual(parser._get_name_id()[1], "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7")
@freeze_time("2014-07-17T01:02:18Z")
def test_verification_response(self):
"""Test verifying signature inside response"""
key = load_fixture("fixtures/signature_cert.pem")
@@ -211,6 +244,7 @@ class TestResponseProcessor(TestCase):
parser = ResponseProcessor(self.source, request)
parser.parse()
@freeze_time("2024-01-18T06:20:48Z")
def test_verification_response_and_assertion(self):
"""Test verifying signature inside response and assertion"""
key = load_fixture("fixtures/signature_cert.pem")
@@ -257,6 +291,7 @@ class TestResponseProcessor(TestCase):
with self.assertRaisesMessage(InvalidSignature, ""):
parser.parse()
@freeze_time("2022-10-14T14:15:00")
def test_verification_no_signature(self):
"""Test rejecting response without signature when signed_assertion is True"""
key = load_fixture("fixtures/signature_cert.pem")
@@ -303,6 +338,7 @@ class TestResponseProcessor(TestCase):
with self.assertRaisesMessage(InvalidSignature, ""):
parser.parse()
@freeze_time("2025-10-30T05:45:47.619Z")
def test_signed_encrypted_response(self):
"""Test signed & encrypted response"""
verification_key = load_fixture("fixtures/signature_cert2.pem")
@@ -330,6 +366,7 @@ class TestResponseProcessor(TestCase):
parser = ResponseProcessor(self.source, request)
parser.parse()
@freeze_time("2026-01-21T14:23")
def test_transient(self):
"""Test SAML transient NameID"""
verification_key = load_fixture("fixtures/signature_cert2.pem")

View File

@@ -4,6 +4,7 @@ from base64 import b64encode
from django.test import RequestFactory, TestCase
from django.urls import reverse
from freezegun import freeze_time
from authentik.core.tests.utils import create_test_flow
from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan
@@ -26,6 +27,7 @@ class TestViews(TestCase):
pre_authentication_flow=create_test_flow(),
)
@freeze_time("2022-10-14T14:15:00")
def test_enroll(self):
"""Enroll"""
flow = create_test_flow()
@@ -52,6 +54,7 @@ class TestViews(TestCase):
plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN)
self.assertIsNotNone(plan)
@freeze_time("2022-10-14T14:15:00")
def test_enroll_redirect(self):
"""Enroll when attempting to access a provider"""
initial_redirect = f"http://{generate_id()}"

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,12 @@ from authentik.tenants.models import Tenant
class FlagJSONField(JSONDictField):
def to_internal_value(self, data: str):
flags = super().to_internal_value(data)
for flag in Flag.available(visibility="system", exclude_system=False):
flags[flag().key] = flag.get()
return flags
def to_representation(self, value: dict) -> dict:
new_value = value.copy()
for flag in Flag.available(exclude_system=False):
@@ -33,13 +39,10 @@ class FlagJSONField(JSONDictField):
def run_validators(self, value: dict):
super().run_validators(value)
for flag in Flag.available(exclude_system=False):
for flag in Flag.available():
_flag = flag()
if _flag.key not in value:
continue
if _flag.visibility == "system":
value.pop(_flag.key, None)
continue
flag_value = value.get(_flag.key)
flag_type = get_args(_flag.__orig_bases__[0])[0]
if flag_value and not isinstance(flag_value, flag_type):

View File

@@ -85,10 +85,30 @@ class TestLocalSettingsAPI(APITestCase):
"flags": {"tenants_test_flag_sys": 123},
},
)
print(response.content)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.flags, {})
self.assertEqual(self.tenant.flags, {"setup": False, "tenants_test_flag_sys": False})
def test_settings_flags_system_empty_put(self):
"""Test settings API"""
self.tenant.flags = {}
self.tenant.save()
class _TestFlag(Flag[bool], key="tenants_test_flag_sys"):
default = False
visibility = "system"
self.client.force_login(self.local_admin)
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={
"flags": {},
},
)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.flags, {"setup": False, "tenants_test_flag_sys": False})
def test_command(self):
self.tenant.flags = {}

View File

@@ -36,14 +36,10 @@ entries:
attrs:
order: 50
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
pending_user = None
if target_uuid and not is_self_service:
from authentik.core.models import User
pending_user = User.objects.filter(pk=target_uuid).first()
actor_uuid = str(getattr(http_request.user, "pk", ""))
pending_user = user if getattr(user, "is_authenticated", False) else None
target_uuid = str(getattr(pending_user, "pk", ""))
is_self_service = not target_uuid or target_uuid == actor_uuid
if is_self_service:
return (
"<p><strong>You are about to lock down your own account.</strong></p>"
@@ -63,14 +59,15 @@ entries:
from django.utils.html import escape
if pending_user:
email = escape(pending_user.email or pending_user.name or "No email")
user_html = f"<p><code>{escape(pending_user.username)}</code> ({email})</p>"
detail = pending_user.email or pending_user.name
user_html = f"<code>{escape(pending_user.username)}</code>"
if detail and detail != pending_user.username:
user_html = f"{user_html} ({escape(detail)})"
else:
user_html = "<p>the account selected when this one-time lockdown link was created</p>"
user_html = "the account selected when this one-time lockdown link was created"
return (
"<p><strong>You are about to lock down the following account:</strong></p>"
f"{user_html}"
f"<p><strong>You are about to lock down the following account:</strong> {user_html}</p>"
"<p>This is an emergency action for cutting off access to the account right away. "
"It does not lock the administrator who opened this page.</p>"
"<p><strong>This will immediately:</strong></p>"
@@ -99,9 +96,9 @@ entries:
attrs:
order: 100
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
actor_uuid = str(getattr(http_request.user, "pk", ""))
target_uuid = str(getattr(user, "pk", ""))
is_self_service = not target_uuid or target_uuid == actor_uuid
if is_self_service:
info = (
"Use this if you no longer trust your current password or sessions. "
@@ -134,9 +131,9 @@ entries:
attrs:
order: 200
placeholder: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
actor_uuid = str(getattr(http_request.user, "pk", ""))
target_uuid = str(getattr(user, "pk", ""))
is_self_service = not target_uuid or target_uuid == actor_uuid
if is_self_service:
return "Describe why you are locking your account..."
return "Describe why this account is being locked down..."
@@ -184,14 +181,10 @@ entries:
attrs:
order: 300
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
from django.utils.html import escape
from authentik.core.models import User
if target_uuid:
target = User.objects.filter(pk=target_uuid).first()
if target:
return f"<p><code>{escape(target.username)}</code> has been locked down.</p>"
if getattr(user, "is_authenticated", False):
return f"<p><code>{escape(user.username)}</code> has been locked down.</p>"
return "<p>The selected account has been locked down.</p>"
initial_value_expression: true
@@ -221,9 +214,9 @@ entries:
attrs:
name: default-account-lockdown-admin-policy
expression: |
target_uuid = (request.http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(request.user, "pk", "") or getattr(request.http_request.user, "pk", ""))
return bool(target_uuid) and target_uuid != current_user_uuid
actor_uuid = str(getattr(request.http_request.user, "pk", ""))
target_uuid = str(getattr(request.user, "pk", ""))
return bool(target_uuid) and target_uuid != actor_uuid
identifiers:
name: default-account-lockdown-admin-policy
id: admin-policy

View File

@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2026.5.0-rc1 Blueprint schema",
"title": "authentik 2026.5.0-rc2 Blueprint schema",
"required": [
"version",
"entries"
@@ -11203,7 +11203,8 @@
"type": "string",
"enum": [
"token",
"oauth"
"oauth",
"oauth_interactive"
],
"title": "Auth mode"
},

View File

@@ -1 +1 @@
2026.5.0-rc1
2026.5.0-rc2

View File

@@ -110,17 +110,6 @@ func (a *Application) getTraefikForwardUrl(r *http.Request) (*url.URL, error) {
// getNginxForwardUrl See https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl
func (a *Application) getNginxForwardUrl(r *http.Request) (*url.URL, error) {
ou := r.Header.Get("X-Original-URI")
if ou != "" {
// Turn this full URL into a relative URL
u := &url.URL{
Host: "",
Scheme: "",
Path: ou,
}
a.log.WithField("url", u.String()).Info("building forward URL from X-Original-URI")
return u, nil
}
h := r.Header.Get("X-Original-URL")
if len(h) < 1 {
return nil, errors.New("no forward URL found")

View File

@@ -5,10 +5,8 @@ import (
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
api "goauthentik.io/packages/client-go"
)
@@ -47,67 +45,6 @@ func TestForwardHandleNginx_Single_Headers(t *testing.T) {
assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect])
}
func TestForwardHandleNginx_Single_URI(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "https://foo.bar/outpost.goauthentik.io/auth/nginx", nil)
req.Header.Set("X-Original-URI", "/app")
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
s, _ := a.sessions.Get(req, a.SessionName())
assert.Equal(t, "/app", s.Values[constants.SessionRedirect])
}
func TestForwardHandleNginx_Single_Claims(t *testing.T) {
a := newTestApplication()
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil)
req.Header.Set("X-Original-URI", "/")
rr := httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
s, _ := a.sessions.Get(req, a.SessionName())
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = types.Claims{
Sub: "foo",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"username": "foo",
"password": "bar",
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.forwardHandleNginx(rr, req)
h := rr.Result().Header
assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"])
assert.Equal(t, []string{"bar"}, h["Foo"])
assert.Equal(t, []string{""}, h["User-Agent"])
assert.Equal(t, []string{""}, h["X-Authentik-Email"])
assert.Equal(t, []string{""}, h["X-Authentik-Groups"])
assert.Equal(t, []string{""}, h["X-Authentik-Jwt"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"])
assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"])
assert.Equal(t, []string{""}, h["X-Authentik-Name"])
assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"])
assert.Equal(t, []string{""}, h["X-Authentik-Username"])
}
func TestForwardHandleNginx_Domain_Blank(t *testing.T) {
a := newTestApplication()
a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr()

View File

@@ -38,6 +38,10 @@ function run_authentik {
echo cargo run -- "$@"
fi
;;
manage)
shift 1
echo python -m manage "$@"
;;
*)
echo "$@"
;;

View File

@@ -18,7 +18,7 @@ Parameters:
Description: authentik Docker image
AuthentikVersion:
Type: String
Default: 2026.5.0-rc1
Default: 2026.5.0-rc2
Description: authentik Docker image tag
AuthentikServerCPU:
Type: Number

View File

@@ -228,8 +228,7 @@ RUN apt-get update && \
# Required for runtime
apt-get install -y --no-install-recommends \
libpq5 libmaxminddb0 ca-certificates \
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
heimdal-multidev libkadm5clnt7t64-heimdal \
libkadm5clnt-mit12 libkadm5clnt7t64-heimdal \
libltdl7 libxslt1.1 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \

View File

@@ -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:-2026.5.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc2}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
@@ -53,7 +53,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:-2026.5.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.5.0-rc2}
restart: unless-stopped
shm_size: 512mb
user: root

View File

@@ -11,3 +11,4 @@ Naur
Wärting
Aadit
Kilby
Kahmen

View File

@@ -164,3 +164,4 @@ yamltags
zxcvbn
~uuid
~uuids
wreply

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2026.5.0-rc1",
"version": "2026.5.0-rc2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2026.5.0-rc1",
"version": "2026.5.0-rc2",
"dependencies": {
"@eslint/js": "^9.39.3",
"@goauthentik/eslint-config": "./packages/eslint-config",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2026.5.0-rc1",
"version": "2026.5.0-rc2",
"private": true,
"scripts": {
"lint": "run-s lint:spellcheck lint:lockfile",

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer("%5B", "[", "%5D", "]")
)
// APIClient manages communication with the authentik API v2026.5.0-rc1
// APIClient manages communication with the authentik API v2026.5.0-rc2
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

View File

@@ -3,7 +3,7 @@ authentik
Making authentication simple.
API version: 2026.5.0-rc1
API version: 2026.5.0-rc2
Contact: hello@goauthentik.io
*/

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