Compare commits

...

96 Commits

Author SHA1 Message Date
Connor Peshek
ef3d795cc2 clean up 2026-04-29 04:09:54 -05:00
Connor Peshek
2cd89b0ab0 update to main 2026-04-28 20:52:24 -05:00
Connor Peshek
a2ca19d718 providers/saml: generate issuer url when provider is set on app (#18022)
* providers/saml: generate issuer url in saml processors unless overridded

* remove issuer

* remove duplicate

* Generate url when assertion is created and save to session

* cleanup

* Fix front-end rendering of issuer

* Update web/src/admin/providers/saml/SAMLProviderViewPage.ts

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* Update authentik/providers/saml/models.py

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* Update authentik/providers/saml/models.py

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* use reverse for urls and update tests

* update issuer description

* Don't absorb sp entity id

* rename issuer_url to issuer_override

* fix migration file to rename to override

* fix migration file order

* lint, fix tests

* fix tests

* fix once again not importing the sp issuer

* build

* use const for default issuer

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-28 17:31:12 -05:00
Marc 'risson' Schmitt
aed634734b root: fix rust build with uv-installed Python (#21858) 2026-04-28 18:11:22 +02:00
Marcelo Elizeche Landó
05005f4eb9 core: add support for hiding applications from the user dashboard (#21530)
* Add meta_hide field to hide apps

* exclude hidden applications from user dashboard

* Add the hide option to the UI

* Add schema

* Add hide setting to application wizard

* Add typescript client changes

* fix linting

* Convert blank://blank to meta_hide=True in the migration

* fix tests

* update docs

* fix continuous login

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

* Apply suggestions from code review

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* fix linting

* fix migrations

* Apply suggestions from code review

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* rename all mentions of dashboard to My applications

* generate schema

* generate TS client

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-04-28 13:05:56 -03:00
dependabot[bot]
baf61056c7 core: bump ruff from 0.15.11 to 0.15.12 (#21871)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.11 to 0.15.12.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.11...0.15.12)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:development
  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-04-28 16:36:23 +02:00
Marc 'risson' Schmitt
e4b0ea7d15 packages/ak-axum/router: add X-Powered-By to all responses (#21940) 2026-04-28 15:35:17 +02:00
Marcelo Elizeche Landó
740a5b85e3 core: bump microsoft-kiota-serialization-form from 1.9.8 to v1.10.1 (#21909) 2026-04-28 13:12:37 +00:00
dependabot[bot]
8fd17966ab core: bump pytest-randomly from 4.0.1 to 4.1.0 (#21873)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 13:09:34 +00:00
Ryan Pesek
e63ff698da core: users/groups reduce number of database queries (#20431)
* reduce number of db queries

* optimize group membership updates too

* further optimize include_user=false and also members_by_pk

* lint

---------

Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2026-04-28 13:00:38 +00:00
dependabot[bot]
7d0ec4de23 core: bump types-channels from 4.3.0.20260408 to 4.3.0.20260421 (#21872)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 14:09:08 +02:00
dependabot[bot]
b2b5f6400d ci: bump taiki-e/install-action from 2.75.21 to 2.75.22 in /.github/actions/setup (#21877)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 14:08:44 +02:00
authentik-automation[bot]
94ce30adb5 core, web: update translations (#21870)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-04-28 14:08:06 +02:00
slavb18
52c573bfe2 sources/oauth: ensure user ID is returned as str (#21880) 2026-04-28 14:07:37 +02:00
transifex-integration[bot]
74e2c63888 translate: Updates for project authentik and language no_NO (#21862)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-04-28 14:06:17 +02:00
Marcelo Elizeche Landó
b390b679b7 core: bump maxminddb from 3.0.0 to v3.1.1 (#21907) 2026-04-28 13:57:40 +02:00
Marcelo Elizeche Landó
501b851f3f core: bump prometheus-client from 0.24.0 to v0.25.0 (#21919) 2026-04-28 13:57:03 +02:00
Marcelo Elizeche Landó
f75a03e4ba core: bump azure-identity from 1.25.1 to v1.25.3 (#21886) 2026-04-28 13:56:37 +02:00
Marcelo Elizeche Landó
95a7d8c92d core: bump aiohttp from 3.13.4 to v3.13.5 (#21882) 2026-04-28 13:56:24 +02:00
Marcelo Elizeche Landó
27d1be0b85 core: bump anyio from 4.12.1 to v4.13.0 (#21883) 2026-04-28 13:56:21 +02:00
Marcelo Elizeche Landó
7ee653cf0a core: bump asgiref from 3.11.0 to v3.11.1 (#21884) 2026-04-28 13:56:18 +02:00
Marcelo Elizeche Landó
079017c799 core: bump azure-core from 1.38.0 to v1.39.0 (#21885) 2026-04-28 13:56:16 +02:00
Marcelo Elizeche Landó
d647976b98 core: bump blessed from 1.25.0 to v1.38.0 (#21887) 2026-04-28 13:56:09 +02:00
Marcelo Elizeche Landó
46862cca22 core: bump boto3 from 1.42.26 to v1.42.97 (#21888) 2026-04-28 13:56:07 +02:00
Marcelo Elizeche Landó
edce545f0d core: bump certifi from 2026.1.4 to v2026.4.22 (#21889) 2026-04-28 13:56:03 +02:00
Marcelo Elizeche Landó
028b711746 core: bump charset-normalizer from 3.4.4 to v3.4.7 (#21890) 2026-04-28 13:56:00 +02:00
Marcelo Elizeche Landó
fa18e71ca4 core: bump microsoft-kiota-serialization-text from 1.9.8 to v1.9.10 (#21912) 2026-04-28 13:54:05 +02:00
Marcelo Elizeche Landó
1fd472c0d9 core: bump librt from 0.8.1 to v0.9.0 (#21905) 2026-04-28 13:54:02 +02:00
Marcelo Elizeche Landó
b4adb7ee70 core: bump jsii from 1.127.0 to v1.128.0 (#21903) 2026-04-28 13:53:59 +02:00
Marcelo Elizeche Landó
98f6726d95 core: bump click from 8.3.1 to v8.3.3 (#21891) 2026-04-28 13:53:46 +02:00
Marcelo Elizeche Landó
ced8db0b65 core: bump django-stubs-ext from 6.0.2 to v6.0.3 (#21892) 2026-04-28 13:53:43 +02:00
Marcelo Elizeche Landó
d8e575c631 core: bump google-api-core from 2.29.0 to v2.30.3 (#21893) 2026-04-28 13:53:40 +02:00
Marcelo Elizeche Landó
b7c03362a3 core: bump google-auth from 2.47.0 to v2.49.2 (#21894) 2026-04-28 13:53:37 +02:00
Marcelo Elizeche Landó
1ca4a34da7 core: bump google-auth-httplib2 from 0.3.0 to v0.3.1 (#21895) 2026-04-28 13:53:34 +02:00
Marcelo Elizeche Landó
94c3686065 core: bump googleapis-common-protos from 1.72.0 to v1.74.0 (#21896) 2026-04-28 13:53:31 +02:00
Marcelo Elizeche Landó
3b61bf04d2 core: bump greenlet from 3.3.0 to v3.5.0 (#21897) 2026-04-28 13:53:28 +02:00
Marcelo Elizeche Landó
e310468a6e core: bump httplib2 from 0.31.1 to v0.31.2 (#21898) 2026-04-28 13:53:25 +02:00
Marcelo Elizeche Landó
0947d38f0b core: bump idna from 3.11 to v3.13 (#21899) 2026-04-28 13:53:22 +02:00
Marcelo Elizeche Landó
f207491cf6 core: bump importlib-resources from 6.5.2 to v7.1.0 (#21900) 2026-04-28 13:53:18 +02:00
Marcelo Elizeche Landó
83294f4866 core: bump invoke from 2.2.1 to v3.0.3 (#21901) 2026-04-28 13:53:15 +02:00
Marcelo Elizeche Landó
4af2d51f50 core: bump jmespath from 1.0.1 to v1.1.0 (#21902) 2026-04-28 13:53:12 +02:00
Marcelo Elizeche Landó
87bd0d7436 core: bump jsonpointer from 3.0.0 to v3.1.1 (#21904) 2026-04-28 13:53:05 +02:00
Marcelo Elizeche Landó
cf0c2881b1 core: bump markdown2 from 2.5.4 to v2.5.5 (#21906) 2026-04-28 13:52:58 +02:00
Marcelo Elizeche Landó
9fe96b6e82 core: bump microsoft-kiota-abstractions from 1.9.8 to v1.9.10 (#21908) 2026-04-28 13:52:55 +02:00
Marcelo Elizeche Landó
48084c0051 core: bump microsoft-kiota-serialization-json from 1.9.8 to v1.9.10 (#21910) 2026-04-28 13:52:48 +02:00
Marcelo Elizeche Landó
1c057517c2 core: bump microsoft-kiota-serialization-multipart from 1.9.8 to v1.9.10 (#21911) 2026-04-28 13:52:45 +02:00
Marcelo Elizeche Landó
a529f2be86 core: bump msal from 1.34.0 to v1.36.0 (#21913) 2026-04-28 13:52:38 +02:00
Marcelo Elizeche Landó
9fdad4d686 core: bump platformdirs from 4.5.1 to v4.9.6 (#21918) 2026-04-28 13:51:55 +02:00
Marcelo Elizeche Landó
d56ff32732 core: bump requests from 2.33.0 to v2.33.1 (#21924) 2026-04-28 13:51:38 +02:00
Marcelo Elizeche Landó
95dd492555 core: bump trio from 0.32.0 to v0.33.0 (#21930) 2026-04-28 13:51:24 +02:00
Marcelo Elizeche Landó
033668373d core: bump multidict from 6.7.0 to v6.7.1 (#21914) 2026-04-28 13:51:21 +02:00
Marcelo Elizeche Landó
c358a4a6e5 core: bump s3transfer from 0.16.0 to v0.16.1 (#21926) 2026-04-28 13:51:07 +02:00
Marcelo Elizeche Landó
565f5cf9c1 core: bump zipp from 3.23.0 to v3.23.1 (#21937) 2026-04-28 13:50:54 +02:00
Marcelo Elizeche Landó
f4807135e5 core: bump opentelemetry-api from 1.39.1 to v1.41.1 (#21915) 2026-04-28 13:50:46 +02:00
Marcelo Elizeche Landó
a96445cdf8 core: bump orjson from 3.11.6 to v3.11.8 (#21916) 2026-04-28 13:50:43 +02:00
Marcelo Elizeche Landó
858ac8d5ff core: bump pathspec from 1.0.3 to v1.1.1 (#21917) 2026-04-28 13:50:38 +02:00
Marcelo Elizeche Landó
fb060d89af core: bump proto-plus from 1.27.0 to v1.27.2 (#21920) 2026-04-28 13:50:32 +02:00
Marcelo Elizeche Landó
682ed056dd core: bump protobuf from 6.33.5 to v6.33.6 (#21921) 2026-04-28 13:50:29 +02:00
Marcelo Elizeche Landó
be2cba2068 core: bump pycparser from 2.23 to v3.0 (#21922) 2026-04-28 13:50:26 +02:00
Marcelo Elizeche Landó
5bb8a1e341 core: bump pyparsing from 3.3.1 to v3.3.2 (#21923) 2026-04-28 13:50:22 +02:00
Marcelo Elizeche Landó
8cbe1bfdd7 core: bump rich from 14.2.0 to v15.0.0 (#21925) 2026-04-28 13:50:15 +02:00
Marcelo Elizeche Landó
93d615f0f4 core: bump setuptools from 80.9.0 to v82.0.1 (#21927) 2026-04-28 13:50:09 +02:00
Marcelo Elizeche Landó
17cdb82f15 core: bump stevedore from 5.6.0 to v5.7.0 (#21928) 2026-04-28 13:50:05 +02:00
Marcelo Elizeche Landó
007fa940d9 core: bump tenacity from 9.1.2 to v9.1.4 (#21929) 2026-04-28 13:50:02 +02:00
Marcelo Elizeche Landó
29e82d4985 core: bump types-paramiko from 4.0.0.20250822 to v4.0.0.20260408 (#21931) 2026-04-28 13:49:54 +02:00
Marcelo Elizeche Landó
f2fd092e8a core: bump types-pyasn1 from 0.6.0.20250914 to v0.6.0.20260408 (#21932) 2026-04-28 13:49:51 +02:00
Marcelo Elizeche Landó
3f179a25d7 core: bump types-pyyaml from 6.0.12.20250915 to v6.0.12.20260408 (#21933) 2026-04-28 13:49:48 +02:00
Marcelo Elizeche Landó
eba970dd03 core: bump ua-parser-builtins from 202601 to v202603 (#21934) 2026-04-28 13:49:44 +02:00
Marcelo Elizeche Landó
21447c461c core: bump wcwidth from 0.2.14 to v0.6.0 (#21935) 2026-04-28 13:49:41 +02:00
Marcelo Elizeche Landó
96c203757c core: bump yarl from 1.22.0 to v1.23.0 (#21936) 2026-04-28 13:49:38 +02:00
Marcelo Elizeche Landó
61b345e577 core: bump zope-interface from 8.2 to v8.4 (#21938) 2026-04-28 13:49:30 +02:00
Marc 'risson' Schmitt
2a027264b3 packages/ak-axum/accept/catch_panic: add acceptor to catch panics in lower acceptors, streams and services (#21860) 2026-04-27 16:40:50 +00:00
Félix MARQUET
fe4a7d2c5f website/integrations: update jellyseerr to seerr (#21855)
web: update jellyseerr doc to seerr
2026-04-27 15:08:08 +00:00
Marc 'risson' Schmitt
71af5e40a3 lifecycle/container: only mount required packages directories (#21859) 2026-04-27 17:00:05 +02:00
Marc 'risson' Schmitt
3e75278052 packages/ak-common/config: fix string load broken after previous fix (#21854) 2026-04-27 14:03:55 +00:00
Dominic R
620387f294 providers/scim: fix vCenter compatibility mode (#21830) 2026-04-27 12:00:00 +00:00
dependabot[bot]
9dfc4e76ee web: bump type-fest from 5.5.0 to 5.6.0 in /web (#21841)
Bumps [type-fest](https://github.com/sindresorhus/type-fest) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/sindresorhus/type-fest/releases)
- [Commits](https://github.com/sindresorhus/type-fest/compare/v5.5.0...v5.6.0)

---
updated-dependencies:
- dependency-name: type-fest
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 13:47:33 +02:00
dependabot[bot]
85f0ab899e web: bump the bundler group across 1 directory with 3 updates (#21839)
Bumps the bundler group with 1 update in the /web directory: [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser).


Updates `@vitest/browser` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/browser-playwright)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: bundler
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: bundler
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 13:47:01 +02:00
dependabot[bot]
72c76bb95b core: bump msgraph-sdk from 1.55.0 to 1.56.0 (#21836)
Bumps [msgraph-sdk](https://github.com/microsoftgraph/msgraph-sdk-python) from 1.55.0 to 1.56.0.
- [Release notes](https://github.com/microsoftgraph/msgraph-sdk-python/releases)
- [Changelog](https://github.com/microsoftgraph/msgraph-sdk-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoftgraph/msgraph-sdk-python/compare/v1.55.0...v1.56.0)

---
updated-dependencies:
- dependency-name: msgraph-sdk
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 12:46:52 +01:00
dependabot[bot]
9ea465441b core: bump cryptography from 46.0.7 to 47.0.0 (#21837)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7 to 47.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...47.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 47.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 12:46:48 +01:00
dependabot[bot]
3c9d682eb3 core: bump mypy from 1.20.1 to 1.20.2 (#21838)
Bumps [mypy](https://github.com/python/mypy) from 1.20.1 to 1.20.2.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.20.1...v1.20.2)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.20.2
  dependency-type: direct:development
  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-04-27 12:46:43 +01:00
dependabot[bot]
32de314485 core: bump library/golang from 982ae92 to 4a7137e in /lifecycle/container (#21840)
core: bump library/golang in /lifecycle/container

Bumps library/golang from `982ae92` to `4a7137e`.

---
updated-dependencies:
- dependency-name: library/golang
  dependency-version: 1.26.2-trixie
  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-04-27 12:46:38 +01:00
dependabot[bot]
3a9211f248 web: bump dompurify from 3.4.0 to 3.4.1 in /web (#21843)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.1)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.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-04-27 12:46:34 +01:00
dependabot[bot]
26cfdf59c9 ci: bump int128/docker-manifest-create-action from 2.18.0 to 2.19.0 (#21844)
Bumps [int128/docker-manifest-create-action](https://github.com/int128/docker-manifest-create-action) from 2.18.0 to 2.19.0.
- [Release notes](https://github.com/int128/docker-manifest-create-action/releases)
- [Commits](3de37de96c...7df7f9e221)

---
updated-dependencies:
- dependency-name: int128/docker-manifest-create-action
  dependency-version: 2.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 12:46:30 +01:00
dependabot[bot]
b6f9013977 ci: bump taiki-e/install-action from 2.75.19 to 2.75.21 in /.github/actions/setup (#21845)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.19 to 2.75.21.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](5f57d6cb7c...787505cde8)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.21
  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-04-27 12:46:26 +01:00
dependabot[bot]
3133f8cbda core: bump hyper-unix-socket from 0.3.0 to 0.6.1 (#21846)
Bumps [hyper-unix-socket](https://github.com/kristof-mattei/hyper-unix-socket) from 0.3.0 to 0.6.1.
- [Release notes](https://github.com/kristof-mattei/hyper-unix-socket/releases)
- [Changelog](https://github.com/kristof-mattei/hyper-unix-socket/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kristof-mattei/hyper-unix-socket/compare/v0.3.0...v0.6.1)

---
updated-dependencies:
- dependency-name: hyper-unix-socket
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 12:46:22 +01:00
Jens L.
b66024f26f web/packages: Rework SFE rendering (#21833)
* rework sfe to use old lit

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

* rework and cleanup some more

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-04-27 13:38:19 +02:00
Jens L.
8f1bdc01b6 providers/oauth2: Configure allowed grant types (#20363)
* naming cleanup

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

* add

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

* adjust defaults, start adding tests

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

* more tests

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

* fix tests

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

* fix tests

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

* gen

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

* fix proxy

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

* add UI

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

* attempt to fix e2e

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

* allow refresh token for conformance

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

* fix e2e

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-04-27 13:36:57 +02:00
Marc 'risson' Schmitt
5c3cd2c6ed packages/ak-common/config: fix boolean parsing from env variable (#21835) 2026-04-27 12:53:47 +02:00
Connor Peshek
1e68fc887a Merge branch 'main' into saml-endpoints 2026-03-12 21:44:21 -05:00
Connor Peshek
f60a441435 fix for tests when sp init login 2026-02-25 17:53:38 -06:00
Connor Peshek
f207fdfed0 Merge branch 'main' into saml-endpoints 2026-02-25 17:23:28 -06:00
Connor Peshek
1137924e49 Merge branch 'main' into saml-endpoints 2026-02-06 23:06:49 -06:00
Connor Peshek
4cb40fee4b fix saml parsing 2026-02-05 17:42:12 -06:00
Connor Peshek
649a4e57c2 Merge branch 'main' into saml-endpoints 2026-02-04 16:27:45 -06:00
Connor Peshek
ca03d81bd9 providers/saml: make unified saml endpoint 2026-02-04 15:57:12 -06:00
139 changed files with 20649 additions and 1032 deletions

View File

@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 # v2
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)

View File

@@ -90,7 +90,7 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@3de37de96c4e900bc3eef9055d3c50abdb4f769d # v2
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # v2
id: build
with:
tags: ${{ matrix.tag }}

View File

@@ -14,6 +14,7 @@ pyproject.toml @goauthentik/backend
uv.lock @goauthentik/backend
Cargo.toml @goauthentik/backend
Cargo.lock @goauthentik/backend
build.rs @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
.cargo/ @goauthentik/backend

6
Cargo.lock generated
View File

@@ -198,6 +198,7 @@ dependencies = [
"metrics-exporter-prometheus",
"nix 0.31.2",
"pyo3",
"pyo3-build-config",
"sqlx",
"tokio",
"tracing",
@@ -216,6 +217,7 @@ dependencies = [
"eyre",
"forwarded-header-value",
"futures",
"pin-project-lite",
"tokio",
"tokio-rustls",
"tower",
@@ -1505,9 +1507,9 @@ dependencies = [
[[package]]
name = "hyper-unix-socket"
version = "0.3.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c255628da188a9d9ee218bae99da33a4b684ed63abe140a94d0f6e4b5af9a090"
checksum = "88978f1d73da0eb87d86555fcc40cbdd87bc86eb6525710b89db8c9278ec6a59"
dependencies = [
"bytes",
"hyper",

View File

@@ -39,7 +39,7 @@ eyre = "= 0.6.12"
forwarded-header-value = "= 0.1.1"
futures = "= 0.3.32"
glob = "= 0.3.3"
hyper-unix-socket = "= 0.3.0"
hyper-unix-socket = "= 0.6.1"
hyper-util = "= 0.1.20"
ipnet = { version = "= 2.12.0", features = ["serde"] }
json-subscriber = "= 0.2.8"
@@ -49,6 +49,7 @@ nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
notify = "= 8.2.0"
pin-project-lite = "= 0.2.17"
pyo3 = "= 0.28.3"
pyo3-build-config = "= 0.28.3"
regex = "= 1.12.3"
reqwest = { version = "= 0.13.2", features = [
"form",
@@ -260,6 +261,9 @@ default = ["core", "proxy"]
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
proxy = ["ak-common/proxy"]
[build-dependencies]
pyo3-build-config.workspace = true
[dependencies]
ak-axum.workspace = true
ak-common.workspace = true

View File

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
GRANT_TYPE_IMPLICIT = "implicit"
GRANT_TYPE_HYBRID = "hybrid"
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
GRANT_TYPE_PASSWORD = "password" # nosec

View File

@@ -30,6 +30,8 @@ SAML_BINDING_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
DEFAULT_ISSUER = "authentik"
DSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"
RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
# https://datatracker.ietf.org/doc/html/rfc4051#section-2.3.2

View File

@@ -4,7 +4,7 @@ from collections.abc import Iterator
from copy import copy
from django.core.cache import cache
from django.db.models import Case, Q, QuerySet
from django.db.models import Case, QuerySet
from django.db.models.expressions import When
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
@@ -120,6 +120,7 @@ class ApplicationSerializer(ModelSerializer):
"meta_publisher",
"policy_engine_mode",
"group",
"meta_hide",
]
extra_kwargs = {
"backchannel_providers": {"required": False},
@@ -283,14 +284,12 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
) == "true"
queryset = self._filter_queryset_for_list(self.get_queryset())
queryset = queryset.exclude(meta_hide=True)
if only_with_launch_url:
# Pre-filter at DB level to skip expensive per-app policy evaluation
# for apps that can never appear in the launcher:
# - No meta_launch_url AND no provider: no possible launch URL
# - meta_launch_url="blank://blank": documented convention to hide from launcher
queryset = queryset.exclude(
Q(meta_launch_url="", provider__isnull=True) | Q(meta_launch_url="blank://blank")
)
# for apps that can never appear in the launcher (no meta_launch_url
# and no provider, so no possible launch URL).
queryset = queryset.exclude(meta_launch_url="", provider__isnull=True)
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)

View File

@@ -19,7 +19,7 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError
@@ -37,6 +37,77 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
class BulkManyRelatedField(ManyRelatedField):
"""ManyRelatedField that validates all PKs in a single query instead of one per PK."""
def to_internal_value(self, data):
if isinstance(data, str) or not hasattr(data, "__iter__"):
self.fail("not_a_list", input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail("empty")
child = self.child_relation
pk_field = child.pk_field
# Coerce PKs through pk_field if defined
pk_map = {}
for item in data:
if isinstance(item, bool):
self.fail("incorrect_type", data_type=type(item).__name__)
pk = pk_field.to_internal_value(item) if pk_field else item
pk_map[pk] = item # map coerced PK -> original value for error reporting
queryset = child.get_queryset()
# Use count to validate all PKs exist in a single query
found_count = queryset.filter(pk__in=pk_map.keys()).count()
if found_count < len(pk_map):
# Some PKs not found — fall back to per-PK checks for error reporting.
# This only runs when there's an actual validation error (rare path).
for pk, original in pk_map.items():
if not queryset.filter(pk=pk).exists():
child.fail("does_not_exist", pk_value=original)
# Return raw PKs — Django's M2M set() accepts both objects and PKs,
# using get_prep_value() for type coercion. This avoids loading all
# objects into memory and avoids triggering post_init signals.
return list(pk_map.keys())
def to_representation(self, iterable):
# For non-prefetched querysets, get PKs directly without loading model instances.
# When prefetched, _result_cache is a list (possibly empty); when not, it's None.
if hasattr(iterable, "values_list") and getattr(iterable, "_result_cache", None) is None:
return list(iterable.values_list("pk", flat=True))
return super().to_representation(iterable)
class BulkPrimaryKeyRelatedField(PrimaryKeyRelatedField):
"""PrimaryKeyRelatedField that uses bulk validation when many=True."""
@classmethod
def many_init(cls, *args, **kwargs):
allow_empty = kwargs.pop("allow_empty", None)
max_length = kwargs.pop("max_length", None)
min_length = kwargs.pop("min_length", None)
child_relation = cls(*args, **kwargs)
list_kwargs = {
"child_relation": child_relation,
}
if allow_empty is not None:
list_kwargs["allow_empty"] = allow_empty
if max_length is not None:
list_kwargs["max_length"] = max_length
if min_length is not None:
list_kwargs["min_length"] = min_length
list_kwargs.update(
{
key: value
for key, value in kwargs.items()
if key in ("required", "default", "source")
}
)
return BulkManyRelatedField(**list_kwargs)
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
"pk",
"username",
@@ -79,6 +150,7 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users = BulkPrimaryKeyRelatedField(queryset=User.objects.all(), many=True, default=list)
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
parents_obj = SerializerMethodField(allow_null=True)
children_obj = SerializerMethodField(allow_null=True)
@@ -193,9 +265,6 @@ class GroupSerializer(ModelSerializer):
"children_obj",
]
extra_kwargs = {
"users": {
"default": list,
},
"children": {
"required": False,
"default": list,
@@ -225,6 +294,7 @@ class GroupFilter(FilterSet):
members_by_pk = ModelMultipleChoiceFilter(
field_name="users",
queryset=User.objects.all(),
distinct=False,
)
def filter_attributes(self, queryset, name, value):
@@ -276,7 +346,8 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
]
def get_queryset(self):
base_qs = Group.objects.all().prefetch_related("roles")
# Always prefetch parents and children since their PKs are always serialized
base_qs = Group.objects.all().prefetch_related("roles", "parents", "children")
if self.serializer_class(context={"request": self.request})._should_include_users:
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
@@ -287,16 +358,9 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
)
)
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))
)
if self.serializer_class(context={"request": self.request})._should_include_children:
base_qs = base_qs.prefetch_related("children")
if self.serializer_class(context={"request": self.request})._should_include_parents:
base_qs = base_qs.prefetch_related("parents")
# When include_users=false, skip users prefetch entirely.
# BulkManyRelatedField.to_representation will use values_list to get PKs
# directly without loading User instances into memory.
return base_qs

View File

@@ -6,6 +6,7 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import AnonymousUser, Permission
from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django.urls import reverse_lazy
@@ -131,7 +132,7 @@ class PartialGroupSerializer(ModelSerializer):
class UserSerializer(ModelSerializer):
"""User Serializer"""
is_superuser = BooleanField(read_only=True)
is_superuser = SerializerMethodField()
avatar = SerializerMethodField()
attributes = JSONDictField(required=False)
groups = PrimaryKeyRelatedField(
@@ -168,6 +169,14 @@ class UserSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_roles", "true")).lower() == "true"
@extend_schema_field(BooleanField)
def get_is_superuser(self, instance: User) -> bool:
"""Use annotation if available to avoid N+1 query"""
ann = getattr(instance, "_annotated_is_superuser", None)
if ann is not None:
return ann
return instance.is_superuser
@extend_schema_field(PartialGroupSerializer(many=True))
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
if not self._should_include_groups:
@@ -541,10 +550,30 @@ class UserViewSet(
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
# Always prefetch groups since group PKs are always serialized.
# Use full prefetch when include_groups=true (for groups_obj), ID-only otherwise.
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("groups")
else:
base_qs = base_qs.prefetch_related(
Prefetch("groups", queryset=Group.objects.all().only("group_uuid"))
)
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
else:
base_qs = base_qs.prefetch_related(
Prefetch("roles", queryset=Role.objects.all().only("uuid"))
)
# Annotate is_superuser to avoid N+1 query per user
base_qs = base_qs.annotate(
_annotated_is_superuser=Exists(
Group.objects.filter(
is_superuser=True,
).filter(
Q(users=OuterRef("pk")) | Q(descendant_nodes__descendant__users=OuterRef("pk"))
)
)
)
return base_qs
@extend_schema(

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.12 on 2026-04-09 18:04
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_blank_launch_url(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Application = apps.get_model("authentik_core", "Application")
Application.objects.using(db_alias).filter(meta_launch_url="blank://blank").update(
meta_hide=True, meta_launch_url=""
)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0058_setup"),
]
operations = [
migrations.AddField(
model_name="application",
name="meta_hide",
field=models.BooleanField(
default=False,
help_text="Hide this application from the user's My applications page.",
),
),
migrations.RunPython(migrate_blank_launch_url, migrations.RunPython.noop),
]

View File

@@ -735,6 +735,9 @@ class Application(SerializerModel, PolicyBindingModel):
meta_icon = FileField(default="", blank=True)
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
meta_hide = models.BooleanField(
default=False, help_text=_("Hide this application from the user's My applications page.")
)
objects = ApplicationQuerySet.as_manager()

View File

@@ -129,6 +129,7 @@ class TestApplicationsAPI(APITestCase):
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_hide": False,
"meta_publisher": "",
"policy_engine_mode": "any",
},
@@ -187,12 +188,14 @@ class TestApplicationsAPI(APITestCase):
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_hide": False,
"meta_publisher": "",
"policy_engine_mode": "any",
},
{
"launch_url": None,
"meta_description": "",
"meta_hide": False,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,

View File

@@ -65,6 +65,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
fields = ProviderSerializer.Meta.fields + [
"authorization_flow",
"client_type",
"grant_types",
"client_id",
"client_secret",
"access_code_validity",

View File

@@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from authentik.events.models import Event, EventAction
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.views import bad_request_message
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
from authentik.providers.oauth2.models import GrantType, RedirectURI
class OAuth2Error(SentryIgnoredException):
@@ -182,7 +182,7 @@ class AuthorizeError(OAuth2Error):
# See:
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
fragment_or_query = (
"#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
"#" if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID] else "?"
)
uri = (
@@ -225,7 +225,7 @@ class TokenError(OAuth2Error):
),
}
def __init__(self, error):
def __init__(self, error: str):
super().__init__()
self.error = error
self.description = self.errors[error]

View File

@@ -0,0 +1,69 @@
# Generated by Django 5.2.11 on 2026-02-17 11:04
import django.contrib.postgres.fields
from django.db import migrations, models
def migrate_default_grant_types():
from authentik.providers.oauth2.models import GrantType
return [
GrantType.AUTHORIZATION_CODE,
GrantType.HYBRID,
GrantType.IMPLICIT,
GrantType.CLIENT_CREDENTIALS,
GrantType.PASSWORD,
GrantType.DEVICE_CODE,
GrantType.REFRESH_TOKEN,
]
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_oauth2",
"0031_remove_oauth2provider_backchannel_logout_uri_and_more",
),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="grant_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
("authorization_code", "Authorization Code"),
("implicit", "Implicit"),
("hybrid", "Hybrid"),
("refresh_token", "Refresh Token"),
("client_credentials", "Client Credentials"),
("password", "Password"),
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
]
),
default=migrate_default_grant_types,
size=None,
),
),
migrations.AlterField(
model_name="oauth2provider",
name="grant_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
("authorization_code", "Authorization Code"),
("implicit", "Implicit"),
("hybrid", "Hybrid"),
("refresh_token", "Refresh Token"),
("client_credentials", "Client Credentials"),
("password", "Password"),
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
]
),
default=list,
size=None,
),
),
]

View File

@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from dacite import Config
from dacite.core import from_dict
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import HashIndex
from django.db import models
from django.http import HttpRequest
@@ -33,7 +34,16 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.brands.models import WebfingerProvider
from authentik.common.oauth.constants import SubModes
from authentik.common.oauth.constants import (
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
GRANT_TYPE_DEVICE_CODE,
GRANT_TYPE_HYBRID,
GRANT_TYPE_IMPLICIT,
GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN,
SubModes,
)
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
@@ -58,7 +68,7 @@ def generate_client_secret() -> str:
return generate_id(128)
class ClientTypes(models.TextChoices):
class ClientType(models.TextChoices):
"""Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable."""
@@ -66,12 +76,16 @@ class ClientTypes(models.TextChoices):
PUBLIC = "public", _("Public")
class GrantTypes(models.TextChoices):
class GrantType(models.TextChoices):
"""OAuth2 Grant types we support"""
AUTHORIZATION_CODE = "authorization_code"
IMPLICIT = "implicit"
HYBRID = "hybrid"
AUTHORIZATION_CODE = GRANT_TYPE_AUTHORIZATION_CODE
IMPLICIT = GRANT_TYPE_IMPLICIT
HYBRID = GRANT_TYPE_HYBRID
REFRESH_TOKEN = GRANT_TYPE_REFRESH_TOKEN
CLIENT_CREDENTIALS = GRANT_TYPE_CLIENT_CREDENTIALS
PASSWORD = GRANT_TYPE_PASSWORD
DEVICE_CODE = GRANT_TYPE_DEVICE_CODE
class ResponseMode(models.TextChoices):
@@ -188,14 +202,15 @@ class OAuth2Provider(WebfingerProvider, Provider):
client_type = models.CharField(
max_length=30,
choices=ClientTypes.choices,
default=ClientTypes.CONFIDENTIAL,
choices=ClientType.choices,
default=ClientType.CONFIDENTIAL,
verbose_name=_("Client Type"),
help_text=_(
"Confidential clients are capable of maintaining the confidentiality "
"of their credentials. Public clients are incapable"
),
)
grant_types = ArrayField(models.TextField(choices=GrantType.choices), default=list)
client_id = models.CharField(
max_length=255,
unique=True,

View File

@@ -22,7 +22,7 @@ from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, Red
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantTypes,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -41,12 +41,34 @@ class TestAuthorize(OAuthTestCase):
super().setUp()
self.factory = RequestFactory()
def test_disallowed_grant_type(self):
"""Test with disallowed grant type"""
OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
grant_types=[],
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
"/",
data={
"response_type": "code",
"client_id": "test",
"redirect_uri": "http://local.invalid/Foo",
},
)
OAuthAuthorizationParams.from_request(request)
self.assertEqual(cm.exception.error, "invalid_request")
def test_invalid_grant_type(self):
"""Test with invalid grant type"""
OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
with self.assertRaises(AuthorizeError) as cm:
@@ -74,6 +96,7 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
grant_types=[GrantType.AUTHORIZATION_CODE],
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
@@ -188,6 +211,7 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, ".+")],
grant_types=[GrantType.AUTHORIZATION_CODE],
)
request = self.factory.get(
"/",
@@ -206,6 +230,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
)
provider.property_mappings.set(
@@ -227,12 +252,14 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantTypes.AUTHORIZATION_CODE,
GrantType.AUTHORIZATION_CODE,
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).redirect_uri,
"http://local.invalid/Foo",
)
provider.grant_types = [GrantType.IMPLICIT]
provider.save()
request = self.factory.get(
"/",
data={
@@ -246,7 +273,7 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantTypes.IMPLICIT,
GrantType.IMPLICIT,
)
# Implicit without openid scope
with self.assertRaises(AuthorizeError) as cm:
@@ -261,8 +288,10 @@ class TestAuthorize(OAuthTestCase):
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type,
GrantTypes.IMPLICIT,
GrantType.IMPLICIT,
)
provider.grant_types = [GrantType.HYBRID]
provider.save()
request = self.factory.get(
"/",
data={
@@ -274,7 +303,7 @@ class TestAuthorize(OAuthTestCase):
},
)
self.assertEqual(
OAuthAuthorizationParams.from_request(request).grant_type, GrantTypes.HYBRID
OAuthAuthorizationParams.from_request(request).grant_type, GrantType.HYBRID
)
with self.assertRaises(AuthorizeError) as cm:
request = self.factory.get(
@@ -297,6 +326,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -333,6 +363,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -404,6 +435,7 @@ class TestAuthorize(OAuthTestCase):
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
encryption_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -466,6 +498,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -515,6 +548,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -572,6 +606,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
grant_types=[GrantType.AUTHORIZATION_CODE],
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id()
@@ -612,6 +647,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.IMPLICIT],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
)
request = self.factory.get(
@@ -635,6 +671,7 @@ class TestAuthorize(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
grant_types=[GrantType.IMPLICIT],
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@@ -667,6 +704,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -697,6 +735,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -736,6 +775,7 @@ class TestAuthorize(OAuthTestCase):
authentication_flow=auth_flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -762,6 +802,7 @@ class TestAuthorize(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()

View File

@@ -10,7 +10,7 @@ from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@@ -22,6 +22,7 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.DEVICE_CODE],
)
self.application = Application.objects.create(
name=generate_id(),
@@ -42,6 +43,21 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
reverse("authentik_providers_oauth2:device"),
)
self.assertEqual(res.status_code, 400)
def test_backchannel_invalid_no_grant(self):
"""Test backchannel"""
self.provider.grant_types = []
self.provider.save()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
data={
"client_id": "test",
},
)
self.assertEqual(res.status_code, 400)
def test_backchannel_invalid_no_app(self):
"""Test backchannel"""
# test without application
self.application.provider = None
self.application.save()

View File

@@ -9,7 +9,7 @@ from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -22,6 +22,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
grant_types=[GrantType.DEVICE_CODE],
)
self.application = Application.objects.create(
name=generate_id(),

View File

@@ -14,7 +14,7 @@ from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientTypes,
ClientType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -173,7 +173,7 @@ class TesOAuth2Introspection(OAuthTestCase):
def test_introspect_provider_public(self):
"""Test introspect"""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.client_type = ClientType.PUBLIC
self.provider.save()
token = AccessToken.objects.create(
provider=self.provider,
@@ -208,7 +208,7 @@ class TesOAuth2Introspection(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
client_type=ClientType.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)

View File

@@ -13,7 +13,7 @@ from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientTypes,
ClientType,
DeviceToken,
OAuth2Provider,
RedirectURI,
@@ -126,7 +126,7 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_public(self):
"""Test revoke public client"""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.client_type = ClientType.PUBLIC
self.provider.save()
token = AccessToken.objects.create(
provider=self.provider,
@@ -241,7 +241,7 @@ class TesOAuth2Revoke(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
client_type=ClientType.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
@@ -270,14 +270,14 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_provider_fed_public(self):
"""Test revoke with federation. self.provider is a public
client and other_provider is a public client."""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.client_type = ClientType.PUBLIC
self.provider.save()
other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
client_type=ClientType.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)

View File

@@ -25,6 +25,7 @@ from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -44,11 +45,39 @@ class TestToken(OAuthTestCase):
self.factory = RequestFactory()
self.app = Application.objects.create(name=generate_id(), slug="test")
def test_invalid_grant_type(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
signing_key=self.keypair,
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
code = AuthorizationCode.objects.create(
code="foobar", provider=provider, user=user, auth_time=timezone.now()
)
request = self.factory.post(
"/",
data={
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
"code": code.code,
"redirect_uri": "http://TestServer",
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
with self.assertRaises(TokenError) as cm:
TokenParams.parse(request, provider, provider.client_id, provider.client_secret)
self.assertEqual(cm.exception.cause, "grant_type_not_configured")
def test_request_auth_code(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
signing_key=self.keypair,
)
@@ -76,6 +105,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.keypair,
)
@@ -97,6 +127,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -139,6 +170,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -179,6 +211,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
encryption_key=self.keypair,
@@ -210,6 +243,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -271,6 +305,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
)
@@ -328,6 +363,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.keypair,
)
@@ -400,6 +436,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.REFRESH_TOKEN],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
refresh_token_threshold="hours=1", # nosec
@@ -497,6 +534,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
grant_types=[GrantType.AUTHORIZATION_CODE],
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
include_claims_in_id_token=True,

View File

@@ -22,6 +22,7 @@ from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
AccessToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -55,6 +56,7 @@ class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
grant_types=[GrantType.CLIENT_CREDENTIALS],
)
self.provider.jwt_federation_providers.add(self.other_provider)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@@ -20,6 +20,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -68,6 +69,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
grant_types=[GrantType.CLIENT_CREDENTIALS],
)
self.provider.jwt_federation_sources.add(self.source)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@@ -21,6 +21,7 @@ from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -41,6 +42,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -22,6 +22,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -42,6 +43,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -25,6 +25,7 @@ from authentik.core.tests.utils import (
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -45,6 +46,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -17,6 +17,7 @@ from authentik.lib.generators import generate_code_fixed_length, generate_id
from authentik.providers.oauth2.models import (
AccessToken,
DeviceToken,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -37,6 +38,7 @@ class TestTokenDeviceCode(OAuthTestCase):
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=create_test_cert(),
grant_types=[GrantType.DEVICE_CODE],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)

View File

@@ -11,6 +11,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
AuthorizationCode,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -37,6 +38,7 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -95,6 +97,7 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -151,6 +154,7 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@@ -196,6 +200,7 @@ class TestTokenPKCE(OAuthTestCase):
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
access_code_validity="seconds=100",
grant_types=[GrantType.AUTHORIZATION_CODE],
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()

View File

@@ -57,7 +57,7 @@ from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
GrantTypes,
GrantType,
OAuth2Provider,
RedirectURIMatchingMode,
ResponseMode,
@@ -164,28 +164,31 @@ class OAuthAuthorizationParams:
"""Check grant"""
# Determine which flow to use.
if self.response_type in [ResponseTypes.CODE]:
self.grant_type = GrantTypes.AUTHORIZATION_CODE
self.grant_type = GrantType.AUTHORIZATION_CODE
elif self.response_type in [
ResponseTypes.ID_TOKEN,
ResponseTypes.ID_TOKEN_TOKEN,
]:
self.grant_type = GrantTypes.IMPLICIT
self.grant_type = GrantType.IMPLICIT
elif self.response_type in [
ResponseTypes.CODE_TOKEN,
ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
]:
self.grant_type = GrantTypes.HYBRID
self.grant_type = GrantType.HYBRID
# Grant type validation.
if not self.grant_type:
LOGGER.warning("Invalid response type", type=self.response_type)
raise AuthorizeError(self.redirect_uri, "unsupported_response_type", "", self.state)
if self.grant_type not in self.provider.grant_types:
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
if self.response_mode not in ResponseMode.values:
self.response_mode = ResponseMode.QUERY
if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID]:
self.response_mode = ResponseMode.FRAGMENT
def check_redirect_uri(self):
@@ -246,7 +249,7 @@ class OAuthAuthorizationParams:
)
self.scope = self.scope.intersection(default_scope_names)
if SCOPE_OPENID not in self.scope and (
self.grant_type == GrantTypes.HYBRID
self.grant_type == GrantType.HYBRID
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
):
LOGGER.warning("Missing 'openid' scope.")
@@ -597,8 +600,8 @@ class OAuthFulfillmentStage(StageView):
code = None
if self.params.grant_type in [
GrantTypes.AUTHORIZATION_CODE,
GrantTypes.HYBRID,
GrantType.AUTHORIZATION_CODE,
GrantType.HYBRID,
]:
code = self.params.create_code(self.request)
code.save()
@@ -613,7 +616,7 @@ class OAuthFulfillmentStage(StageView):
if self.params.response_mode == ResponseMode.FRAGMENT:
query_fragment = {}
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
query_fragment["code"] = code.code
query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
else:
@@ -627,7 +630,7 @@ class OAuthFulfillmentStage(StageView):
if self.params.response_mode == ResponseMode.FORM_POST:
post_params = {}
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
post_params["code"] = code.code
post_params["state"] = [str(self.params.state) if self.params.state else ""]
else:
@@ -696,7 +699,7 @@ class OAuthFulfillmentStage(StageView):
token.save()
# Code parameter must be present if it's Hybrid Flow.
if self.params.grant_type == GrantTypes.HYBRID:
if self.params.grant_type == GrantType.HYBRID:
query_fragment["code"] = code.code
query_fragment["token_type"] = TOKEN_TYPE

View File

@@ -15,7 +15,7 @@ from authentik.core.models import Application
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.errors import DeviceCodeError
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -42,6 +42,8 @@ class DeviceView(View):
_ = provider.application
except Application.DoesNotExist:
raise DeviceCodeError("invalid_client") from None
if GrantType.DEVICE_CODE not in provider.grant_types:
raise DeviceCodeError("invalid_client")
self.provider = provider
self.client_id = client_id

View File

@@ -11,7 +11,7 @@ from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenIntrospectionError
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger()
@@ -45,7 +45,7 @@ class TokenIntrospectionParams:
if not provider:
LOGGER.info("Failed to authenticate introspection request")
raise TokenIntrospectionError
if provider.client_type != ClientTypes.CONFIDENTIAL:
if provider.client_type != ClientType.CONFIDENTIAL:
LOGGER.info("Introspection request from public provider, denying.")
raise TokenIntrospectionError

View File

@@ -58,7 +58,7 @@ from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
ClientTypes,
ClientType,
DeviceToken,
OAuth2Provider,
RedirectURIMatchingMode,
@@ -165,6 +165,10 @@ class TokenParams:
raise TokenError("invalid_grant")
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
if self.grant_type not in self.provider.grant_types:
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
raise TokenError("invalid_grant").with_cause("grant_type_not_configured")
# Confidential clients MUST authenticate to the token endpoint per
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
# that requirement - the device_code alone is not a substitute for
@@ -174,7 +178,7 @@ class TokenParams:
GRANT_TYPE_REFRESH_TOKEN,
GRANT_TYPE_DEVICE_CODE,
]:
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
if self.provider.client_type == ClientType.CONFIDENTIAL and not compare_digest(
self.provider.client_secret, self.client_secret
):
LOGGER.warning(
@@ -606,10 +610,10 @@ class TokenView(View):
if not self.provider:
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
raise TokenError("invalid_client")
CTX_AUTH_VIA.set("oauth_client_secret")
self.params = self.params_class.parse(
request, self.provider, client_id, client_secret
)
CTX_AUTH_VIA.set("oauth_client_secret")
with start_span(
op="authentik.providers.oauth2.post.response",

View File

@@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenRevocationError
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import (
TokenResponse,
authenticate_provider,
@@ -33,11 +33,13 @@ class TokenRevocationParams:
raw_token = request.POST.get("token")
provider, _, _ = provider_from_request(request)
if provider and provider.client_type == ClientType.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")
# By default clients can only revoke their own tokens
query = Q(provider=provider, token=raw_token)
if provider.client_type == ClientTypes.CONFIDENTIAL:
if provider.client_type == ClientType.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")

View File

@@ -16,7 +16,8 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
from authentik.outposts.models import OutpostModel
from authentik.providers.oauth2.models import (
ClientTypes,
ClientType,
GrantType,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -161,7 +162,12 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct"""
self.client_type = ClientTypes.CONFIDENTIAL
self.grant_types = [
GrantType.AUTHORIZATION_CODE,
GrantType.CLIENT_CREDENTIALS,
GrantType.PASSWORD,
]
self.client_type = ClientType.CONFIDENTIAL
self.signing_key = None
self.include_claims_in_id_token = True
scopes = ScopeMapping.objects.filter(

View File

@@ -9,7 +9,7 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.models import ClientTypes
from authentik.providers.oauth2.models import ClientType
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
@@ -96,7 +96,7 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 201)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
def test_update_defaults(self):
"""Test create"""
@@ -114,8 +114,8 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 201)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
provider.client_type = ClientTypes.PUBLIC
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
provider.client_type = ClientType.PUBLIC
provider.save()
response = self.client.put(
reverse("authentik_api:proxyprovider-detail", kwargs={"pk": provider.pk}),
@@ -130,7 +130,7 @@ class ProxyProviderTests(APITestCase):
)
self.assertEqual(response.status_code, 200)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
def test_sa_fetch(self):
"""Test fetching the outpost config as the service account"""

View File

@@ -24,7 +24,11 @@ from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.common.saml.constants import SAML_BINDING_POST, SAML_BINDING_REDIRECT
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
SAML_BINDING_POST,
SAML_BINDING_REDIRECT,
)
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
@@ -55,7 +59,13 @@ class SAMLProviderSerializer(ProviderSerializer):
"""SAMLProvider Serializer"""
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()
@@ -85,6 +95,53 @@ class SAMLProviderSerializer(ProviderSerializer):
+ "?download"
)
def get_url_issuer(self, instance: SAMLProvider) -> str:
"""Get Issuer/EntityID URL"""
if instance.issuer_override:
return instance.issuer_override
if "request" not in self._context:
return DEFAULT_ISSUER
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 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:
@@ -198,7 +255,7 @@ class SAMLProviderSerializer(ProviderSerializer):
"acs_url",
"sls_url",
"audience",
"issuer",
"issuer_override",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
@@ -220,6 +277,9 @@ class SAMLProviderSerializer(ProviderSerializer):
"default_relay_state",
"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

@@ -0,0 +1,34 @@
# Generated by Django 5.2.11 on 2026-02-24 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0021_samlprovider_sign_logout_response"),
]
operations = [
migrations.RenameField(
model_name="samlprovider",
old_name="issuer",
new_name="issuer_override",
),
migrations.AlterField(
model_name="samlprovider",
name="issuer_override",
field=models.TextField(
blank=True,
default="",
help_text="Also known as EntityID. Providing a value overrides the default issuer generated by authentik.",
),
),
migrations.AddField(
model_name="samlsession",
name="issuer",
field=models.TextField(
default=None, help_text="SAML Issuer used for this session", null=True
),
),
]

View File

@@ -77,7 +77,14 @@ class SAMLProvider(Provider):
"no audience restriction will be added."
),
)
issuer = models.TextField(help_text=_("Also known as EntityID"), default="authentik")
issuer_override = models.TextField(
blank=True,
default="",
help_text=_(
"Also known as EntityID. Providing a value overrides the default issuer "
"generated by authentik."
),
)
sls_url = models.TextField(
blank=True,
validators=[DomainlessURLValidator(schemes=("http", "https"))],
@@ -234,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:
@@ -318,6 +325,9 @@ class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
session_index = models.TextField(help_text=_("SAML SessionIndex for this session"))
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
name_id_format = models.TextField(default="", blank=True, help_text=_("SAML NameID format"))
issuer = models.TextField(
default=None, null=True, help_text=_("SAML Issuer used for this session")
)
created = models.DateTimeField(auto_now_add=True)
@property

View File

@@ -6,6 +6,7 @@ from types import GeneratorType
import xmlsec
from django.http import HttpRequest
from django.urls import reverse
from django.utils.timezone import now
from lxml import etree # nosec
from lxml.etree import Element, SubElement, _Element # nosec
@@ -63,6 +64,7 @@ class AssertionProcessor:
session_index: str
name_id: str
name_id_format: str
issuer: str
session_not_on_or_after_datetime: datetime
def __init__(self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest):
@@ -137,10 +139,24 @@ class AssertionProcessor:
continue
return attribute_statement
def _get_issuer_value(self) -> str:
"""Get issuer value, with fallback to generated URL if empty"""
# If user has set an override issuer, use it
if self.provider.issuer_override:
return self.provider.issuer_override
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
def get_issuer(self) -> Element:
"""Get Issuer Element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
issuer.text = self.provider.issuer
self.issuer = self._get_issuer_value()
issuer.text = self.issuer
return issuer
def get_assertion_auth_n_statement(self) -> Element:

View File

@@ -8,6 +8,7 @@ from lxml import etree # nosec
from lxml.etree import Element, _Element
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
@@ -33,11 +34,12 @@ class LogoutRequestProcessor:
name_id_format: str
session_index: str | None
relay_state: str | None
issuer: str | None
_issue_instant: str
_request_id: str
def __init__(
def __init__( # noqa: PLR0913
self,
provider: SAMLProvider,
user: User | None,
@@ -46,6 +48,7 @@ class LogoutRequestProcessor:
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
session_index: str | None = None,
relay_state: str | None = None,
issuer: str | None = None,
):
self.provider = provider
self.user = user
@@ -54,14 +57,23 @@ class LogoutRequestProcessor:
self.name_id_format = name_id_format
self.session_index = session_index
self.relay_state = relay_state
self.issuer = issuer
self._issue_instant = get_time_string()
self._request_id = get_random_id()
def _get_issuer_value(self) -> str:
"""Get issuer value from session, with fallback to provider"""
if self.issuer:
return self.issuer
if self.provider.issuer_override:
return self.provider.issuer_override
return DEFAULT_ISSUER
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.provider.issuer
issuer.text = self._get_issuer_value()
return issuer
def get_name_id(self) -> Element:

View File

@@ -8,6 +8,7 @@ from lxml import etree
from lxml.etree import Element, SubElement
from authentik.common.saml.constants import (
DEFAULT_ISSUER,
DIGEST_ALGORITHM_TRANSLATION_MAP,
NS_MAP,
NS_SAML_ASSERTION,
@@ -28,27 +29,38 @@ class LogoutResponseProcessor:
logout_request: LogoutRequest
destination: str | None
relay_state: str | None
issuer: str | None
_issue_instant: str
_response_id: str
def __init__(
def __init__( # noqa: PLR0913
self,
provider: SAMLProvider,
logout_request: LogoutRequest,
destination: str | None = None,
relay_state: str | None = None,
issuer: str | None = None,
):
self.provider = provider
self.logout_request = logout_request
self.destination = destination
self.relay_state = relay_state or (logout_request.relay_state if logout_request else None)
self.issuer = issuer
self._issue_instant = get_time_string()
self._response_id = get_random_id()
def _get_issuer_value(self) -> str:
"""Get issuer value from session, with fallback to provider"""
if self.issuer:
return self.issuer
if self.provider.issuer_override:
return self.provider.issuer_override
return DEFAULT_ISSUER
def get_issuer(self) -> Element:
"""Get Issuer element"""
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
issuer.text = self.provider.issuer
issuer.text = self._get_issuer_value()
return issuer
def build(self, status: str = "Success") -> Element:

View File

@@ -40,6 +40,19 @@ class MetadataProcessor:
self.force_binding = None
self.xml_id = "_" + sha256(f"{provider.name}-{provider.pk}".encode("ascii")).hexdigest()
def _get_issuer_value(self) -> str:
"""Get issuer value, with fallback to generated URL if empty"""
# If user has set an override issuer, use it
if self.provider.issuer_override:
return self.provider.issuer_override
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
# Using type unions doesn't work with cython types (which is what lxml is)
def get_signing_key_descriptor(self) -> Element | None:
"""Get Signing KeyDescriptor, if enabled for the provider"""
@@ -68,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):
@@ -189,7 +183,7 @@ class MetadataProcessor:
"""Build full EntityDescriptor"""
entity_descriptor = Element(f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP)
entity_descriptor.attrib["ID"] = self.xml_id
entity_descriptor.attrib["entityID"] = self.provider.issuer
entity_descriptor.attrib["entityID"] = self._get_issuer_value()
if self.provider.signing_kp:
self._prepare_signature(entity_descriptor)

View File

@@ -51,7 +51,6 @@ class ServiceProviderMetadata:
provider = SAMLProvider.objects.create(
name=name, authorization_flow=authorization_flow, invalidation_flow=invalidation_flow
)
provider.issuer = self.entity_id
provider.sp_binding = self.acs_binding
provider.acs_url = self.acs_location
provider.default_name_id_policy = self.name_id_policy

View File

@@ -75,6 +75,7 @@ def handle_saml_iframe_pre_user_logout(
name_id_format=session.name_id_format,
session_index=session.session_index,
relay_state=relay_state,
issuer=session.issuer,
)
if session.provider.sls_binding == SAMLBindings.POST:
@@ -163,6 +164,7 @@ def handle_flow_pre_user_logout(
name_id_format=session.name_id_format,
session_index=session.session_index,
relay_state=relay_state,
issuer=session.issuer,
)
if session.provider.sls_binding == SAMLBindings.POST:
@@ -224,6 +226,7 @@ def user_session_deleted_saml_logout(sender, instance: AuthenticatedSession, **_
name_id=saml_session.name_id,
name_id_format=saml_session.name_id_format,
session_index=saml_session.session_index,
issuer=saml_session.issuer,
)
@@ -257,4 +260,5 @@ def user_deactivated_saml_logout(sender, instance: User, **kwargs):
name_id=saml_session.name_id,
name_id_format=saml_session.name_id_format,
session_index=saml_session.session_index,
issuer=saml_session.issuer,
)

View File

@@ -22,6 +22,7 @@ def send_saml_logout_request(
name_id: str,
name_id_format: str,
session_index: str,
issuer: str,
):
"""Send SAML LogoutRequest to a Service Provider using session data"""
provider = SAMLProvider.objects.filter(pk=provider_pk).first()
@@ -47,6 +48,7 @@ def send_saml_logout_request(
name_id=name_id,
name_id_format=name_id_format,
session_index=session_index,
issuer=issuer,
)
return send_post_logout_request(provider, processor)
@@ -89,6 +91,7 @@ def send_saml_logout_response(
sls_url: str,
logout_request_id: str | None = None,
relay_state: str | None = None,
issuer: str | None = None,
):
"""Send SAML LogoutResponse to a Service Provider using backchannel (server-to-server)"""
provider = SAMLProvider.objects.filter(pk=provider_pk).first()
@@ -119,6 +122,7 @@ def send_saml_logout_response(
logout_request=logout_request,
destination=sls_url,
relay_state=relay_state,
issuer=issuer,
)
encoded_response = processor.encode_post()

View File

@@ -15,6 +15,7 @@ from authentik.common.saml.constants import (
SAML_NAME_ID_FORMAT_EMAIL,
SAML_NAME_ID_FORMAT_UNSPECIFIED,
)
from authentik.core.models import Application
from authentik.core.tests.utils import (
RequestFactory,
create_test_admin_user,
@@ -97,6 +98,11 @@ class TestAuthNRequest(TestCase):
)
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
self.provider.save()
Application.objects.create(
name="test-app",
slug="test-app",
provider=self.provider,
)
self.source = SAMLSource.objects.create(
slug="provider",
issuer="authentik",
@@ -526,7 +532,7 @@ class TestAuthNRequest(TestCase):
authorization_flow=create_test_flow(),
acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
audience="https://10.120.20.200/saml-sp/SAML2/POST",
issuer="https://10.120.20.200/saml-sp/SAML2/POST",
issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
signing_kp=static_keypair,
verification_kp=static_keypair,
)
@@ -547,7 +553,7 @@ class TestAuthNRequest(TestCase):
"saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
),
audience="https://10.120.20.200/saml-sp/SAML2/POST",
issuer="https://10.120.20.200/saml-sp/SAML2/POST",
issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
signing_kp=create_test_cert(),
)
parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)

View File

@@ -47,7 +47,7 @@ class TestNativeLogoutStageView(TestCase):
authorization_flow=self.flow,
acs_url="https://sp1.example.com/acs",
sls_url="https://sp1.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE,
@@ -58,7 +58,7 @@ class TestNativeLogoutStageView(TestCase):
authorization_flow=self.flow,
acs_url="https://sp2.example.com/acs",
sls_url="https://sp2.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="post",
sls_binding="post",
logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE,
@@ -218,7 +218,7 @@ class TestIframeLogoutStageView(TestCase):
authorization_flow=self.flow,
acs_url="https://sp1.example.com/acs",
sls_url="https://sp1.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
logout_method="frontchannel_iframe",
@@ -229,7 +229,7 @@ class TestIframeLogoutStageView(TestCase):
authorization_flow=self.flow,
acs_url="https://sp2.example.com/acs",
sls_url="https://sp2.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="post",
sls_binding="post",
logout_method="frontchannel_iframe",
@@ -372,7 +372,7 @@ class TestIdPLogoutIntegration(FlowTestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
signing_kp=self.keypair,

View File

@@ -28,7 +28,7 @@ class TestLogoutIntegration(TestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
signature_algorithm=RSA_SHA256,
@@ -57,7 +57,7 @@ class TestLogoutIntegration(TestCase):
parsed = self.parser.parse(encoded)
# Verify all fields match
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
self.assertEqual(parsed.name_id, "test@example.com")
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
self.assertEqual(parsed.session_index, "test-session-123")
@@ -72,7 +72,7 @@ class TestLogoutIntegration(TestCase):
parsed = self.parser.parse_detached(encoded)
# Verify all fields match
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
self.assertEqual(parsed.name_id, "test@example.com")
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
self.assertEqual(parsed.session_index, "test-session-123")
@@ -106,7 +106,7 @@ class TestLogoutIntegration(TestCase):
parsed = parser.parse(encoded)
# Verify all fields match
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
self.assertEqual(parsed.name_id, "signed@example.com")
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
self.assertEqual(parsed.session_index, "signed-session-456")
@@ -125,7 +125,7 @@ class TestLogoutIntegration(TestCase):
parsed = self.parser.parse_detached(saml_request)
# Verify parsing succeeded
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
self.assertEqual(parsed.name_id, "test@example.com")
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
@@ -164,7 +164,7 @@ class TestLogoutIntegration(TestCase):
# Parse the SAMLRequest (unsigned XML)
parsed = self.parser.parse_detached(params["SAMLRequest"][0])
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
def test_form_data_can_be_parsed(self):
"""Test that form data generates parseable POST request"""
@@ -175,7 +175,7 @@ class TestLogoutIntegration(TestCase):
parsed = self.parser.parse(form_data["SAMLRequest"])
# Verify parsing succeeded
self.assertEqual(parsed.issuer, self.provider.issuer)
self.assertEqual(parsed.issuer, self.provider.issuer_override)
self.assertEqual(parsed.name_id, "test@example.com")
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
self.assertEqual(parsed.session_index, "test-session-123")
@@ -244,4 +244,4 @@ class TestLogoutIntegration(TestCase):
# But same issuer
self.assertEqual(parsed1.issuer, parsed2.issuer)
self.assertEqual(parsed1.issuer, self.provider.issuer)
self.assertEqual(parsed1.issuer, self.provider.issuer_override)

View File

@@ -35,7 +35,7 @@ class TestLogoutRequestProcessor(TestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
signature_algorithm=RSA_SHA256,

View File

@@ -1,7 +1,7 @@
"""logout response tests"""
from defusedxml import ElementTree
from django.test import TestCase
from django.test import RequestFactory, TestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.common.saml.constants import (
@@ -9,10 +9,13 @@ from authentik.common.saml.constants import (
NS_SAML_PROTOCOL,
NS_SIGNATURE,
)
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
from authentik.providers.saml.processors.metadata import MetadataProcessor
class TestLogoutResponse(TestCase):
@@ -21,6 +24,7 @@ class TestLogoutResponse(TestCase):
@apply_blueprint("system/providers-saml.yaml")
def setUp(self):
cert = create_test_cert()
self.factory = RequestFactory()
self.provider: SAMLProvider = SAMLProvider.objects.create(
authorization_flow=create_test_flow(),
acs_url="http://testserver/source/saml/provider/acs/",
@@ -30,17 +34,31 @@ class TestLogoutResponse(TestCase):
)
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
self.provider.save()
self.application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
def test_build_response(self):
"""Test building a LogoutResponse"""
"""Test building a LogoutResponse uses the generated issuer from the assertion"""
# Generate the issuer the same way the assertion/metadata processors would
request = self.factory.get("/")
metadata_processor = MetadataProcessor(self.provider, request)
generated_issuer = metadata_processor._get_issuer_value()
logout_request = LogoutRequest(
id="test-request-id",
issuer="test-sp",
relay_state="test-relay-state",
)
# Pass the generated issuer as if it came from SAMLSession.issuer
processor = LogoutResponseProcessor(
self.provider, logout_request, destination=self.provider.sls_url
self.provider,
logout_request,
destination=self.provider.sls_url,
issuer=generated_issuer,
)
response_xml = processor.build_response(status="Success")
@@ -51,9 +69,9 @@ class TestLogoutResponse(TestCase):
self.assertEqual(root.attrib["Destination"], self.provider.sls_url)
self.assertEqual(root.attrib["InResponseTo"], "test-request-id")
# Check Issuer
# Check Issuer matches the generated issuer from the assertion processor
issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
self.assertEqual(issuer.text, self.provider.issuer)
self.assertEqual(issuer.text, generated_issuer)
# Check Status
status = root.find(f".//{{{NS_SAML_PROTOCOL}}}StatusCode")

View File

@@ -85,7 +85,6 @@ class TestServiceProviderMetadataParser(TestCase):
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/simple.xml"))
provider = metadata.to_provider("test", self.flow, self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
self.assertEqual(provider.default_name_id_policy, SAMLNameIDPolicy.EMAIL)
self.assertEqual(
@@ -99,7 +98,6 @@ class TestServiceProviderMetadataParser(TestCase):
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/cert.xml"))
provider = metadata.to_provider("test", self.flow, self.flow)
self.assertEqual(provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs")
self.assertEqual(provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata")
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
self.assertEqual(
provider.verification_kp.certificate_data, load_fixture("fixtures/cert.pem")

View File

@@ -32,7 +32,7 @@ class TestSAMLSessionModel(TestCase):
name="test-provider",
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
)
# Create another provider for testing
@@ -40,7 +40,7 @@ class TestSAMLSessionModel(TestCase):
name="test-provider-2",
authorization_flow=self.flow,
acs_url="https://sp2.example.com/acs",
issuer="https://idp2.example.com",
issuer_override="https://idp2.example.com",
)
# Create a session first (using authentik's custom Session model)
@@ -72,6 +72,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify the session was created
@@ -100,6 +101,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Try to create another session with same session_index and provider
@@ -113,6 +115,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
def test_cascade_deletion_user(self):
@@ -127,6 +130,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify session exists
@@ -150,6 +154,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify session exists
@@ -173,6 +178,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify session exists
@@ -196,6 +202,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Create second session with different provider
@@ -208,6 +215,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify both sessions exist
@@ -229,6 +237,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=future_time,
expiring=True,
issuer="authentik",
)
# Verify expiry time
@@ -248,6 +257,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=past_time,
expiring=True,
issuer="authentik",
)
# Check if marked as expired
@@ -265,6 +275,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format="", # Blank format
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify it was created successfully
@@ -283,6 +294,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
session2 = SAMLSession.objects.create(
@@ -294,6 +306,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Query by provider
@@ -316,6 +329,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Check serializer property
@@ -334,6 +348,7 @@ class TestSAMLSessionModel(TestCase):
name_id_format=self.name_id_format,
expires=self.expires,
expiring=True,
issuer="authentik",
)
# Verify sessions exist

View File

@@ -7,6 +7,7 @@ from guardian.shortcuts import get_anonymous_user
from lxml import etree # nosec
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
from authentik.lib.xml import lxml_from_string
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
@@ -30,6 +31,11 @@ class TestSchema(TestCase):
)
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
self.provider.save()
Application.objects.create(
name="test-app",
slug="test-app",
provider=self.provider,
)
self.source = SAMLSource.objects.create(
slug="provider",
issuer="authentik",

View File

@@ -28,7 +28,7 @@ class TestSendSamlLogoutResponse(TestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
signing_kp=self.cert,
)
@@ -137,7 +137,7 @@ class TestSendSamlLogoutRequest(TestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
signing_kp=self.cert,
)
@@ -155,6 +155,7 @@ class TestSendSamlLogoutRequest(TestCase):
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
issuer="https://idp.example.com",
)
self.assertTrue(result)
@@ -179,6 +180,7 @@ class TestSendSamlLogoutRequest(TestCase):
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
issuer="https://idp.example.com",
)
self.assertFalse(result)
@@ -198,6 +200,7 @@ class TestSendSamlLogoutRequest(TestCase):
name_id="test@example.com",
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
session_index="test-session-123",
issuer="https://idp.example.com",
)
@@ -214,7 +217,7 @@ class TestSendPostLogoutRequest(TestCase):
authorization_flow=self.flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
signing_kp=self.cert,
)

View File

@@ -40,7 +40,7 @@ class TestSPInitiatedSLOViews(TestCase):
invalidation_flow=self.invalidation_flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
)
@@ -90,7 +90,7 @@ class TestSPInitiatedSLOViews(TestCase):
# Verify logout request was stored in plan context
self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
logout_request = view.plan_context["authentik/providers/saml/logout_request"]
self.assertEqual(logout_request.issuer, self.provider.issuer)
self.assertEqual(logout_request.issuer, self.provider.issuer_override)
self.assertEqual(logout_request.session_index, "test-session-123")
def test_redirect_view_handles_logout_response_with_plan_context(self):
@@ -228,7 +228,7 @@ class TestSPInitiatedSLOViews(TestCase):
# Verify logout request was stored in plan context
self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
logout_request = view.plan_context["authentik/providers/saml/logout_request"]
self.assertEqual(logout_request.issuer, self.provider.issuer)
self.assertEqual(logout_request.issuer, self.provider.issuer_override)
self.assertEqual(logout_request.session_index, "test-session-123")
def test_post_view_handles_logout_response_with_plan_context(self):
@@ -396,7 +396,7 @@ class TestSPInitiatedSLOViews(TestCase):
authorization_flow=self.flow,
acs_url="https://sp2.example.com/acs",
sls_url="https://sp2.example.com/sls",
issuer="https://idp2.example.com",
issuer_override="https://idp2.example.com",
invalidation_flow=None, # No invalidation flow
)
@@ -524,7 +524,7 @@ class TestSPInitiatedSLOLogoutMethods(TestCase):
invalidation_flow=self.invalidation_flow,
acs_url="https://sp.example.com/acs",
sls_url="https://sp.example.com/sls",
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
sp_binding="redirect",
sls_binding="redirect",
signing_kp=self.cert,
@@ -714,7 +714,7 @@ class TestSPInitiatedSLOLogoutMethods(TestCase):
invalidation_flow=self.invalidation_flow,
acs_url="https://sp.example.com/acs",
sls_url="", # No SLS URL
issuer="https://idp.example.com",
issuer_override="https://idp.example.com",
)
app_no_sls = Application.objects.create(

View File

@@ -4,13 +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 = [
# Unified Endpoint - handles SSO and SLO based on message type
path(
"<slug:application_slug>/",
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

@@ -81,6 +81,7 @@ class SAMLFlowFinalView(ChallengeStageView):
"session": auth_session,
"name_id": processor.name_id,
"name_id_format": processor.name_id_format,
"issuer": processor.issuer,
"expires": processor.session_not_on_or_after_datetime,
"expiring": True,
},

View File

@@ -107,12 +107,25 @@ class SPInitiatedSLOView(PolicyAccessView):
# Store relay state for the logout response
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
# Look up the session issuer to use in the logout response
auth_session = AuthenticatedSession.from_request(request, request.user)
session_issuer = None
if auth_session:
saml_session = SAMLSession.objects.filter(
session=auth_session,
user=request.user,
provider=self.provider,
).first()
if saml_session:
session_issuer = saml_session.issuer
if self.provider.logout_method == SAMLLogoutMethods.FRONTCHANNEL_NATIVE:
# Native mode - user will be redirected/posted away from authentik
processor = LogoutResponseProcessor(
self.provider,
logout_request,
destination=self.provider.sls_url,
issuer=session_issuer,
)
if self.provider.sls_binding == SAMLBindings.POST:
@@ -152,6 +165,7 @@ class SPInitiatedSLOView(PolicyAccessView):
sls_url=self.provider.sls_url,
logout_request_id=logout_request.id if logout_request else None,
relay_state=relay_state,
issuer=session_issuer,
)
LOGGER.debug(
@@ -168,6 +182,7 @@ class SPInitiatedSLOView(PolicyAccessView):
self.provider,
logout_request,
destination=self.provider.sls_url,
issuer=session_issuer,
)
logout_response = processor.build_response()

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

@@ -97,6 +97,9 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
if cached_config is not None:
return cached_config
if self.provider.compatibility_mode == SCIMCompatibilityMode.VCENTER:
return default_config
# Attempt to fetch from remote
path = "/ServiceProviderConfig"
if self.provider.compatibility_mode == SCIMCompatibilityMode.SALESFORCE:

View File

@@ -94,6 +94,7 @@ class Migration(migrations.Migration):
("slack", "Slack"),
("sfdc", "Salesforce"),
("webex", "Webex"),
("vcenter", "vCenter"),
],
default="default",
help_text="Alter authentik behavior for vendor-specific SCIM implementations.",

View File

@@ -83,6 +83,7 @@ class SCIMCompatibilityMode(models.TextChoices):
SLACK = "slack", _("Slack")
SALESFORCE = "sfdc", _("Salesforce")
WEBEX = "webex", _("Webex")
VCENTER = "vcenter", _("vCenter")
class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):

View File

@@ -94,7 +94,7 @@ class OAuthCallback(OAuthClientMixin, View):
def get_user_id(self, info: dict[str, Any]) -> str | None:
"""Return unique identifier from the profile info."""
if "id" in info:
return info["id"]
return str(info["id"])
return None
def handle_login_failure(self, reason: str) -> HttpResponse:

View File

@@ -353,7 +353,7 @@ class IdentificationStageView(ChallengeStageView):
PLAN_CONTEXT_APPLICATION, Application()
)
challenge.initial_data["application_pre"] = app.name
if launch_url := app.get_launch_url():
if not app.meta_hide and (launch_url := app.get_launch_url()):
challenge.initial_data["application_pre_launch"] = launch_url
if (
PLAN_CONTEXT_DEVICE in self.executor.plan.context

View File

@@ -5215,6 +5215,11 @@
"type": "string",
"title": "Group"
},
"meta_hide": {
"type": "boolean",
"title": "Meta hide",
"description": "Hide this application from the user's My applications page."
},
"icon": {
"type": "string",
"minLength": 1,
@@ -9962,6 +9967,23 @@
"title": "Client Type",
"description": "Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable"
},
"grant_types": {
"type": "array",
"items": {
"type": "string",
"enum": [
"authorization_code",
"implicit",
"hybrid",
"refresh_token",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code"
],
"title": "Grant types"
},
"title": "Grant types"
},
"client_id": {
"type": "string",
"maxLength": 255,
@@ -10795,11 +10817,10 @@
"title": "Audience",
"description": "Value of the audience restriction field of the assertion. When left empty, no audience restriction will be added."
},
"issuer": {
"issuer_override": {
"type": "string",
"minLength": 1,
"title": "Issuer",
"description": "Also known as EntityID"
"title": "Issuer override",
"description": "Also known as EntityID. Providing a value overrides the default issuer generated by authentik."
},
"assertion_valid_not_before": {
"type": "string",
@@ -11082,7 +11103,8 @@
"aws",
"slack",
"sfdc",
"webex"
"webex",
"vcenter"
],
"title": "SCIM Compatibility Mode",
"description": "Alter authentik behavior for vendor-specific SCIM implementations."

View File

@@ -75,6 +75,10 @@ entries:
url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
grant_types:
- authorization_code
- implicit
- refresh_token
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
@@ -106,6 +110,10 @@ entries:
url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
grant_types:
- authorization_code
- implicit
- refresh_token
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]

6
build.rs Normal file
View File

@@ -0,0 +1,6 @@
fn main() {
#[cfg(feature = "core")]
{
pyo3_build_config::add_libpython_rpath_link_args();
}
}

View File

@@ -29,7 +29,7 @@ RUN npm run build && \
npm run build:sfe
# Stage: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -147,8 +147,11 @@ RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
--mount=type=bind,target=.cargo/,src=.cargo/ \
--mount=type=bind,target=build.rs,src=build.rs \
--mount=type=bind,target=src/,src=src/ \
--mount=type=bind,target=packages/,src=packages/ \
--mount=type=bind,target=packages/ak-axum,src=packages/ak-axum \
--mount=type=bind,target=packages/ak-common,src=packages/ak-common \
--mount=type=bind,target=packages/client-rust,src=packages/client-rust \
--mount=type=bind,target=authentik/lib/default.yml,src=authentik/lib/default.yml \
# Required otherwise workspace discovery fails
--mount=type=bind,target=website/scripts/docsmg/,src=website/scripts/docsmg/ \
@@ -191,7 +194,10 @@ COPY --from=rust-toolchain /root/.cargo /root/.cargo
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=bind,target=uv.lock,src=uv.lock \
--mount=type=bind,target=packages,src=packages \
--mount=type=bind,target=packages/ak-guardian,src=packages/ak-guardian \
--mount=type=bind,target=packages/django-channels-postgres,src=packages/django-channels-postgres \
--mount=type=bind,target=packages/django-dramatiq-postgres,src=packages/django-dramatiq-postgres \
--mount=type=bind,target=packages/django-postgres-cache,src=packages/django-postgres-cache \
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -21,7 +21,7 @@ COPY web .
RUN npm run build-proxy
# Stage 2: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
ARG TARGETOS
ARG TARGETARCH

Binary file not shown.

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-23 00:25+0000\n"
"POT-Creation-Date: 2026-04-28 00:30+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2717,6 +2717,10 @@ msgstr ""
msgid "Webex"
msgstr ""
#: authentik/providers/scim/models.py
msgid "vCenter"
msgstr ""
#: authentik/providers/scim/models.py
msgid "Group filters used to define sync-scope for groups."
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ durstr.workspace = true
eyre.workspace = true
forwarded-header-value.workspace = true
futures.workspace = true
pin-project-lite.workspace = true
tokio-rustls.workspace = true
tokio.workspace = true
tower-http.workspace = true

View File

@@ -0,0 +1,737 @@
//! axum-server acceptor that catches panics and shuts down the application.
use std::{
any::Any,
io::{self, IoSlice},
panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
task::{Context, Poll},
};
use ak_common::Arbiter;
use axum_server::accept::Accept;
use futures::{FutureExt as _, future::BoxFuture};
use pin_project_lite::pin_project;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tower::Service;
use tracing::{error, instrument};
fn extract_panic_msg<'a>(panic: &'a Box<dyn Any + Send + 'static>) -> &'a str {
panic
.downcast_ref::<&str>()
.copied()
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
.unwrap_or("unknown panic message")
}
/// Acceptor catching panics from the underlying acceptor.
///
/// Also wraps the stream and service to catch panics.
#[derive(Clone)]
pub(crate) struct CatchPanicAcceptor<A> {
inner: A,
arbiter: Arbiter,
}
impl<A> CatchPanicAcceptor<A> {
pub(crate) fn new(inner: A, arbiter: Arbiter) -> Self {
Self { inner, arbiter }
}
}
impl<A, I, S> Accept<I, S> for CatchPanicAcceptor<A>
where
A: Accept<I, S> + Clone + Send + 'static,
A::Stream: AsyncRead + AsyncWrite + Send,
A::Service: Send,
A::Future: Send,
I: AsyncRead + AsyncWrite + Unpin + Send + 'static,
S: Send + 'static,
{
type Future = BoxFuture<'static, io::Result<(Self::Stream, Self::Service)>>;
type Service = CatchPanicService<A::Service>;
type Stream = CatchPanicStream<A::Stream>;
#[instrument(skip_all)]
fn accept(&self, stream: I, service: S) -> Self::Future {
let acceptor = self.inner.clone();
let arbiter = self.arbiter.clone();
Box::pin(async move {
match AssertUnwindSafe(acceptor.accept(stream, service))
.catch_unwind()
.await
{
Ok(result) => {
let (stream, service) = result?;
Ok((
CatchPanicStream::new(stream, arbiter.clone()),
CatchPanicService::new(service, arbiter),
))
}
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"acceptor panicked, shutting down immediately"
);
arbiter.do_fast_shutdown().await;
resume_unwind(panic);
}
}
})
}
}
pin_project! {
/// A stream wrapper that catches panics from the underlying stream.
pub(crate) struct CatchPanicStream<S> {
#[pin]
inner: S,
arbiter: Arbiter,
}
}
impl<S> CatchPanicStream<S> {
pub(crate) fn new(inner: S, arbiter: Arbiter) -> Self {
Self { inner, arbiter }
}
}
impl<S: AsyncRead> AsyncRead for CatchPanicStream<S> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_read(cx, buf))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream poll_read panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
}
impl<S: AsyncWrite> AsyncWrite for CatchPanicStream<S> {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_write(cx, buf))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream poll_write panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
fn poll_write_vectored(
self: std::pin::Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<io::Result<usize>> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| {
this.inner.poll_write_vectored(cx, bufs)
})) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream poll_write_vectored panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic)
}
}
}
fn is_write_vectored(&self) -> bool {
match catch_unwind(AssertUnwindSafe(|| self.inner.is_write_vectored())) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream is_write_vectored panicked, shutting down immediately"
);
let arbiter = self.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
fn poll_flush(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_flush(cx))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream poll_flush panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
fn poll_shutdown(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_shutdown(cx))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"stream poll_shutdown panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
}
/// A panic wrapper that catches panics from the underlying service.
#[derive(Clone)]
pub(crate) struct CatchPanicService<S> {
inner: S,
arbiter: Arbiter,
}
impl<S> CatchPanicService<S> {
pub(crate) fn new(inner: S, arbiter: Arbiter) -> Self {
Self { inner, arbiter }
}
}
impl<S, R> Service<R> for CatchPanicService<S>
where
S: Service<R>,
{
type Error = S::Error;
type Future = CatchPanicFuture<S::Future>;
type Response = S::Response;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let inner = &mut self.inner;
match catch_unwind(AssertUnwindSafe(|| inner.poll_ready(cx))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"service poll_ready panicked, shutting down immediately"
);
let arbiter = self.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
fn call(&mut self, req: R) -> Self::Future {
let inner = &mut self.inner;
match catch_unwind(AssertUnwindSafe(|| inner.call(req))) {
Ok(future) => CatchPanicFuture::new(future, self.arbiter.clone()),
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"service call panicked, shutting down immediately"
);
let arbiter = self.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
}
pin_project! {
/// A Future wrapper that catches panics from the inner future.
pub(crate) struct CatchPanicFuture<F> {
#[pin]
inner: F,
arbiter: Arbiter,
}
}
impl<F> CatchPanicFuture<F> {
fn new(inner: F, arbiter: Arbiter) -> Self {
Self { inner, arbiter }
}
}
impl<F: Future> Future for CatchPanicFuture<F> {
type Output = F::Output;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
match catch_unwind(AssertUnwindSafe(|| this.inner.poll(cx))) {
Ok(result) => result,
Err(panic) => {
error!(
panic = extract_panic_msg(&panic),
"service future panicked, shutting down immediately"
);
let arbiter = this.arbiter.clone();
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
resume_unwind(panic);
}
}
}
}
#[cfg(test)]
mod tests {
use std::{
convert::Infallible,
io,
panic::{AssertUnwindSafe, panic_any},
task::{Context, Poll},
};
use ak_common::{Arbiter, Tasks};
use axum_server::accept::Accept;
use futures::{
FutureExt as _,
future::{BoxFuture, poll_fn},
};
use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream, ReadBuf, duplex},
time::{Duration, timeout},
};
use tower::Service;
use super::{CatchPanicAcceptor, CatchPanicService, CatchPanicStream};
fn duplex_stream() -> DuplexStream {
let (stream, _peer) = duplex(1024);
stream
}
/// Returns `true` if the arbiter's fast-shutdown has already been triggered.
async fn fast_shutdown_triggered(arbiter: &Arbiter) -> bool {
timeout(Duration::from_millis(50), arbiter.fast_shutdown())
.await
.is_ok()
}
#[derive(Clone)]
struct OkAcceptor;
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for OkAcceptor {
type Future = BoxFuture<'static, io::Result<(I, S)>>;
type Service = S;
type Stream = I;
fn accept(&self, stream: I, service: S) -> Self::Future {
Box::pin(async move { Ok((stream, service)) })
}
}
#[derive(Clone)]
struct ErrorAcceptor;
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for ErrorAcceptor {
type Future = BoxFuture<'static, io::Result<(I, S)>>;
type Service = S;
type Stream = I;
fn accept(&self, _stream: I, _service: S) -> Self::Future {
Box::pin(async move { Err(io::Error::other("inner error")) })
}
}
/// Panics with a `&'static str` payload.
#[derive(Clone)]
struct PanicStrAcceptor;
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicStrAcceptor {
type Future = BoxFuture<'static, io::Result<(I, S)>>;
type Service = S;
type Stream = I;
fn accept(&self, _stream: I, _service: S) -> Self::Future {
Box::pin(async move { panic!("str panic message") })
}
}
/// Panics with a `String` payload.
#[derive(Clone)]
struct PanicStringAcceptor;
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicStringAcceptor {
type Future = BoxFuture<'static, io::Result<(I, S)>>;
type Service = S;
type Stream = I;
fn accept(&self, _stream: I, _service: S) -> Self::Future {
Box::pin(async move {
let msg = "string panic message".to_owned();
panic_any(msg)
})
}
}
/// Panics with a payload that is neither `&str` nor `String`.
#[derive(Clone)]
struct PanicUnknownAcceptor;
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicUnknownAcceptor {
type Future = BoxFuture<'static, io::Result<(I, S)>>;
type Service = S;
type Stream = I;
fn accept(&self, _stream: I, _service: S) -> Self::Future {
Box::pin(async move { panic_any(42u32) })
}
}
struct PanicStream;
impl tokio::io::AsyncRead for PanicStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
panic!("poll_read panic")
}
}
impl tokio::io::AsyncWrite for PanicStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &[u8],
) -> Poll<io::Result<usize>> {
panic!("poll_write panic")
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
panic!("poll_flush panic")
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
panic!("poll_shutdown panic")
}
}
#[derive(Clone)]
struct OkService;
impl Service<()> for OkService {
type Error = Infallible;
type Future = futures::future::Ready<Result<(), Infallible>>;
type Response = ();
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: ()) -> Self::Future {
futures::future::ready(Ok(()))
}
}
struct PanicPollReadyService;
impl Service<()> for PanicPollReadyService {
type Error = Infallible;
type Future = futures::future::Ready<Result<(), Infallible>>;
type Response = ();
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
panic!("poll_ready panic")
}
fn call(&mut self, _req: ()) -> Self::Future {
unreachable!()
}
}
struct PanicCallBodyService;
impl Service<()> for PanicCallBodyService {
type Error = Infallible;
type Future = futures::future::Ready<Result<(), Infallible>>;
type Response = ();
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: ()) -> Self::Future {
panic!("call body panic")
}
}
struct PanicFuture;
impl Future for PanicFuture {
type Output = Result<(), Infallible>;
fn poll(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
panic!("future panic")
}
}
struct PanicCallFutureService;
impl Service<()> for PanicCallFutureService {
type Error = Infallible;
type Future = PanicFuture;
type Response = ();
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: ()) -> Self::Future {
PanicFuture
}
}
#[tokio::test]
async fn acceptor_passes_through_success() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let acceptor = CatchPanicAcceptor::new(OkAcceptor, arbiter.clone());
let result = acceptor.accept(duplex_stream(), OkService).await;
assert!(result.is_ok());
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn acceptor_passes_through_error() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let acceptor = CatchPanicAcceptor::new(ErrorAcceptor, arbiter.clone());
let result = acceptor.accept(duplex_stream(), OkService).await;
assert!(result.is_err());
assert_eq!(result.err().unwrap().to_string(), "inner error");
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn acceptor_catches_str_panic_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let acceptor = CatchPanicAcceptor::new(PanicStrAcceptor, arbiter.clone());
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn acceptor_catches_string_panic_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let acceptor = CatchPanicAcceptor::new(PanicStringAcceptor, arbiter.clone());
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn acceptor_catches_unknown_panic_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let acceptor = CatchPanicAcceptor::new(PanicUnknownAcceptor, arbiter.clone());
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_read_passes_through() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let (mut a, mut b) = duplex(1024);
b.write_all(b"hello").await.unwrap();
let mut stream = CatchPanicStream::new(&mut a, arbiter.clone());
let mut buf = [0u8; 5];
let result = stream.read(&mut buf).await;
assert!(result.is_ok());
assert_eq!(&buf, b"hello");
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_read_panic_returns_error_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
let result = AssertUnwindSafe(stream.read(&mut [0u8; 10]))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_write_passes_through() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let (mut a, _b) = duplex(1024);
let mut stream = CatchPanicStream::new(&mut a, arbiter.clone());
let result = stream.write_all(b"hello").await;
assert!(result.is_ok());
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_write_panic_returns_error_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
let result = AssertUnwindSafe(stream.write(b"hello"))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_flush_panic_returns_error_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
let result = AssertUnwindSafe(stream.flush()).catch_unwind().await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn stream_poll_shutdown_panic_returns_error_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
let result = AssertUnwindSafe(stream.shutdown()).catch_unwind().await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn service_poll_ready_passes_through() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut service = CatchPanicService::new(OkService, arbiter.clone());
let result = poll_fn(|cx| service.poll_ready(cx)).await;
assert!(result.is_ok());
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn service_poll_ready_panic_re_panics_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut service = CatchPanicService::new(PanicPollReadyService, arbiter.clone());
let result = AssertUnwindSafe(poll_fn(|cx| service.poll_ready(cx)))
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn service_call_passes_through() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut service = CatchPanicService::new(OkService, arbiter.clone());
let result = service.call(()).await;
assert!(result.is_ok());
assert!(!fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn service_call_body_panic_re_panics_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut service = CatchPanicService::new(PanicCallBodyService, arbiter.clone());
let result = AssertUnwindSafe(async { service.call(()).await })
.catch_unwind()
.await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
#[tokio::test]
async fn service_call_future_panic_re_panics_and_shuts_down() {
let tasks = Tasks::new().expect("failed to create tasks");
let arbiter = tasks.arbiter();
let mut service = CatchPanicService::new(PanicCallFutureService, arbiter.clone());
let result = AssertUnwindSafe(service.call(())).catch_unwind().await;
assert!(result.is_err());
assert!(fast_shutdown_triggered(&arbiter).await);
}
}

View File

@@ -1,2 +1,3 @@
pub mod catch_panic;
pub mod proxy_protocol;
pub mod tls;

View File

@@ -1,7 +1,13 @@
//! Utilities for working with [`Router`].
use ak_common::config;
use axum::{Router, http::StatusCode, middleware::from_fn};
use axum::{
Router,
extract::Request,
http::{HeaderName, HeaderValue, StatusCode},
middleware::{Next, from_fn},
response::Response,
};
use tower::ServiceBuilder;
use tower_http::timeout::TimeoutLayer;
@@ -13,6 +19,16 @@ use crate::{
tracing::{span_middleware, tracing_middleware},
};
const X_POWERED_BY: HeaderName = HeaderName::from_static("x-powered-by");
async fn powered_by_authentik_middleware(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert(X_POWERED_BY, HeaderValue::from_static("authentik"));
response
}
/// Wrap a [`Router`] with common middlewares.
///
/// Set `with_tracing` to [`true`] to log requests.
@@ -30,6 +46,7 @@ pub fn wrap_router(router: Router, with_tracing: bool) -> Router {
timeout,
))
.layer(from_fn(span_middleware))
.layer(from_fn(powered_by_authentik_middleware))
.layer(from_fn(trusted_proxy_middleware))
.layer(from_fn(client_ip_middleware))
.layer(from_fn(scheme_middleware))

View File

@@ -12,7 +12,9 @@ use axum_server::{
use eyre::Result;
use tracing::{info, trace};
use crate::accept::{proxy_protocol::ProxyProtocolAcceptor, tls::TlsAcceptor};
use crate::accept::{
catch_panic::CatchPanicAcceptor, proxy_protocol::ProxyProtocolAcceptor, tls::TlsAcceptor,
};
async fn run_plain(
arbiter: Arbiter,
@@ -27,7 +29,10 @@ async fn run_plain(
arbiter.add_net_handle(handle.clone()).await;
let res = axum_server::Server::bind(addr)
.acceptor(ProxyProtocolAcceptor::new().acceptor(DefaultAcceptor::new()))
.acceptor(CatchPanicAcceptor::new(
ProxyProtocolAcceptor::new().acceptor(DefaultAcceptor::new()),
arbiter.clone(),
))
.handle(handle)
.serve(router.into_make_service_with_connect_info::<net::SocketAddr>())
.await;
@@ -80,7 +85,10 @@ pub(crate) async fn run_unix(
}
}
let res = axum_server::Server::bind(addr.clone())
.acceptor(DefaultAcceptor::new())
.acceptor(CatchPanicAcceptor::new(
DefaultAcceptor::new(),
arbiter.clone(),
))
.handle(handle)
.serve(router.into_make_service())
.await;
@@ -133,9 +141,12 @@ async fn run_tls(
arbiter.add_net_handle(handle.clone()).await;
axum_server::Server::bind(addr)
.acceptor(ProxyProtocolAcceptor::new().acceptor(TlsAcceptor::new(
RustlsAcceptor::new(config).acceptor(DefaultAcceptor::new()),
)))
.acceptor(CatchPanicAcceptor::new(
ProxyProtocolAcceptor::new().acceptor(TlsAcceptor::new(
RustlsAcceptor::new(config).acceptor(DefaultAcceptor::new()),
)),
arbiter.clone(),
))
.handle(handle)
.serve(router.into_make_service_with_connect_info::<net::SocketAddr>())
.await?;

View File

@@ -235,7 +235,7 @@ impl Arbiter {
}
/// Shutdown the application immediately.
async fn do_fast_shutdown(&self) {
pub async fn do_fast_shutdown(&self) {
info!("arbiter has been told to shutdown immediately");
self.unix_handles
.lock()
@@ -253,7 +253,7 @@ impl Arbiter {
}
/// Shutdown the application gracefully.
async fn do_graceful_shutdown(&self) {
pub async fn do_graceful_shutdown(&self) {
info!("arbiter has been told to shutdown gracefully");
// Match the value in lifecycle/gunicorn.conf.py for graceful shutdown
let timeout = Some(Duration::from_secs(30 + 5));

View File

@@ -16,7 +16,10 @@ use url::Url;
pub mod schema;
pub use schema::Config;
use crate::arbiter::{Arbiter, Event, Tasks};
use crate::{
arbiter::{Arbiter, Event, Tasks},
config::schema::KEYS_TO_PARSE_AS_LIST,
};
static DEFAULT_CONFIG: &str = include_str!("../../../../authentik/lib/default.yml");
static CONFIG_MANAGER: OnceLock<ConfigManager> = OnceLock::new();
@@ -75,11 +78,15 @@ impl Config {
config_rs::File::from(path.as_path()).format(config_rs::FileFormat::Yaml),
);
}
builder = builder.add_source(
config_rs::Environment::with_prefix("AUTHENTIK")
.prefix_separator("_")
.separator("__"),
);
let mut env_source = config_rs::Environment::with_prefix("AUTHENTIK")
.prefix_separator("_")
.separator("__")
.try_parsing(true)
.list_separator(",");
for key in KEYS_TO_PARSE_AS_LIST {
env_source = env_source.with_list_parse_key(key);
}
builder = builder.add_source(env_source);
if let Some(overrides) = overrides {
builder = builder.add_source(config_rs::File::from_str(
&overrides.to_string(),
@@ -455,4 +462,92 @@ mod tests {
super::set(json!({"secret_key": "my_new_secret_key"})).expect("failed to set config");
assert_eq!(super::get().secret_key, "my_new_secret_key");
}
#[test]
fn env_bool_true() {
#[expect(unsafe_code, reason = "testing")]
// SAFETY: testing
unsafe {
env::set_var("AUTHENTIK_DEBUG", "true");
}
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
assert!(config.debug);
}
#[test]
fn env_bool_false() {
#[expect(unsafe_code, reason = "testing")]
// SAFETY: testing
unsafe {
env::set_var("AUTHENTIK_DEBUG", "false");
}
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
assert!(!config.debug);
}
// See https://github.com/rust-cli/config-rs/issues/443
// #[test]
// fn env_list_empty() {
// #[expect(unsafe_code, reason = "testing")]
// // SAFETY: testing
// unsafe {
// env::set_var("AUTHENTIK_LISTEN__HTTP", "");
// }
//
// let (config, _) = super::Config::load(&[], None).expect("failed to load config");
//
// assert_eq!(config.listen.http, []);
// }
#[test]
fn env_list_one_element() {
#[expect(unsafe_code, reason = "testing")]
// SAFETY: testing
unsafe {
env::set_var("AUTHENTIK_LISTEN__HTTP", "[::1]:9000");
}
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
assert_eq!(
config.listen.http,
["[::1]:9000".parse().expect("infallible")]
);
}
#[test]
fn env_list_many_elements() {
#[expect(unsafe_code, reason = "testing")]
// SAFETY: testing
unsafe {
env::set_var("AUTHENTIK_LISTEN__HTTP", "[::1]:9000,[::1]:9001");
}
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
assert_eq!(
config.listen.http,
[
"[::1]:9000".parse().expect("infallible"),
"[::1]:9001".parse().expect("infallible")
]
);
}
#[test]
fn env_string() {
#[expect(unsafe_code, reason = "testing")]
// SAFETY: testing
unsafe {
env::set_var("AUTHENTIK_SECRET_KEY", "my_secret_key");
}
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
assert_eq!(config.secret_key, "my_secret_key",);
}
}

View File

@@ -3,6 +3,13 @@ use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize};
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
pub(super) const KEYS_TO_PARSE_AS_LIST: [&str; 4] = [
"listen.http",
"listen.metrics",
"listen.trusted_proxy_cidrs",
"log.http_headers",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub postgresql: PostgreSQLConfig,

View File

@@ -634,7 +634,7 @@ export interface ProvidersSamlListRequest {
encryptionKp?: string;
invalidationFlow?: string;
isBackchannel?: boolean;
issuer?: string;
issuerOverride?: string;
logoutMethod?: SAMLLogoutMethods;
name?: string;
nameIdMapping?: string;
@@ -841,7 +841,7 @@ export interface ProvidersWsfedListRequest {
encryptionKp?: string;
invalidationFlow?: string;
isBackchannel?: boolean;
issuer?: string;
issuerOverride?: string;
logoutMethod?: SAMLLogoutMethods;
name?: string;
nameIdMapping?: string;
@@ -6842,8 +6842,8 @@ export class ProvidersApi extends runtime.BaseAPI {
queryParameters["is_backchannel"] = requestParameters["isBackchannel"];
}
if (requestParameters["issuer"] != null) {
queryParameters["issuer"] = requestParameters["issuer"];
if (requestParameters["issuerOverride"] != null) {
queryParameters["issuer_override"] = requestParameters["issuerOverride"];
}
if (requestParameters["logoutMethod"] != null) {
@@ -9326,8 +9326,8 @@ export class ProvidersApi extends runtime.BaseAPI {
queryParameters["is_backchannel"] = requestParameters["isBackchannel"];
}
if (requestParameters["issuer"] != null) {
queryParameters["issuer"] = requestParameters["issuer"];
if (requestParameters["issuerOverride"] != null) {
queryParameters["issuer_override"] = requestParameters["issuerOverride"];
}
if (requestParameters["logoutMethod"] != null) {

View File

@@ -127,6 +127,12 @@ export interface Application {
* @memberof Application
*/
group?: string;
/**
* Hide this application from the user's My applications page.
* @type {boolean}
* @memberof Application
*/
metaHide?: boolean;
}
/**
@@ -177,6 +183,7 @@ export function ApplicationFromJSONTyped(json: any, ignoreDiscriminator: boolean
? undefined
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
group: json["group"] == null ? undefined : json["group"],
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
};
}
@@ -212,5 +219,6 @@ export function ApplicationToJSONTyped(
meta_publisher: value["metaPublisher"],
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
group: value["group"],
meta_hide: value["metaHide"],
};
}

View File

@@ -87,6 +87,12 @@ export interface ApplicationRequest {
* @memberof ApplicationRequest
*/
group?: string;
/**
* Hide this application from the user's My applications page.
* @type {boolean}
* @memberof ApplicationRequest
*/
metaHide?: boolean;
}
/**
@@ -125,6 +131,7 @@ export function ApplicationRequestFromJSONTyped(
? undefined
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
group: json["group"] == null ? undefined : json["group"],
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
};
}
@@ -152,5 +159,6 @@ export function ApplicationRequestToJSONTyped(
meta_publisher: value["metaPublisher"],
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
group: value["group"],
meta_hide: value["metaHide"],
};
}

View File

@@ -22,6 +22,7 @@ export const CompatibilityModeEnum = {
Slack: "slack",
Sfdc: "sfdc",
Webex: "webex",
Vcenter: "vcenter",
UnknownDefaultOpenApi: "11184809",
} as const;
export type CompatibilityModeEnum =

View File

@@ -0,0 +1,62 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
*/
export const GrantTypesEnum = {
AuthorizationCode: "authorization_code",
Implicit: "implicit",
Hybrid: "hybrid",
RefreshToken: "refresh_token",
ClientCredentials: "client_credentials",
Password: "password",
UrnIetfParamsOauthGrantTypeDeviceCode: "urn:ietf:params:oauth:grant-type:device_code",
UnknownDefaultOpenApi: "11184809",
} as const;
export type GrantTypesEnum = (typeof GrantTypesEnum)[keyof typeof GrantTypesEnum];
export function instanceOfGrantTypesEnum(value: any): boolean {
for (const key in GrantTypesEnum) {
if (Object.prototype.hasOwnProperty.call(GrantTypesEnum, key)) {
if (GrantTypesEnum[key as keyof typeof GrantTypesEnum] === value) {
return true;
}
}
}
return false;
}
export function GrantTypesEnumFromJSON(json: any): GrantTypesEnum {
return GrantTypesEnumFromJSONTyped(json, false);
}
export function GrantTypesEnumFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): GrantTypesEnum {
return json as GrantTypesEnum;
}
export function GrantTypesEnumToJSON(value?: GrantTypesEnum | null): any {
return value as any;
}
export function GrantTypesEnumToJSONTyped(
value: any,
ignoreDiscriminator: boolean,
): GrantTypesEnum {
return value as GrantTypesEnum;
}

View File

@@ -14,6 +14,8 @@
import type { ClientTypeEnum } from "./ClientTypeEnum";
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
import type { GrantTypesEnum } from "./GrantTypesEnum";
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
import type { IssuerModeEnum } from "./IssuerModeEnum";
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
@@ -122,6 +124,12 @@ export interface OAuth2Provider {
* @memberof OAuth2Provider
*/
clientType?: ClientTypeEnum;
/**
*
* @type {Array<GrantTypesEnum>}
* @memberof OAuth2Provider
*/
grantTypes?: Array<GrantTypesEnum>;
/**
*
* @type {string}
@@ -279,6 +287,10 @@ export function OAuth2ProviderFromJSONTyped(
metaModelName: json["meta_model_name"],
clientType:
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
grantTypes:
json["grant_types"] == null
? undefined
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
clientId: json["client_id"] == null ? undefined : json["client_id"],
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
accessCodeValidity:
@@ -341,6 +353,10 @@ export function OAuth2ProviderToJSONTyped(
invalidation_flow: value["invalidationFlow"],
property_mappings: value["propertyMappings"],
client_type: ClientTypeEnumToJSON(value["clientType"]),
grant_types:
value["grantTypes"] == null
? undefined
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
client_id: value["clientId"],
client_secret: value["clientSecret"],
access_code_validity: value["accessCodeValidity"],

View File

@@ -14,6 +14,8 @@
import type { ClientTypeEnum } from "./ClientTypeEnum";
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
import type { GrantTypesEnum } from "./GrantTypesEnum";
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
import type { IssuerModeEnum } from "./IssuerModeEnum";
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
@@ -68,6 +70,12 @@ export interface OAuth2ProviderRequest {
* @memberof OAuth2ProviderRequest
*/
clientType?: ClientTypeEnum;
/**
*
* @type {Array<GrantTypesEnum>}
* @memberof OAuth2ProviderRequest
*/
grantTypes?: Array<GrantTypesEnum>;
/**
*
* @type {string}
@@ -197,6 +205,10 @@ export function OAuth2ProviderRequestFromJSONTyped(
propertyMappings: json["property_mappings"] == null ? undefined : json["property_mappings"],
clientType:
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
grantTypes:
json["grant_types"] == null
? undefined
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
clientId: json["client_id"] == null ? undefined : json["client_id"],
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
accessCodeValidity:
@@ -248,6 +260,10 @@ export function OAuth2ProviderRequestToJSONTyped(
invalidation_flow: value["invalidationFlow"],
property_mappings: value["propertyMappings"],
client_type: ClientTypeEnumToJSON(value["clientType"]),
grant_types:
value["grantTypes"] == null
? undefined
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
client_id: value["clientId"],
client_secret: value["clientSecret"],
access_code_validity: value["accessCodeValidity"],

View File

@@ -87,6 +87,12 @@ export interface PatchedApplicationRequest {
* @memberof PatchedApplicationRequest
*/
group?: string;
/**
* Hide this application from the user's My applications page.
* @type {boolean}
* @memberof PatchedApplicationRequest
*/
metaHide?: boolean;
}
/**
@@ -125,6 +131,7 @@ export function PatchedApplicationRequestFromJSONTyped(
? undefined
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
group: json["group"] == null ? undefined : json["group"],
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
};
}
@@ -152,5 +159,6 @@ export function PatchedApplicationRequestToJSONTyped(
meta_publisher: value["metaPublisher"],
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
group: value["group"],
meta_hide: value["metaHide"],
};
}

View File

@@ -14,6 +14,8 @@
import type { ClientTypeEnum } from "./ClientTypeEnum";
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
import type { GrantTypesEnum } from "./GrantTypesEnum";
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
import type { IssuerModeEnum } from "./IssuerModeEnum";
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
@@ -68,6 +70,12 @@ export interface PatchedOAuth2ProviderRequest {
* @memberof PatchedOAuth2ProviderRequest
*/
clientType?: ClientTypeEnum;
/**
*
* @type {Array<GrantTypesEnum>}
* @memberof PatchedOAuth2ProviderRequest
*/
grantTypes?: Array<GrantTypesEnum>;
/**
*
* @type {string}
@@ -196,6 +204,10 @@ export function PatchedOAuth2ProviderRequestFromJSONTyped(
propertyMappings: json["property_mappings"] == null ? undefined : json["property_mappings"],
clientType:
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
grantTypes:
json["grant_types"] == null
? undefined
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
clientId: json["client_id"] == null ? undefined : json["client_id"],
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
accessCodeValidity:
@@ -250,6 +262,10 @@ export function PatchedOAuth2ProviderRequestToJSONTyped(
invalidation_flow: value["invalidationFlow"],
property_mappings: value["propertyMappings"],
client_type: ClientTypeEnumToJSON(value["clientType"]),
grant_types:
value["grantTypes"] == null
? undefined
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
client_id: value["clientId"],
client_secret: value["clientSecret"],
access_code_validity: value["accessCodeValidity"],

View File

@@ -81,11 +81,11 @@ export interface PatchedSAMLProviderRequest {
*/
audience?: string;
/**
* Also known as EntityID
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
* @type {string}
* @memberof PatchedSAMLProviderRequest
*/
issuer?: string;
issuerOverride?: string;
/**
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
* @type {string}
@@ -233,7 +233,7 @@ export function PatchedSAMLProviderRequestFromJSONTyped(
acsUrl: json["acs_url"] == null ? undefined : json["acs_url"],
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
audience: json["audience"] == null ? undefined : json["audience"],
issuer: json["issuer"] == null ? undefined : json["issuer"],
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
assertionValidNotBefore:
json["assertion_valid_not_before"] == null
? undefined
@@ -306,7 +306,7 @@ export function PatchedSAMLProviderRequestToJSONTyped(
acs_url: value["acsUrl"],
sls_url: value["slsUrl"],
audience: value["audience"],
issuer: value["issuer"],
issuer_override: value["issuerOverride"],
assertion_valid_not_before: value["assertionValidNotBefore"],
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],

View File

@@ -135,11 +135,11 @@ export interface SAMLProvider {
*/
audience?: string;
/**
* Also known as EntityID
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
* @type {string}
* @memberof SAMLProvider
*/
issuer?: string;
issuerOverride?: string;
/**
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
* @type {string}
@@ -260,6 +260,24 @@ export interface SAMLProvider {
* @memberof SAMLProvider
*/
readonly urlDownloadMetadata: string;
/**
* Get Issuer/EntityID URL
* @type {string}
* @memberof SAMLProvider
*/
readonly urlIssuer: string;
/**
* Get unified SAML endpoint URL (handles SSO and SLO)
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnified: string;
/**
* Get IdP-initiated SAML URL
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnifiedInit: string;
/**
* Get SSO Post URL
* @type {string}
@@ -321,6 +339,9 @@ export function instanceOfSAMLProvider(value: object): value is SAMLProvider {
if (!("acsUrl" in value) || value["acsUrl"] === undefined) return false;
if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined)
return false;
if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false;
if (!("urlUnified" in value) || value["urlUnified"] === undefined) return false;
if (!("urlUnifiedInit" in value) || value["urlUnifiedInit"] === undefined) return false;
if (!("urlSsoPost" in value) || value["urlSsoPost"] === undefined) return false;
if (!("urlSsoRedirect" in value) || value["urlSsoRedirect"] === undefined) return false;
if (!("urlSsoInit" in value) || value["urlSsoInit"] === undefined) return false;
@@ -356,7 +377,7 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
acsUrl: json["acs_url"],
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
audience: json["audience"] == null ? undefined : json["audience"],
issuer: json["issuer"] == null ? undefined : json["issuer"],
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
assertionValidNotBefore:
json["assertion_valid_not_before"] == null
? undefined
@@ -406,6 +427,9 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
? undefined
: SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]),
urlDownloadMetadata: json["url_download_metadata"],
urlIssuer: json["url_issuer"],
urlUnified: json["url_unified"],
urlUnifiedInit: json["url_unified_init"],
urlSsoPost: json["url_sso_post"],
urlSsoRedirect: json["url_sso_redirect"],
urlSsoInit: json["url_sso_init"],
@@ -431,6 +455,9 @@ export function SAMLProviderToJSONTyped(
| "verbose_name_plural"
| "meta_model_name"
| "url_download_metadata"
| "url_issuer"
| "url_unified"
| "url_unified_init"
| "url_sso_post"
| "url_sso_redirect"
| "url_sso_init"
@@ -452,7 +479,7 @@ export function SAMLProviderToJSONTyped(
acs_url: value["acsUrl"],
sls_url: value["slsUrl"],
audience: value["audience"],
issuer: value["issuer"],
issuer_override: value["issuerOverride"],
assertion_valid_not_before: value["assertionValidNotBefore"],
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],

View File

@@ -81,11 +81,11 @@ export interface SAMLProviderRequest {
*/
audience?: string;
/**
* Also known as EntityID
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
* @type {string}
* @memberof SAMLProviderRequest
*/
issuer?: string;
issuerOverride?: string;
/**
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
* @type {string}
@@ -234,7 +234,7 @@ export function SAMLProviderRequestFromJSONTyped(
acsUrl: json["acs_url"],
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
audience: json["audience"] == null ? undefined : json["audience"],
issuer: json["issuer"] == null ? undefined : json["issuer"],
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
assertionValidNotBefore:
json["assertion_valid_not_before"] == null
? undefined
@@ -307,7 +307,7 @@ export function SAMLProviderRequestToJSONTyped(
acs_url: value["acsUrl"],
sls_url: value["slsUrl"],
audience: value["audience"],
issuer: value["issuer"],
issuer_override: value["issuerOverride"],
assertion_valid_not_before: value["assertionValidNotBefore"],
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],

View File

@@ -221,6 +221,7 @@ export * from "./GoogleWorkspaceProviderMappingRequest";
export * from "./GoogleWorkspaceProviderRequest";
export * from "./GoogleWorkspaceProviderUser";
export * from "./GoogleWorkspaceProviderUserRequest";
export * from "./GrantTypesEnum";
export * from "./Group";
export * from "./GroupKerberosSourceConnection";
export * from "./GroupKerberosSourceConnectionRequest";

View File

@@ -9,7 +9,7 @@ dependencies = [
"argon2-cffi==25.1.0",
"cachetools==7.0.6",
"channels==4.3.2",
"cryptography==46.0.7",
"cryptography==47.0.0",
"dacite==1.9.2",
"deepmerge==2.0",
"defusedxml==0.7.1",
@@ -44,7 +44,7 @@ dependencies = [
"kubernetes==35.0.0",
"ldap3==2.9.1",
"lxml==6.1.0",
"msgraph-sdk==1.55.0",
"msgraph-sdk==1.56.0",
"opencontainers==0.0.15",
"packaging==26.1",
"paramiko==4.0.0",
@@ -92,18 +92,18 @@ dev = [
"importlib-metadata==8.7.1",
"k5test==0.10.4",
"lxml-stubs==0.5.1",
"mypy==1.20.1",
"mypy==1.20.2",
"pdoc==16.0.0",
"pytest-django==4.12.0",
"pytest-flakefinder==1.1.0",
"pytest-github-actions-annotate-failures==0.4.0",
"pytest-randomly==4.0.1",
"pytest-randomly==4.1.0",
"pytest-timeout==2.4.0",
"pytest==9.0.3",
"requests-mock==1.12.1",
"ruff==0.15.11",
"ruff==0.15.12",
"selenium==4.43.0",
"types-channels==4.3.0.20260408",
"types-channels==4.3.0.20260421",
"types-docker==7.1.0.20260409",
"types-jwcrypto==1.5.7.20260409",
"types-ldap3==2.9.13.20260408",

View File

@@ -18919,7 +18919,7 @@ paths:
schema:
type: boolean
- in: query
name: issuer
name: issuer_override
schema:
type: string
- in: query
@@ -20078,7 +20078,7 @@ paths:
schema:
type: boolean
- in: query
name: issuer
name: issuer_override
schema:
type: string
- in: query
@@ -34111,6 +34111,9 @@ components:
$ref: '#/components/schemas/PolicyEngineMode'
group:
type: string
meta_hide:
type: boolean
description: Hide this application from the user's My applications page.
required:
- backchannel_providers_obj
- launch_url
@@ -34192,6 +34195,9 @@ components:
$ref: '#/components/schemas/PolicyEngineMode'
group:
type: string
meta_hide:
type: boolean
description: Hide this application from the user's My applications page.
required:
- name
- slug
@@ -36180,6 +36186,7 @@ components:
- slack
- sfdc
- webex
- vcenter
type: string
Config:
type: object
@@ -39976,6 +39983,16 @@ components:
- google_id
- provider
- user
GrantTypesEnum:
enum:
- authorization_code
- implicit
- hybrid
- refresh_token
- client_credentials
- password
- urn:ietf:params:oauth:grant-type:device_code
type: string
Group:
type: object
description: Group Serializer
@@ -43635,6 +43652,10 @@ components:
- $ref: '#/components/schemas/ClientTypeEnum'
description: Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable
grant_types:
type: array
items:
$ref: '#/components/schemas/GrantTypesEnum'
client_id:
type: string
maxLength: 255
@@ -43756,6 +43777,10 @@ components:
- $ref: '#/components/schemas/ClientTypeEnum'
description: Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable
grant_types:
type: array
items:
$ref: '#/components/schemas/GrantTypesEnum'
client_id:
type: string
minLength: 1
@@ -47409,6 +47434,9 @@ components:
$ref: '#/components/schemas/PolicyEngineMode'
group:
type: string
meta_hide:
type: boolean
description: Hide this application from the user's My applications page.
PatchedAuthenticatorDuoStageRequest:
type: object
description: AuthenticatorDuoStage Serializer
@@ -49323,6 +49351,10 @@ components:
- $ref: '#/components/schemas/ClientTypeEnum'
description: Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable
grant_types:
type: array
items:
$ref: '#/components/schemas/GrantTypesEnum'
client_id:
type: string
minLength: 1
@@ -50170,10 +50202,10 @@ components:
type: string
description: Value of the audience restriction field of the assertion. When
left empty, no audience restriction will be added.
issuer:
issuer_override:
type: string
minLength: 1
description: Also known as EntityID
description: Also known as EntityID. Providing a value overrides the default
issuer generated by authentik.
assertion_valid_not_before:
type: string
minLength: 1
@@ -53692,9 +53724,10 @@ components:
type: string
description: Value of the audience restriction field of the assertion. When
left empty, no audience restriction will be added.
issuer:
issuer_override:
type: string
description: Also known as EntityID
description: Also known as EntityID. Providing a value overrides the default
issuer generated by authentik.
assertion_valid_not_before:
type: string
description: 'Assertion valid not before current time + this value (Format:
@@ -53784,6 +53817,18 @@ components:
type: string
description: Get metadata download URL
readOnly: true
url_issuer:
type: string
description: Get Issuer/EntityID URL
readOnly: true
url_unified:
type: string
description: Get unified SAML endpoint URL (handles SSO and SLO)
readOnly: true
url_unified_init:
type: string
description: Get IdP-initiated SAML URL
readOnly: true
url_sso_post:
type: string
description: Get SSO Post URL
@@ -53817,11 +53862,14 @@ components:
- name
- pk
- url_download_metadata
- url_issuer
- url_slo_post
- url_slo_redirect
- url_sso_init
- url_sso_post
- url_sso_redirect
- url_unified
- url_unified_init
- verbose_name
- verbose_name_plural
SAMLProviderImportRequest:
@@ -53884,10 +53932,10 @@ components:
type: string
description: Value of the audience restriction field of the assertion. When
left empty, no audience restriction will be added.
issuer:
issuer_override:
type: string
minLength: 1
description: Also known as EntityID
description: Also known as EntityID. Providing a value overrides the default
issuer generated by authentik.
assertion_valid_not_before:
type: string
minLength: 1

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