Compare commits

...

104 Commits

Author SHA1 Message Date
42a99e3672 add api
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
4e501f2fbf better endpoint
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
1cca629464 fix session
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
4efdc3113e give session
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
5a9b0f7b7a it works
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
395ccc5af1 format
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:16 +02:00
c8ac4fcdd6 snap
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:15 +02:00
53c36394e9 init
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:31:14 +02:00
c7fe987c5a core: fix missing serializer on AuthenticatedSession (#15323)
fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-30 21:30:34 +02:00
e48739c8a0 stages/email: remove superflous <td> from account_confirmation template (#15297)
🐛 FIX: stages/email: remove superflous <td> from account_confirmation template

Signed-off-by: Jonas Sulzer <jonas@violoncello.ch>
2025-06-30 11:02:52 +00:00
b2ee585c43 website: bump @types/node from 24.0.4 to 24.0.7 in /website (#15307)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.0.4 to 24.0.7.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.7
  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>
2025-06-30 12:37:30 +02:00
97e8ea8e76 website: bump prettier-plugin-packagejson from 2.5.16 to 2.5.17 in /website (#15308)
website: bump prettier-plugin-packagejson in /website

Bumps [prettier-plugin-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson) from 2.5.16 to 2.5.17.
- [Release notes](https://github.com/matzkoh/prettier-plugin-packagejson/releases)
- [Commits](https://github.com/matzkoh/prettier-plugin-packagejson/compare/v2.5.16...v2.5.17)

---
updated-dependencies:
- dependency-name: prettier-plugin-packagejson
  dependency-version: 2.5.17
  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>
2025-06-30 12:37:11 +02:00
1f1e0c9db1 website: bump the eslint group in /website with 2 updates (#15309)
Bumps the eslint group in /website with 2 updates: [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) and [eslint](https://github.com/eslint/eslint).


Updates `@eslint/js` from 9.29.0 to 9.30.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.30.0/packages/js)

Updates `eslint` from 9.29.0 to 9.30.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.29.0...v9.30.0)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.30.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: eslint
  dependency-version: 9.30.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 12:37:01 +02:00
ca47a803fe core: bump uvicorn[standard] from 0.34.3 to 0.35.0 (#15312)
Bumps [uvicorn[standard]](https://github.com/encode/uvicorn) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: uvicorn[standard]
  dependency-version: 0.35.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>
2025-06-30 12:36:45 +02:00
c606eb53b0 core: bump goauthentik.io/api/v3 from 3.2025062.6 to 3.2025063.1 (#15306)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 09:32:40 +00:00
62357133b0 core: bump astral-sh/uv from 0.7.15 to 0.7.17 (#15311)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 10:03:54 +02:00
99d2d91257 core: bump sentry-sdk from 2.31.0 to 2.32.0 (#15314)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 10:02:43 +02:00
69d9363fce core: bump axllent/mailpit from v1.26.2 to v1.27.0 in /tests/e2e (#15315)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 10:02:28 +02:00
cfc7f6b993 core, web: update translations (#15298)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-30 03:17:37 +02:00
bebbbe9b90 website/integrations: add omada controller doc (#14523)
* Adds omada controller doc and updated integration sidebar

* Update verification section

* WIP

* WIP

* Updated encoding section

* Finished document

* Applied suggestions

* Applied suggestions

* Entity ID changed to issuer

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/omada-controller/index.mdx

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* And then

* Remove errant :::

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-06-28 15:50:22 +00:00
188d3c69c1 Change issuer from zulip to authentik in zulip integration documentation (#15296)
I tried to follow this documentation. My zulip instance (running docker-zulip 10.3-0)  did not recognize the IdP when a user returned from authentik to zulip, until I changed the issuer in the authentik settings from `zulip.company` to `authentik.company`.

Signed-off-by: CSDUMMI <31551856+CSDUMMI@users.noreply.github.com>
2025-06-28 13:25:54 +01:00
877f312145 website/integrations: bookstack: fix redirect URI (#15295)
Signed-off-by: Dominic R <dominic@sdko.org>
2025-06-28 11:13:19 +01:00
f471a98bc7 website/integrations: fix typo in actual budget (#15293)
docs: Update index.mdx

Signed-off-by: Bryan J. <132493975+chkpwd@users.noreply.github.com>
2025-06-27 12:52:42 -05:00
e874cfc21d website: bump prettier from 3.6.1 to 3.6.2 in /website (#15280)
Bumps [prettier](https://github.com/prettier/prettier) from 3.6.1 to 3.6.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.6.1...3.6.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.6.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>
2025-06-27 17:04:52 +02:00
ec7bdf74aa core, web: update translations (#15278)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-27 14:56:51 +00:00
e87bc94b95 release: backport 2025.6.3 (#15292)
release: 2025.6.3
2025-06-27 16:21:18 +02:00
a3865abaa9 website: changelog for security releases (#15291)
* website: changelog for security releases

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

* format

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-27 15:42:02 +02:00
7100d3c674 security: fix CVE-2025-52553 (#15289)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-27 15:26:39 +02:00
c0c2d2ad3c website/docs: updated security release procedure (#15288)
* ci: skip translate compile

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

* ci: allow skipping build container for website

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

* update docs

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

* fix gha perms?

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-27 14:18:29 +02:00
dc287989db translate: Updates for file web/xliff/en.xlf in zh-Hans (#15285)
Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 14:02:01 +02:00
03204f6943 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#15284)
Translate django.po in zh-Hans

100% translated source file: 'django.po'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 14:01:48 +02:00
fcd369e466 translate: Updates for file web/xliff/en.xlf in zh_CN (#15283)
Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 14:01:38 +02:00
cb79407bc1 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#15282)
Translate locale/en/LC_MESSAGES/django.po in zh_CN

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 14:01:16 +02:00
04a88daf34 translate: Updates for file locale/en/LC_MESSAGES/django.po in it (#15273)
Translate locale/en/LC_MESSAGES/django.po in it

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'it'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 13:59:54 +02:00
c6a49da5c3 translate: Updates for file web/xliff/en.xlf in it (#15272)
Translate web/xliff/en.xlf in it

100% translated source file: 'web/xliff/en.xlf'
on 'it'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-27 13:59:35 +02:00
bfeeecf3fa ci: more adjustable mirror options (#15287)
* custom mirror which doesn't get rid of other branches

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

* add workflow for manual semi-release

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

* make ci work on internal

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-27 13:48:20 +02:00
d86b5e7c8a web/packages: NPM workspace: Mini Cleanup (#14767)
* web: Move non-workspace package.

* web: Update ESBuild package version.

* web: Use NPM link to alias local package.

* web: Update lock.

* web: Fix regression where bundler is expected.
2025-06-26 17:29:35 -04:00
a95776891e website/docs: add hint that flows need cookies (#15252)
* website/docs: add hint that flows need cookies

The executor itself does not set a session cookie, but requires
one to be set before. This took me days to figure out, so maybe
this will be helpful to somebody in the future.

Signed-off-by: Leonardo Mörlein <git@irrelefant.net>

* Update website/docs/developer-docs/api/flow-executor.md

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

---------

Signed-off-by: Leonardo Mörlein <git@irrelefant.net>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-06-26 15:40:40 +00:00
031158fdba website: bump prettier from 3.6.0 to 3.6.1 in /website (#15263)
Bumps [prettier](https://github.com/prettier/prettier) from 3.6.0 to 3.6.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.6.0...3.6.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.6.1
  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>
2025-06-26 14:51:40 +02:00
b2fbb92498 core: bump django-prometheus from 2.4.0 to 2.4.1 (#15253)
Bumps [django-prometheus](https://github.com/korfuri/django-prometheus) from 2.4.0 to 2.4.1.
- [Release notes](https://github.com/korfuri/django-prometheus/releases)
- [Changelog](https://github.com/django-commons/django-prometheus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/korfuri/django-prometheus/compare/v2.4.0...v2.4.1)

---
updated-dependencies:
- dependency-name: django-prometheus
  dependency-version: 2.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>
2025-06-26 14:51:29 +02:00
b1b6bf1a19 core, web: update translations (#15251)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-26 14:28:13 +02:00
179d9d0721 core: bump goauthentik.io/api/v3 from 3.2025062.5 to 3.2025062.6 (#15259)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025062.5 to 3.2025062.6.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025062.5...v3.2025062.6)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025062.6
  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>
2025-06-26 14:25:27 +02:00
8e94d58851 core: bump google-api-python-client from 2.173.0 to 2.174.0 (#15255)
Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.173.0 to 2.174.0.
- [Release notes](https://github.com/googleapis/google-api-python-client/releases)
- [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.173.0...v2.174.0)

---
updated-dependencies:
- dependency-name: google-api-python-client
  dependency-version: 2.174.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>
2025-06-26 14:25:10 +02:00
026669cfce core: bump msgraph-sdk from 1.34.0 to 1.35.0 (#15256)
Bumps [msgraph-sdk](https://github.com/microsoftgraph/msgraph-sdk-python) from 1.34.0 to 1.35.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.34.0...v1.35.0)

---
updated-dependencies:
- dependency-name: msgraph-sdk
  dependency-version: 1.35.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>
2025-06-26 14:25:01 +02:00
c83cea6963 core: bump astral-sh/uv from 0.7.14 to 0.7.15 (#15257)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.7.14 to 0.7.15.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.7.14...0.7.15)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.7.15
  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>
2025-06-26 14:24:38 +02:00
8e01cc2df8 lifecycle/aws: bump aws-cdk from 2.1019.1 to 2.1019.2 in /lifecycle/aws (#15258)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1019.1 to 2.1019.2.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1019.2/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1019.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>
2025-06-26 14:23:22 +02:00
279cec203d core: bump django-guardian from 3.0.0 to 3.0.3 (#15254)
Bumps [django-guardian](https://github.com/django-guardian/django-guardian) from 3.0.0 to 3.0.3.
- [Release notes](https://github.com/django-guardian/django-guardian/releases)
- [Commits](https://github.com/django-guardian/django-guardian/compare/3.0.0...3.0.3)

---
updated-dependencies:
- dependency-name: django-guardian
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 14:21:58 +02:00
41c5030c1e web: bump @sentry/browser from 9.31.0 to 9.32.0 in /web in the sentry group across 1 directory (#15260)
web: bump @sentry/browser in /web in the sentry group across 1 directory

Bumps the sentry group with 1 update in the /web directory: [@sentry/browser](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/browser` from 9.31.0 to 9.32.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/9.31.0...9.32.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 9.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 14:16:27 +02:00
3206fdb7ef website: bump the build group in /website with 6 updates (#15261)
Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.6` | `1.12.7` |


Updates `@swc/core-darwin-arm64` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

Updates `@swc/core-linux-arm64-gnu` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

Updates `@swc/core-linux-x64-gnu` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

Updates `@swc/html-darwin-arm64` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

Updates `@swc/html-linux-arm64-gnu` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

Updates `@swc/html-linux-x64-gnu` from 1.12.6 to 1.12.7
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.6...v1.12.7)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.12.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 14:16:18 +02:00
d7c0868eef website: bump @types/lodash from 4.17.18 to 4.17.19 in /website (#15262)
Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.17.18 to 4.17.19.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash)

---
updated-dependencies:
- dependency-name: "@types/lodash"
  dependency-version: 4.17.19
  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>
2025-06-26 14:16:01 +02:00
7d96a89697 website: bump prettier-plugin-packagejson from 2.5.15 to 2.5.16 in /website (#15264)
website: bump prettier-plugin-packagejson in /website

Bumps [prettier-plugin-packagejson](https://github.com/matzkoh/prettier-plugin-packagejson) from 2.5.15 to 2.5.16.
- [Release notes](https://github.com/matzkoh/prettier-plugin-packagejson/releases)
- [Commits](https://github.com/matzkoh/prettier-plugin-packagejson/compare/v2.5.15...v2.5.16)

---
updated-dependencies:
- dependency-name: prettier-plugin-packagejson
  dependency-version: 2.5.16
  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>
2025-06-26 14:15:45 +02:00
dfb0007777 translate: Updates for file web/xliff/en.xlf in it (#15266)
Translate web/xliff/en.xlf in it

100% translated source file: 'web/xliff/en.xlf'
on 'it'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-26 13:09:49 +02:00
816d9668eb website: add reference to "writing documentation" to readme (#15245)
* website: add reference to "writing documentation" to readme

As per: https://www.notion.so/authentiksecurity/Check-ins-17caee05b24e80a0aec6c7d508406435?source=copy_link#21daee05b24e8041adbadd3082ec7c8f

* Update website/README.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dominic R <dominic@sdko.org>

* lint readme

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-06-25 14:45:01 -05:00
371d35ec06 website: minimalistic readme (#14240)
* website: propose minimalistic readme

Introduce a minimalistic README for the website, link official website, and direct users to contribution guidelines, and finally also removes build commands from README (as source of truth is website)

Signed-off-by: Dominic R <dominic@sdko.org>

* Update README.md

Signed-off-by: Dominic R <dominic@sdko.org>

* fix md link 

Signed-off-by: Dominic R <dominic@sdko.org>

* I suppose i'm used to appending /CONTRUBUTING(.md or not) to contrib docs

Signed-off-by: Dominic R <dominic@sdko.org>

* add utm source as used on the main readme

Signed-off-by: Dominic R <dominic@sdko.org>

* Apply suggestions from code review

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/README.md

Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
2025-06-25 18:00:14 +00:00
664d3593ca core: bump oauthlib from 3.2.2 to v3.3.1 (#15242) 2025-06-25 17:09:50 +00:00
7acd27eea8 core: bump boto3 from 1.38.29 to v1.38.43 (#15239) 2025-06-25 16:48:52 +00:00
83550dc50d core: bump multidict from 6.4.4 to v6.5.1 (#15241) 2025-06-25 16:48:37 +00:00
c272dd70fd core: bump click-plugins from 1.1.1 to v1.1.1.2 (#15240) 2025-06-25 16:48:24 +00:00
ae1d82dc69 core: bump python-dotenv from 1.1.0 to v1.1.1 (#15244) 2025-06-25 16:48:17 +00:00
dd42eeab62 core: bump pygments from 2.19.1 to v2.19.2 (#15243) 2025-06-25 16:48:01 +00:00
680db9bae6 events: use pending_user as user when possible (#15238)
* unrelated: dont show nested for user

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

* unrelated: fix error when no extents in. map

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

* events: use pending_user when possible

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

* fix for identification stage "fake" user

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

* better username rendering

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-25 18:22:51 +02:00
31b72751bc blueprints: add JSON tag to parse JSON from string (#15235) 2025-06-25 18:19:28 +02:00
8210067479 website/integrations: add ssh active user filter to sssd integration doc (#15203)
* Update sssd integration doc

* Improve language

* Update website/integrations/services/sssd/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/sssd/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/sssd/index.md

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/sssd/index.md

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Update website/integrations/services/sssd/index.md

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Minor changes

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-06-25 17:01:23 +01:00
423911d974 web: bump API Client version (#15237)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-25 13:24:56 +00:00
d4ca070d76 core: better API validation for JSON fields (#15236)
* core: better API validation for JSON fields

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

* fix web

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-25 15:05:32 +02:00
db1e8b291f website: bump @types/node from 24.0.3 to 24.0.4 in /website (#15230)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.0.3 to 24.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.4
  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>
2025-06-25 13:47:04 +02:00
44ff6fce23 core: bump github.com/redis/go-redis/v9 from 9.10.0 to 9.11.0 (#15231)
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.10.0 to 9.11.0.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.10.0...v9.11.0)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.11.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>
2025-06-25 13:47:01 +02:00
085c22a41a core: bump goauthentik.io/api/v3 from 3.2025062.4 to 3.2025062.5 (#15232)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025062.4 to 3.2025062.5.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025062.4...v3.2025062.5)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025062.5
  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>
2025-06-25 13:46:58 +02:00
fb2887fa4b core: bump sentry-sdk from 2.30.0 to 2.31.0 (#15233)
Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 2.30.0 to 2.31.0.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.30.0...2.31.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.31.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>
2025-06-25 13:46:56 +02:00
ed41eb66de core, web: update translations (#15229)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-25 13:04:31 +02:00
ee8122baa7 website/docs: fix documentation for external group write commands in hashicorp-vault integrations (#15234)
Fix external group write

Signed-off-by: Balázs Hasprai <balazs.hasprai@hbalazs.com>
2025-06-25 10:01:17 +00:00
f0d70eef6f website/docs: enterprise: fix link for customer portal (#15225)
* website/docs: enterprise: fix link for customer portal

* fix more

Signed-off-by: Dominic R <dominic@sdko.org>

* fix more

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/enterprise/manage-enterprise.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-06-24 18:14:19 -05:00
ff966d763b providers/oauth2: add conformance tools (#15228)
add conformance tools

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-25 00:43:15 +02:00
e00b68cafe web: bump API Client version (#15227)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-24 22:13:34 +00:00
bf4e8dbedc core: include more authenticator details when possible (#15224)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 23:52:06 +02:00
d09b7757b6 root: fix some cases of invalid data triggering exceptions (#14799)
* sentry: separate checker if exception should be ignored

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

* use should_ignore_exception in flow executor (fix ParseError)

fix system exceptions for unsupported media type, json decode error

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

* fix tests

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

* improve API validation

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 22:42:59 +02:00
ca2f0439f6 website/docs: add links to Customer Portal (#15223)
* add direct link to Portal, remove screenshot

* fix link

* Update website/docs/enterprise/get-started.md

Signed-off-by: Jens L. <jens@beryju.org>

---------

Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-06-24 15:34:37 -05:00
27b7b0b0e7 web/elements/empty-state: Fix issues with EmptyState and Loading Overlay (#15152)
* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/element: empty-state should not have a default label when used as a loading indicator

* .

* web/bug/empty-state: Fix issues with EmptyState and Loading Overlay

- Add a method, `hasSlotted()`, to the Base component.
- Revise `EmptyState` to use `hasSlotted()`.
- Revise `LoadingOverlay` to use `hasSlotted()`.
- Provide (hopefully complete) Storybook stories for both
- Revise use of these components throughout the codebase.

The essential problem here was mine: I misunderstood what the Patternfly `SlotController` does (and,
yikes, how it does it). Slots aren't magical; they're just named containers, in which lightDOM
elements that appear between the opening and closing tags of a web component can be strategically
placed, shown or hidden, and to some extent styled, within the rendered and visible results of the
shadowDOM component that will fill the browser's RECT allocated to that component.

SlotController tries to associate the template with slots by creating the shadowDOM *first*, then
working backwards to see if there are lightDOM components to put into those slots.  That's not what
we want; we want to see if there are lightDOM components that meet our slot requirements and, if
there are, create corresponding slots for them.

That's what `hasSlotted()` does: it returns true or false to the question, "Is there currently in
the lightDOM for this component an entry requesting a known slot name?"  Components are free to do
what they want with that knowledge.

`<ak-empty-state>` now has several modes, all well-documented in the Storybook story.  But in short,
the Title is now a default slot; any HTML Element not sent to one of the named slots are put into
the Title.  The two named slots are `body` and `primary`.  The header is bold and large; body is
just text, and primary is boxed to indicate that one or more buttons should be placed there, to
allow interaction.

The extra modes are controlled by boolean attributes:

- `loading`: Shows the loading spinner, overriding the `icon` attribute
- `default`: Shows the loading spinner *and* the word "Loading" (i18n-aware).

The priority for all of these is:

- Has something in the default (header) slot: That text will be shown. Overrides both
- `default` overrides `loading`
- `loading`

q`<ak-loading-overlay>` is a specialized variant of `<ak-empty-state>` over what will become
`<ak-backdrop>`, but for now is just internal.  It allows only for the heading and primary slots,
forwarding them `<ak-empty-state>`.  Since this is literally the *Loading*Overlay, showing the
`loading` spinner is the default; to prevent it, pass `no-spinner` as an attribute.

* Grammatical error.

* Prettier had opinions that shouldn't have been aired in public.

* Prettier had opinions that shouldn't have been aired in public.

* Collapsing unnecessary boolean nest.

* fix typo

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

* always render icon

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

* missing default in flow exec

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

* unrelated: fix loading interface

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

* rename default attr

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

* fix jsdoc

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 21:33:07 +02:00
88073305eb *: remove redundant user_logged_out signals (#15221) 2025-06-24 18:29:05 +02:00
37657e47a3 web: bump the eslint group across 2 directories with 3 updates (#15215)
Bumps the eslint group with 1 update in the /packages/eslint-config directory: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).
Bumps the eslint group with 1 update in the /web directory: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:51:10 +02:00
0d649a70c9 core: bump github.com/getsentry/sentry-go from 0.33.0 to 0.34.0 (#15213)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.34.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>
2025-06-24 16:51:00 +02:00
7ec3055018 web: bump @sentry/browser from 9.30.0 to 9.31.0 in /web in the sentry group across 1 directory (#15214)
web: bump @sentry/browser in /web in the sentry group across 1 directory

Bumps the sentry group with 1 update in the /web directory: [@sentry/browser](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/browser` from 9.30.0 to 9.31.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/9.30.0...9.31.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 9.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:50:48 +02:00
50ffce87c4 website: bump the eslint group in /website with 3 updates (#15216)
Bumps the eslint group in /website with 3 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:50:33 +02:00
a4393ac9f0 core: bump astral-sh/uv from 0.7.13 to 0.7.14 (#15217)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.7.13 to 0.7.14.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.7.13...0.7.14)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.7.14
  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>
2025-06-24 16:50:14 +02:00
e235c854a5 website: bump the build group in /website with 6 updates (#15218)
Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |


Updates `@swc/core-darwin-arm64` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

Updates `@swc/core-linux-arm64-gnu` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

Updates `@swc/core-linux-x64-gnu` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

Updates `@swc/html-darwin-arm64` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

Updates `@swc/html-linux-arm64-gnu` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

Updates `@swc/html-linux-x64-gnu` from 1.12.5 to 1.12.6
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.5...v1.12.6)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:50:04 +02:00
910b69f89d translate: Updates for file web/xliff/en.xlf in it (#15219)
Translate web/xliff/en.xlf in it

100% translated source file: 'web/xliff/en.xlf'
on 'it'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-24 16:49:41 +02:00
f89cc98014 sources/scim: add group patch support (#15212)
* set auth_via

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

* allow requests with json content type

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

* fix group schema

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

* start improving error handling

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

* add scim group patch for members

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

* unrelated #1: fix debug check on startup

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

* unrelated fix #2: fix path for user page

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

* add group view tests

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

* add more user tests too

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 16:48:48 +02:00
91a675a5a1 web/admin: point create application to wizard (#15211)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 01:36:20 +02:00
71be3acd1a web/elements: typing error when variables are not converted to string (#15169)
fix: typing error when variables are not converted to string

Co-authored-by: leandro.saraiva <leandro.saraiva@adonite.com>
2025-06-23 23:40:36 +02:00
0b6ab171ce website: bump prettier from 3.5.3 to 3.6.0 in /website (#15199)
* website: bump prettier from 3.5.3 to 3.6.0 in /website

Bumps [prettier](https://github.com/prettier/prettier) from 3.5.3 to 3.6.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.5.3...3.6.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* format

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 21:29:14 +02:00
0c73572b0c ci: update daily tested versions (#15196)
* ci: update daily tested versions

Signed-off-by: Dominic R <dominic@sdko.org>

* Update ci-main-daily.yml

Co-authored-by: Jens L. <jens@beryju.org>
Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens L. <jens@beryju.org>
2025-06-23 21:16:47 +02:00
03d0899a76 website/docs: sys mgmt: clean up certificates and add steps to download saml certs (#14497)
* website/docs: sys mgmt: clean up certificates and add steps to download
saml certs

* fix broken link

* Apply suggestions from code review

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Dewi review comments

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Dominic R <dominic@sdko.org>

* Apply suggestions from code review

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/docs/sys-mgmt/certificates.md

Signed-off-by: Dominic R <dominic@sdko.org>

* lint fix following header rm

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-06-23 18:56:50 +00:00
91f79c97d8 ci: fix CodeQL failing on cherry-pick PRs (#15205) 2025-06-23 16:55:08 +02:00
19324c61a3 root: add system check for database encoding (#15186)
* root: add system check for database encoding

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

* oops

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 15:17:48 +02:00
d297733614 enterprise/stages/source: update outer flow with context from inner flow (#15177)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 15:13:27 +02:00
f201f41a1b website: bump the build group in /website with 6 updates (#15200)
Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |


Updates `@swc/core-darwin-arm64` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

Updates `@swc/core-linux-arm64-gnu` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

Updates `@swc/core-linux-x64-gnu` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

Updates `@swc/html-darwin-arm64` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

Updates `@swc/html-linux-arm64-gnu` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

Updates `@swc/html-linux-x64-gnu` from 1.12.4 to 1.12.5
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.12.4...v1.12.5)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:54:27 +02:00
f58f679171 web: bump the eslint group across 2 directories with 1 update (#15198)
Bumps the eslint group with 1 update in the /packages/eslint-config directory: [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import).
Bumps the eslint group with 1 update in the /web directory: [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import).


Updates `eslint-plugin-import` from 2.31.0 to 2.32.0
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0)

Updates `eslint-plugin-import` from 2.31.0 to 2.32.0
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:54:16 +02:00
1bea5e38a1 core: bump goauthentik.io/api/v3 from 3.2025062.3 to 3.2025062.4 (#15197)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025062.3 to 3.2025062.4.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025062.3...v3.2025062.4)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025062.4
  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>
2025-06-23 13:54:08 +02:00
4d1c63e7fa core: bump axllent/mailpit from v1.26.1 to v1.26.2 in /tests/e2e (#15201)
Bumps axllent/mailpit from v1.26.1 to v1.26.2.

---
updated-dependencies:
- dependency-name: axllent/mailpit
  dependency-version: v1.26.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:52:03 +02:00
e341032bf9 website/integrations: bitwarden: fix certificate download section (#15184)
* website/integrations: bitwarden: fix certificate download section

Fix whatever happened here

Signed-off-by: Dominic R <dominic@sdko.org>

* keep it as it was before I copy-pasted from stripe

Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
2025-06-23 10:28:38 +01:00
e3ff242956 core, web: update translations (#15194)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-23 02:45:30 +02:00
c6756bf809 web: bump API Client version (#15193)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-06-23 02:45:03 +02:00
cf9b7eaa64 web/user: fix infinite loop when no user settings flow is set (#15188)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 01:49:43 +02:00
53d8f9bd8c stages/authenticator_webauthn: add option to configure max attempts (#15041)
* house keeping - migrate to session part 1

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

* cleanup v2

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

* add max_attempts

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

* teeny tiny cleanup

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

* add ui

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 01:49:07 +02:00
309 changed files with 4291 additions and 6929 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2025.6.2 current_version = 2025.6.3
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

@ -38,6 +38,8 @@ jobs:
# Needed for attestation # Needed for attestation
id-token: write id-token: write
attestations: write attestations: write
# Needed for checkout
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3.6.0 - uses: docker/setup-qemu-action@v3.6.0

View File

@ -9,14 +9,15 @@ on:
jobs: jobs:
test-container: test-container:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
version: version:
- docs - docs
- version-2025-4
- version-2025-2 - version-2025-2
- version-2024-12
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: | - run: |

View File

@ -247,11 +247,13 @@ jobs:
# Needed for attestation # Needed for attestation
id-token: write id-token: write
attestations: write attestations: write
# Needed for checkout
contents: read
needs: ci-core-mark needs: ci-core-mark
uses: ./.github/workflows/_reusable-docker-build.yaml uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit secrets: inherit
with: with:
image_name: ghcr.io/goauthentik/dev-server image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
release: false release: false
pr-comment: pr-comment:
needs: needs:

View File

@ -59,6 +59,7 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
build-container: build-container:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
- ci-outpost-mark - ci-outpost-mark

View File

@ -63,6 +63,7 @@ jobs:
working-directory: website/ working-directory: website/
run: npm run ${{ matrix.job }} run: npm run ${{ matrix.job }}
build-container: build-container:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload container images to ghcr.io # Needed to upload container images to ghcr.io
@ -122,3 +123,4 @@ jobs:
- uses: re-actors/alls-green@release/v1 - uses: re-actors/alls-green@release/v1
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }}

View File

@ -2,7 +2,7 @@ name: "CodeQL"
on: on:
push: push:
branches: [main, "*", next, version*] branches: [main, next, version*]
pull_request: pull_request:
branches: [main] branches: [main]
schedule: schedule:

View File

@ -0,0 +1,21 @@
name: "authentik-repo-mirror-cleanup"
on:
workflow_dispatch:
jobs:
to_internal:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }}
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
with:
target_repo_url: git@github.com:goauthentik/authentik-internal.git
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
args: --tags --force --prune
env:
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}

View File

@ -11,11 +11,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }} - if: ${{ env.MIRROR_KEY != '' }}
uses: pixta-dev/repository-mirroring-action@v1 uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb
with: with:
target_repo_url: target_repo_url: git@github.com:goauthentik/authentik-internal.git
git@github.com:goauthentik/authentik-internal.git ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
ssh_private_key: args: --tags --force
${{ secrets.GH_MIRROR_KEY }}
env: env:
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}

View File

@ -16,6 +16,7 @@ env:
jobs: jobs:
compile: compile:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token

View File

@ -6,13 +6,15 @@
"!Context scalar", "!Context scalar",
"!Enumerate sequence", "!Enumerate sequence",
"!Env scalar", "!Env scalar",
"!Env sequence",
"!Find sequence", "!Find sequence",
"!Format sequence", "!Format sequence",
"!If sequence", "!If sequence",
"!Index scalar", "!Index scalar",
"!KeyOf scalar", "!KeyOf scalar",
"!Value scalar", "!Value scalar",
"!AtIndex scalar" "!AtIndex scalar",
"!ParseJSON scalar"
], ],
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index", "typescript.preferences.importModuleSpecifierEnding": "index",

View File

@ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv # Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.7.13 AS uv FROM ghcr.io/astral-sh/uv:0.7.17 AS uv
# Stage 5: Base python image # Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base

View File

@ -150,9 +150,9 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
--additional-properties=npmVersion=${NPM_VERSION} \ --additional-properties=npmVersion=${NPM_VERSION} \
--git-repo-id authentik \ --git-repo-id authentik \
--git-user-id goauthentik --git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
cd ${PWD}/${GEN_API_TS} && npm i cd ${PWD}/${GEN_API_TS} && npm link
\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api cd ${PWD}/web && npm link @goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \ docker run \

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.6.2" __version__ = "2025.6.3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -37,6 +37,7 @@ entries:
- attrs: - attrs:
attributes: attributes:
env_null: !Env [bar-baz, null] env_null: !Env [bar-baz, null]
json_parse: !ParseJSON '{"foo": "bar"}'
policy_pk1: policy_pk1:
!Format [ !Format [
"%s-%s", "%s-%s",

View File

@ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable:
for blueprint_file in Path("blueprints/").glob("**/*.yaml"): for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
if "local" in str(blueprint_file): if "local" in str(blueprint_file) or "testing" in str(blueprint_file):
continue continue
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))

View File

@ -5,7 +5,6 @@ from collections.abc import Callable
from django.apps import apps from django.apps import apps
from django.test import TestCase from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.providers.oauth2.models import RefreshToken from authentik.providers.oauth2.models import RefreshToken
@ -22,10 +21,13 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable:
return return
model_class = test_model() model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel)) self.assertTrue(isinstance(model_class, SerializerModel))
# Models that have subclasses don't have to have a serializer
if len(test_model.__subclasses__()) > 0:
return
self.assertIsNotNone(model_class.serializer) self.assertIsNotNone(model_class.serializer)
if model_class.serializer.Meta().model == RefreshToken: if model_class.serializer.Meta().model == RefreshToken:
return return
self.assertEqual(model_class.serializer.Meta().model, test_model) self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model))
return tester return tester
@ -34,6 +36,6 @@ for app in apps.get_app_configs():
if not app.label.startswith("authentik"): if not app.label.startswith("authentik"):
continue continue
for model in app.get_models(): for model in app.get_models():
if not is_model_allowed(model): if not issubclass(model, SerializerModel):
continue continue
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))

View File

@ -215,6 +215,7 @@ class TestBlueprintsV1(TransactionTestCase):
}, },
"nested_context": "context-nested-value", "nested_context": "context-nested-value",
"env_null": None, "env_null": None,
"json_parse": {"foo": "bar"},
"at_index_sequence": "foo", "at_index_sequence": "foo",
"at_index_sequence_default": "non existent", "at_index_sequence_default": "non existent",
"at_index_mapping": 2, "at_index_mapping": 2,

View File

@ -6,6 +6,7 @@ from copy import copy
from dataclasses import asdict, dataclass, field, is_dataclass from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum from enum import Enum
from functools import reduce from functools import reduce
from json import JSONDecodeError, loads
from operator import ixor from operator import ixor
from os import getenv from os import getenv
from typing import Any, Literal, Union from typing import Any, Literal, Union
@ -291,6 +292,22 @@ class Context(YAMLTag):
return value return value
class ParseJSON(YAMLTag):
"""Parse JSON from context/env/etc value"""
raw: str
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.raw = node.value
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
try:
return loads(self.raw)
except JSONDecodeError as exc:
raise EntryInvalidError.from_entry(exc, entry) from exc
class Format(YAMLTag): class Format(YAMLTag):
"""Format a string""" """Format a string"""
@ -666,6 +683,7 @@ class BlueprintLoader(SafeLoader):
self.add_constructor("!Value", Value) self.add_constructor("!Value", Value)
self.add_constructor("!Index", Index) self.add_constructor("!Index", Index)
self.add_constructor("!AtIndex", AtIndex) self.add_constructor("!AtIndex", AtIndex)
self.add_constructor("!ParseJSON", ParseJSON)
class EntryInvalidError(SentryIgnoredException): class EntryInvalidError(SentryIgnoredException):

View File

@ -43,6 +43,7 @@ from authentik.core.models import (
) )
from authentik.enterprise.license import LicenseKey from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.apple_psso.models import AppleNonce
from authentik.enterprise.providers.google_workspace.models import ( from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup, GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser, GoogleWorkspaceProviderUser,
@ -135,6 +136,7 @@ def excluded_models() -> list[type[Model]]:
EndpointDeviceConnection, EndpointDeviceConnection,
DeviceToken, DeviceToken,
StreamEvent, StreamEvent,
AppleNonce,
) )

View File

@ -1,8 +1,6 @@
"""Authenticator Devices API Views""" """Authenticator Devices API Views"""
from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import ( from rest_framework.fields import (
BooleanField, BooleanField,
@ -15,6 +13,7 @@ from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from authentik.core.api.users import ParamUserSerializer
from authentik.core.api.utils import MetaNameSerializer from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.stages.authenticator import device_classes, devices_for_user from authentik.stages.authenticator import device_classes, devices_for_user
@ -23,7 +22,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceSerializer(MetaNameSerializer): class DeviceSerializer(MetaNameSerializer):
"""Serializer for Duo authenticator devices""" """Serializer for authenticator devices"""
pk = CharField() pk = CharField()
name = CharField() name = CharField()
@ -33,22 +32,27 @@ class DeviceSerializer(MetaNameSerializer):
last_updated = DateTimeField(read_only=True) last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True) last_used = DateTimeField(read_only=True, allow_null=True)
extra_description = SerializerMethodField() extra_description = SerializerMethodField()
external_id = SerializerMethodField()
def get_type(self, instance: Device) -> str: def get_type(self, instance: Device) -> str:
"""Get type of device""" """Get type of device"""
return instance._meta.label return instance._meta.label
def get_extra_description(self, instance: Device) -> str: def get_extra_description(self, instance: Device) -> str | None:
"""Get extra description""" """Get extra description"""
if isinstance(instance, WebAuthnDevice): if isinstance(instance, WebAuthnDevice):
return ( return instance.device_type.description if instance.device_type else None
instance.device_type.description
if instance.device_type
else _("Extra description not available")
)
if isinstance(instance, EndpointDevice): if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel") return instance.data.get("deviceSignals", {}).get("deviceModel")
return "" return None
def get_external_id(self, instance: Device) -> str | None:
"""Get external Device ID"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.aaguid if instance.device_type else None
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return None
class DeviceViewSet(ViewSet): class DeviceViewSet(ViewSet):
@ -57,7 +61,6 @@ class DeviceViewSet(ViewSet):
serializer_class = DeviceSerializer serializer_class = DeviceSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema(responses={200: DeviceSerializer(many=True)})
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
"""Get all devices for current user""" """Get all devices for current user"""
devices = devices_for_user(request.user) devices = devices_for_user(request.user)
@ -79,18 +82,11 @@ class AdminDeviceViewSet(ViewSet):
yield from device_set yield from device_set
@extend_schema( @extend_schema(
parameters=[ parameters=[ParamUserSerializer],
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
responses={200: DeviceSerializer(many=True)}, responses={200: DeviceSerializer(many=True)},
) )
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
"""Get all devices for current user""" """Get all devices for current user"""
kwargs = {} args = ParamUserSerializer(data=request.query_params)
if "user" in request.query_params: args.is_valid(raise_exception=True)
kwargs = {"user": request.query_params["user"]} return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)

View File

@ -90,6 +90,12 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False)
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
"""Simplified Group Serializer for user's groups""" """Simplified Group Serializer for user's groups"""
@ -401,7 +407,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
StrField(User, "path"), StrField(User, "path"),
BoolField(User, "is_active", nullable=True), BoolField(User, "is_active", nullable=True),
ChoiceSearchField(User, "type"), ChoiceSearchField(User, "type"),
JSONSearchField(User, "attributes"), JSONSearchField(User, "attributes", suggest_nested=False),
] ]
def get_queryset(self): def get_queryset(self):

View File

@ -2,6 +2,7 @@
from typing import Any from typing import Any
from django.db import models
from django.db.models import Model from django.db.models import Model
from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.plumbing import build_basic_type from drf_spectacular.plumbing import build_basic_type
@ -30,7 +31,27 @@ def is_dict(value: Any):
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
class JSONDictField(JSONField):
"""JSON Field which only allows dictionaries"""
default_validators = [is_dict]
class JSONExtension(OpenApiSerializerFieldExtension):
"""Generate API Schema for JSON fields as"""
target_class = "authentik.core.api.utils.JSONDictField"
def map_serializer_field(self, auto_schema, direction):
return build_basic_type(OpenApiTypes.OBJECT)
class ModelSerializer(BaseModelSerializer): class ModelSerializer(BaseModelSerializer):
# By default, JSON fields we have are used to store dictionaries
serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy()
serializer_field_mapping[models.JSONField] = JSONDictField
def create(self, validated_data): def create(self, validated_data):
instance = super().create(validated_data) instance = super().create(validated_data)
@ -71,21 +92,6 @@ class ModelSerializer(BaseModelSerializer):
return instance return instance
class JSONDictField(JSONField):
"""JSON Field which only allows dictionaries"""
default_validators = [is_dict]
class JSONExtension(OpenApiSerializerFieldExtension):
"""Generate API Schema for JSON fields as"""
target_class = "authentik.core.api.utils.JSONDictField"
def map_serializer_field(self, auto_schema, direction):
return build_basic_type(OpenApiTypes.OBJECT)
class PassiveSerializer(Serializer): class PassiveSerializer(Serializer):
"""Base serializer class which doesn't implement create/update methods""" """Base serializer class which doesn't implement create/update methods"""

View File

@ -13,7 +13,6 @@ class Command(TenantCommand):
parser.add_argument("usernames", nargs="*", type=str) parser.add_argument("usernames", nargs="*", type=str)
def handle_per_tenant(self, **options): def handle_per_tenant(self, **options):
print(options)
new_type = UserTypes(options["type"]) new_type = UserTypes(options["type"])
qs = ( qs = (
User.objects.exclude_anonymous() User.objects.exclude_anonymous()

View File

@ -1082,6 +1082,12 @@ class AuthenticatedSession(SerializerModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
@property
def serializer(self) -> type[Serializer]:
from authentik.core.api.authenticated_sessions import AuthenticatedSessionSerializer
return AuthenticatedSessionSerializer
class Meta: class Meta:
verbose_name = _("Authenticated Session") verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions") verbose_name_plural = _("Authenticated Sessions")

View File

@ -0,0 +1,32 @@
"""Apple Platform SSO Provider API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.apple_psso.models import ApplePlatformSSOProvider
class ApplePlatformSSOProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""ApplePlatformSSOProvider Serializer"""
class Meta:
model = ApplePlatformSSOProvider
fields = [
"pk",
"name",
]
extra_kwargs = {}
class ApplePlatformSSOProviderViewSet(UsedByMixin, ModelViewSet):
"""ApplePlatformSSOProvider Viewset"""
queryset = ApplePlatformSSOProvider.objects.all()
serializer_class = ApplePlatformSSOProviderSerializer
filterset_fields = [
"name",
]
search_fields = ["name"]
ordering = ["name"]

View File

@ -0,0 +1,13 @@
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderApplePSSOConfig(EnterpriseConfig):
name = "authentik.enterprise.providers.apple_psso"
label = "authentik_providers_apple_psso"
verbose_name = "authentik Enterprise.Providers.Apple Platform SSO"
default = True
mountpoints = {
"authentik.enterprise.providers.apple_psso.urls": "endpoint/apple/sso/",
"authentik.enterprise.providers.apple_psso.urls_root": "",
}

View File

@ -0,0 +1,118 @@
from base64 import urlsafe_b64encode
from json import dumps
from secrets import token_bytes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash
from django.http import HttpResponse
from jwcrypto.common import base64url_decode, base64url_encode
from authentik.enterprise.providers.apple_psso.models import AppleDevice
def length_prefixed(data: bytes) -> bytes:
length = len(data)
return length.to_bytes(4, "big") + data
def build_apu(public_key: ec.EllipticCurvePublicKey):
# X9.63 representation: 0x04 || X || Y
public_numbers = public_key.public_numbers()
x_bytes = public_numbers.x.to_bytes(32, "big")
y_bytes = public_numbers.y.to_bytes(32, "big")
x963 = bytes([0x04]) + x_bytes + y_bytes
result = length_prefixed(b"APPLE") + length_prefixed(x963)
return result
def encrypt_token_with_a256_gcm(body: dict, device_encryption_key: str, apv: bytes) -> str:
ephemeral_key = ec.generate_private_key(curve=ec.SECP256R1())
device_public_key = serialization.load_pem_public_key(
device_encryption_key.encode(), backend=default_backend()
)
shared_secret_z = ephemeral_key.exchange(ec.ECDH(), device_public_key)
apu = build_apu(ephemeral_key.public_key())
jwe_header = {
"enc": "A256GCM",
"kid": "ephemeralKey",
"epk": {
"x": base64url_encode(
ephemeral_key.public_key().public_numbers().x.to_bytes(32, "big")
),
"y": base64url_encode(
ephemeral_key.public_key().public_numbers().y.to_bytes(32, "big")
),
"kty": "EC",
"crv": "P-256",
},
"typ": "platformsso-login-response+jwt",
"alg": "ECDH-ES",
"apu": base64url_encode(apu),
"apv": base64url_encode(apv),
}
party_u_info = length_prefixed(apu)
party_v_info = length_prefixed(apv)
supp_pub_info = (256).to_bytes(4, "big")
other_info = length_prefixed(b"A256GCM") + party_u_info + party_v_info + supp_pub_info
ckdf = ConcatKDFHash(
algorithm=hashes.SHA256(),
length=32,
otherinfo=other_info,
)
derived_key = ckdf.derive(shared_secret_z)
nonce = token_bytes(12)
header_json = dumps(jwe_header, separators=(",", ":")).encode()
aad = urlsafe_b64encode(header_json).rstrip(b"=")
aesgcm = AESGCM(derived_key)
ciphertext = aesgcm.encrypt(nonce, dumps(body).encode(), aad)
ciphertext_body = ciphertext[:-16]
tag = ciphertext[-16:]
# base64url encoding
protected_b64 = urlsafe_b64encode(header_json).rstrip(b"=")
iv_b64 = urlsafe_b64encode(nonce).rstrip(b"=")
ciphertext_b64 = urlsafe_b64encode(ciphertext_body).rstrip(b"=")
tag_b64 = urlsafe_b64encode(tag).rstrip(b"=")
jwe_compact = b".".join(
[
protected_b64,
b"",
iv_b64,
ciphertext_b64,
tag_b64,
]
)
return jwe_compact.decode()
class JWEResponse(HttpResponse):
def __init__(
self,
data: dict,
device: AppleDevice,
apv: str,
):
super().__init__(
content=encrypt_token_with_a256_gcm(data, device.encryption_key, base64url_decode(apv)),
content_type="application/platformsso-login-response+jwt",
)

View File

@ -0,0 +1,36 @@
# Generated by Django 5.1.11 on 2025-06-28 00:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.CreateModel(
name="ApplePlatformSSOProvider",
fields=[
(
"oauth2provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_providers_oauth2.oauth2provider",
),
),
],
options={
"abstract": False,
},
bases=("authentik_providers_oauth2.oauth2provider",),
),
]

View File

@ -0,0 +1,94 @@
# Generated by Django 5.1.11 on 2025-06-28 15:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_apple_psso", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AppleDevice",
fields=[
(
"endpoint_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("signing_key", models.TextField()),
("encryption_key", models.TextField()),
("key_exchange_key", models.TextField()),
("sign_key_id", models.TextField()),
("enc_key_id", models.TextField()),
("creation_time", models.DateTimeField(auto_now_add=True)),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_apple_psso.appleplatformssoprovider",
),
),
],
),
migrations.CreateModel(
name="AppleDeviceUser",
fields=[
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("signing_key", models.TextField()),
("encryption_key", models.TextField()),
("sign_key_id", models.TextField()),
("enc_key_id", models.TextField()),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_apple_psso.appledevice",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddField(
model_name="appledevice",
name="users",
field=models.ManyToManyField(
through="authentik_providers_apple_psso.AppleDeviceUser",
to=settings.AUTH_USER_MODEL,
),
),
migrations.CreateModel(
name="AppleNonce",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("nonce", models.TextField()),
],
options={
"abstract": False,
"indexes": [
models.Index(fields=["expires"], name="authentik_p_expires_47d534_idx"),
models.Index(fields=["expiring"], name="authentik_p_expirin_87253e_idx"),
models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_20a7c9_idx"
),
],
},
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.1.11 on 2025-06-28 22:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_apple_psso",
"0002_appledevice_appledeviceuser_appledevice_users_and_more",
),
]
operations = [
migrations.RenameField(
model_name="appledeviceuser",
old_name="sign_key_id",
new_name="enclave_key_id",
),
migrations.RenameField(
model_name="appledeviceuser",
old_name="signing_key",
new_name="secure_enclave_key",
),
migrations.RemoveField(
model_name="appledeviceuser",
name="enc_key_id",
),
migrations.RemoveField(
model_name="appledeviceuser",
name="encryption_key",
),
]

View File

@ -0,0 +1,85 @@
from uuid import uuid4
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.core.models import ExpiringModel, User
from authentik.crypto.models import CertificateKeyPair
from authentik.providers.oauth2.models import (
ClientTypes,
IssuerMode,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
class ApplePlatformSSOProvider(OAuth2Provider):
"""Integrate with Apple Platform SSO"""
def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct"""
self.issuer_mode = IssuerMode.PER_PROVIDER
self.client_type = ClientTypes.PUBLIC
self.signing_key = CertificateKeyPair.objects.get(name="authentik Self-signed Certificate")
self.include_claims_in_id_token = True
scopes = ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-offline_access",
"goauthentik.io/providers/oauth2/scope-authentik_api",
]
)
self.property_mappings.add(*list(scopes))
self.redirect_uris = [
RedirectURI(RedirectURIMatchingMode.STRICT, "io.goauthentik.endpoint:/oauth2redirect"),
]
@property
def component(self) -> str:
return "ak-provider-apple-psso-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.apple_psso.api.providers import (
ApplePlatformSSOProviderSerializer,
)
return ApplePlatformSSOProviderSerializer
class Meta:
verbose_name = _("Apple Platform SSO Provider")
verbose_name_plural = _("Apple Platform SSO Providers")
class AppleDevice(models.Model):
endpoint_uuid = models.UUIDField(default=uuid4, primary_key=True)
signing_key = models.TextField()
encryption_key = models.TextField()
key_exchange_key = models.TextField()
sign_key_id = models.TextField()
enc_key_id = models.TextField()
creation_time = models.DateTimeField(auto_now_add=True)
provider = models.ForeignKey(ApplePlatformSSOProvider, on_delete=models.CASCADE)
users = models.ManyToManyField(User, through="AppleDeviceUser")
class AppleDeviceUser(models.Model):
uuid = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey(AppleDevice, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
secure_enclave_key = models.TextField()
enclave_key_id = models.TextField()
class AppleNonce(ExpiringModel):
nonce = models.TextField()

View File

@ -0,0 +1,15 @@
from django.urls import path
from authentik.enterprise.providers.apple_psso.views.nonce import NonceView
from authentik.enterprise.providers.apple_psso.views.register import (
RegisterDeviceView,
RegisterUserView,
)
from authentik.enterprise.providers.apple_psso.views.token import TokenView
urlpatterns = [
path("token/", TokenView.as_view(), name="token"),
path("nonce/", NonceView.as_view(), name="nonce"),
path("register/device/", RegisterDeviceView.as_view(), name="register-device"),
path("register/user/", RegisterUserView.as_view(), name="register-user"),
]

View File

@ -0,0 +1,7 @@
from django.urls import path
from authentik.enterprise.providers.apple_psso.views.site_association import AppleAppSiteAssociation
urlpatterns = [
path(".well-known/apple-app-site-association", AppleAppSiteAssociation.as_view(), name="asa"),
]

View File

@ -0,0 +1,25 @@
from base64 import b64encode
from datetime import timedelta
from secrets import token_bytes
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from authentik.enterprise.providers.apple_psso.models import AppleNonce
@method_decorator(csrf_exempt, name="dispatch")
class NonceView(View):
def post(self, request: HttpRequest, *args, **kwargs):
nonce = AppleNonce.objects.create(
nonce=b64encode(token_bytes(32)).decode(), expires=now() + timedelta(minutes=5)
)
return JsonResponse(
{
"Nonce": nonce.nonce,
}
)

View File

@ -0,0 +1,92 @@
from django.shortcuts import get_object_or_404
from rest_framework.authentication import BaseAuthentication
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.api.authentication import TokenAuthentication
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
from authentik.enterprise.providers.apple_psso.models import (
AppleDevice,
AppleDeviceUser,
ApplePlatformSSOProvider,
)
from authentik.lib.generators import generate_key
class DeviceRegisterAuth(BaseAuthentication):
def authenticate(self, request):
# very temporary, lol
return (User(), None)
class RegisterDeviceView(APIView):
class DeviceRegistration(PassiveSerializer):
device_uuid = CharField()
client_id = CharField()
device_signing_key = CharField()
device_encryption_key = CharField()
sign_key_id = CharField()
enc_key_id = CharField()
permission_classes = []
pagination_class = None
filter_backends = []
serializer_class = DeviceRegistration
authentication_classes = [DeviceRegisterAuth, TokenAuthentication]
def post(self, request: Request) -> Response:
data = self.DeviceRegistration(data=request.data)
data.is_valid(raise_exception=True)
provider = get_object_or_404(
ApplePlatformSSOProvider, client_id=data.validated_data["client_id"]
)
AppleDevice.objects.update_or_create(
endpoint_uuid=data.validated_data["device_uuid"],
defaults={
"signing_key": data.validated_data["device_signing_key"],
"encryption_key": data.validated_data["device_encryption_key"],
"sign_key_id": data.validated_data["sign_key_id"],
"enc_key_id": data.validated_data["enc_key_id"],
"key_exchange_key": generate_key(),
"provider": provider,
},
)
return Response()
class RegisterUserView(APIView):
class UserRegistration(PassiveSerializer):
device_uuid = CharField()
user_secure_enclave_key = CharField()
enclave_key_id = CharField()
permission_classes = []
pagination_class = None
filter_backends = []
serializer_class = UserRegistration
authentication_classes = [TokenAuthentication]
def post(self, request: Request) -> Response:
data = self.UserRegistration(data=request.data)
data.is_valid(raise_exception=True)
device = get_object_or_404(AppleDevice, endpoint_uuid=data.validated_data["device_uuid"])
AppleDeviceUser.objects.update_or_create(
device=device,
user=request.user,
defaults={
"secure_enclave_key": data.validated_data["user_secure_enclave_key"],
"enclave_key_id": data.validated_data["enclave_key_id"],
},
)
return Response(
{
"username": request.user.username,
}
)

View File

@ -0,0 +1,16 @@
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views import View
class AppleAppSiteAssociation(View):
def get(self, request: HttpRequest) -> HttpResponse:
return JsonResponse(
{
"authsrv": {
"apps": [
"232G855Y8N.io.goauthentik.endpoint",
"232G855Y8N.io.goauthentik.endpoint.psso",
]
}
}
)

View File

@ -0,0 +1,140 @@
from datetime import timedelta
from django.http import Http404, HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from jwt import PyJWT, decode
from rest_framework.exceptions import ValidationError
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, Session, User
from authentik.core.sessions import SessionStore
from authentik.enterprise.providers.apple_psso.http import JWEResponse
from authentik.enterprise.providers.apple_psso.models import (
AppleDevice,
AppleDeviceUser,
AppleNonce,
ApplePlatformSSOProvider,
)
from authentik.events.models import Event, EventAction
from authentik.events.signals import SESSION_LOGIN_EVENT
from authentik.providers.oauth2.constants import TOKEN_TYPE
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import RefreshToken
from authentik.root.middleware import SessionMiddleware
LOGGER = get_logger()
@method_decorator(csrf_exempt, name="dispatch")
class TokenView(View):
device: AppleDevice
provider: ApplePlatformSSOProvider
def post(self, request: HttpRequest) -> HttpResponse:
version = request.POST.get("platform_sso_version")
assertion = request.POST.get("assertion", request.POST.get("request"))
if not assertion:
return HttpResponse(status=400)
decode_unvalidated = PyJWT().decode_complete(assertion, options={"verify_signature": False})
LOGGER.debug(decode_unvalidated["header"])
expected_kid = decode_unvalidated["header"]["kid"]
self.device = AppleDevice.objects.filter(sign_key_id=expected_kid).first()
if not self.device:
raise Http404
self.provider = self.device.provider
# Properly decode the JWT with the key from the device
decoded = decode(
assertion, self.device.signing_key, algorithms=["ES256"], options={"verify_aud": False}
)
LOGGER.debug(decoded)
LOGGER.debug("got device", device=self.device)
# Check that the nonce hasn't been used before
nonce = AppleNonce.objects.filter(nonce=decoded["request_nonce"]).first()
if not nonce:
return HttpResponse(status=400)
nonce.delete()
handler_func = (
f"handle_v{version}_{decode_unvalidated["header"]["typ"]}".replace("-", "_")
.replace("+", "_")
.replace(".", "_")
)
handler = getattr(self, handler_func, None)
if not handler:
LOGGER.debug("Handler not found", handler=handler_func)
return HttpResponse(status=400)
LOGGER.debug("sending to handler", handler=handler_func)
return handler(decoded)
def validate_device_user_response(self, assertion: str) -> tuple[AppleDeviceUser, dict] | None:
"""Decode an embedded assertion and validate it by looking up the matching device user"""
decode_unvalidated = PyJWT().decode_complete(assertion, options={"verify_signature": False})
expected_kid = decode_unvalidated["header"]["kid"]
device_user = AppleDeviceUser.objects.filter(
device=self.device, enclave_key_id=expected_kid
).first()
if not device_user:
return None
return device_user, decode(
assertion,
device_user.secure_enclave_key,
audience="apple-platform-sso",
algorithms=["ES256"],
)
def create_auth_session(self, user: User):
event = Event.new(EventAction.LOGIN).from_http(self.request, user=user)
store = SessionStore()
store[SESSION_LOGIN_EVENT] = event
store.save()
session = Session.objects.filter(session_key=store.session_key).first()
AuthenticatedSession.objects.create(session=session, user=user)
session = SessionMiddleware.encode_session(store.session_key, user)
return session
def handle_v1_0_platformsso_login_request_jwt(self, decoded: dict):
user = None
if decoded["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer":
# Decode and validate inner assertion
user, inner = self.validate_device_user_response(decoded["assertion"])
if inner["nonce"] != decoded["nonce"]:
LOGGER.warning("Mis-matched nonce to outer assertion")
raise ValidationError("Invalid request")
refresh_token = RefreshToken(
user=user.user,
scope=decoded["scope"],
expires=now() + timedelta(hours=8),
provider=self.provider,
auth_time=now(),
session=None,
)
id_token = IDToken.new(
self.provider,
refresh_token,
self.request,
)
id_token.nonce = decoded["nonce"]
refresh_token.id_token = id_token
refresh_token.save()
return JWEResponse(
{
"refresh_token": refresh_token.token,
"refresh_token_expires_in": int((refresh_token.expires - now()).total_seconds()),
"id_token": refresh_token.id_token.to_jwt(self.provider),
"token_type": TOKEN_TYPE,
"session_key": self.create_auth_session(user.user),
},
device=self.device,
apv=decoded["jwe_crypto"]["apv"],
)

View File

@ -1,10 +1,8 @@
from hashlib import sha256 from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http.request import HttpRequest
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from authentik.core.models import ( from authentik.core.models import (
@ -62,31 +60,6 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created:
instance.save() instance.save()
@receiver(user_logged_out)
def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_):
"""Session revoked trigger (user logged out)"""
if not request.session or not request.session.session_key or not user:
return
send_ssf_event(
EventTypes.CAEP_SESSION_REVOKED,
{
"initiating_entity": "user",
},
sub_id={
"format": "complex",
"session": {
"format": "opaque",
"id": sha256(request.session.session_key.encode("ascii")).hexdigest(),
},
"user": {
"format": "email",
"email": user.email,
},
},
request=request,
)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_):
"""Session revoked trigger (users' session has been deleted) """Session revoked trigger (users' session has been deleted)

View File

@ -15,6 +15,7 @@ CELERY_BEAT_SCHEDULE = {
TENANT_APPS = [ TENANT_APPS = [
"authentik.enterprise.audit", "authentik.enterprise.audit",
"authentik.enterprise.policies.unique_password", "authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.apple_psso",
"authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf", "authentik.enterprise.providers.ssf",

View File

@ -97,6 +97,7 @@ class SourceStageFinal(StageView):
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan plan = token.plan
plan.context.update(self.executor.plan.context)
plan.context[PLAN_CONTEXT_IS_RESTORED] = token plan.context[PLAN_CONTEXT_IS_RESTORED] = token
response = plan.to_redirect(self.request, token.flow) response = plan.to_redirect(self.request, token.flow)
token.delete() token.delete()

View File

@ -90,14 +90,17 @@ class TestSourceStage(FlowTestCase):
plan: FlowPlan = session[SESSION_KEY_PLAN] plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
plan.context["foo"] = "bar"
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session.save() session.save()
# Pretend we've just returned from the source # Pretend we've just returned from the source
response = self.client.get( with self.assertFlowFinishes() as ff:
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True response = self.client.get(
) reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
self.assertEqual(response.status_code, 200) )
self.assertStageRedirects( self.assertEqual(response.status_code, 200)
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) self.assertStageRedirects(
) response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertEqual(ff().context["foo"], "bar")

View File

@ -19,7 +19,7 @@ from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict from authentik.events.utils import model_to_dict
from authentik.lib.sentry import before_send from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.stages.authenticator_static.models import StaticToken from authentik.stages.authenticator_static.models import StaticToken
@ -173,7 +173,7 @@ class AuditMiddleware:
message=exception_to_string(exception), message=exception_to_string(exception),
) )
thread.run() thread.run()
elif before_send({}, {"exc_info": (None, exception, None)}) is not None: elif not should_ignore_exception(exception):
thread = EventNewThread( thread = EventNewThread(
EventAction.SYSTEM_EXCEPTION, EventAction.SYSTEM_EXCEPTION,
request, request,

View File

@ -193,17 +193,32 @@ class Event(SerializerModel, ExpiringModel):
brand: Brand = request.brand brand: Brand = request.brand
self.brand = sanitize_dict(model_to_dict(brand)) self.brand = sanitize_dict(model_to_dict(brand))
if hasattr(request, "user"): if hasattr(request, "user"):
original_user = None self.user = get_user(request.user)
if hasattr(request, "session"):
original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None)
self.user = get_user(request.user, original_user)
if user: if user:
self.user = get_user(user) self.user = get_user(user)
# Check if we're currently impersonating, and add that user
if hasattr(request, "session"): if hasattr(request, "session"):
from authentik.flows.views.executor import SESSION_KEY_PLAN
# Check if we're currently impersonating, and add that user
if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session:
self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER])
self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER])
# Special case for events that happen during a flow, the user might not be authenticated
# yet but is a pending user instead
if SESSION_KEY_PLAN in request.session:
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
pending_user = plan.context.get(PLAN_CONTEXT_PENDING_USER, None)
# Only save `authenticated_as` if there's a different pending user in the flow
# than the user that is authenticated
if pending_user and (
(pending_user.pk and pending_user.pk != self.user.get("pk"))
or (not pending_user.pk)
):
orig_user = self.user.copy()
self.user = {"authenticated_as": orig_user, **get_user(pending_user)}
# User 255.255.255.255 as fallback if IP cannot be determined # User 255.255.255.255 as fallback if IP cannot be determined
self.client_ip = ClientIPMiddleware.get_client_ip(request) self.client_ip = ClientIPMiddleware.get_client_ip(request)
# Enrich event data # Enrich event data

View File

@ -2,7 +2,9 @@
from django.test import TestCase from django.test import TestCase
from authentik.events.context_processors.base import get_context_processors
from authentik.events.context_processors.geoip import GeoIPContextProcessor from authentik.events.context_processors.geoip import GeoIPContextProcessor
from authentik.events.models import Event, EventAction
class TestGeoIP(TestCase): class TestGeoIP(TestCase):
@ -13,8 +15,7 @@ class TestGeoIP(TestCase):
def test_simple(self): def test_simple(self):
"""Test simple city wrapper""" """Test simple city wrapper"""
# IPs from # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
# https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
self.assertEqual( self.assertEqual(
self.reader.city_dict("2.125.160.216"), self.reader.city_dict("2.125.160.216"),
{ {
@ -25,3 +26,12 @@ class TestGeoIP(TestCase):
"long": -1.25, "long": -1.25,
}, },
) )
def test_special_chars(self):
"""Test city name with special characters"""
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
event = Event.new(EventAction.LOGIN)
event.client_ip = "89.160.20.112"
for processor in get_context_processors():
processor.enrich_event(event)
event.save()

View File

@ -8,9 +8,11 @@ from django.views.debug import SafeExceptionReporterFilter
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import Group from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event from authentik.events.models import Event
from authentik.flows.views.executor import QS_QUERY from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy from authentik.policies.dummy.models import DummyPolicy
@ -116,3 +118,92 @@ class TestEvents(TestCase):
"pk": brand.pk.hex, "pk": brand.pk.hex,
}, },
) )
def test_from_http_flow_pending_user(self):
"""Test request from flow request with a pending user"""
user = create_test_user()
session = self.client.session
plan = FlowPlan(generate_id())
plan.context[PLAN_CONTEXT_PENDING_USER] = user
session[SESSION_KEY_PLAN] = plan
session.save()
request = self.factory.get("/")
request.session = session
request.user = user
event = Event.new("unittest").from_http(request)
self.assertEqual(
event.user,
{
"email": user.email,
"pk": user.pk,
"username": user.username,
},
)
def test_from_http_flow_pending_user_anon(self):
"""Test request from flow request with a pending user"""
user = create_test_user()
anon = get_anonymous_user()
session = self.client.session
plan = FlowPlan(generate_id())
plan.context[PLAN_CONTEXT_PENDING_USER] = user
session[SESSION_KEY_PLAN] = plan
session.save()
request = self.factory.get("/")
request.session = session
request.user = anon
event = Event.new("unittest").from_http(request)
self.assertEqual(
event.user,
{
"authenticated_as": {
"pk": anon.pk,
"is_anonymous": True,
"username": "AnonymousUser",
"email": "",
},
"email": user.email,
"pk": user.pk,
"username": user.username,
},
)
def test_from_http_flow_pending_user_fake(self):
"""Test request from flow request with a pending user"""
user = User(
username=generate_id(),
email=generate_id(),
)
anon = get_anonymous_user()
session = self.client.session
plan = FlowPlan(generate_id())
plan.context[PLAN_CONTEXT_PENDING_USER] = user
session[SESSION_KEY_PLAN] = plan
session.save()
request = self.factory.get("/")
request.session = session
request.user = anon
event = Event.new("unittest").from_http(request)
self.assertEqual(
event.user,
{
"authenticated_as": {
"pk": anon.pk,
"is_anonymous": True,
"username": "AnonymousUser",
"email": "",
},
"email": user.email,
"pk": user.pk,
"username": user.username,
},
)

View File

@ -74,8 +74,8 @@ def model_to_dict(model: Model) -> dict[str, Any]:
} }
def get_user(user: User | AnonymousUser, original_user: User | None = None) -> dict[str, Any]: def get_user(user: User | AnonymousUser) -> dict[str, Any]:
"""Convert user object to dictionary, optionally including the original user""" """Convert user object to dictionary"""
if isinstance(user, AnonymousUser): if isinstance(user, AnonymousUser):
try: try:
user = get_anonymous_user() user = get_anonymous_user()
@ -88,10 +88,6 @@ def get_user(user: User | AnonymousUser, original_user: User | None = None) -> d
} }
if user.username == settings.ANONYMOUS_USER_NAME: if user.username == settings.ANONYMOUS_USER_NAME:
user_data["is_anonymous"] = True user_data["is_anonymous"] = True
if original_user:
original_data = get_user(original_user)
original_data["on_behalf_of"] = user_data
return original_data
return user_data return user_data

View File

@ -4,8 +4,10 @@ from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode from urllib.parse import urlencode
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_flow, create_test_user from authentik.core.tests.utils import create_test_flow, create_test_user
@ -648,3 +650,25 @@ class TestFlowExecutor(FlowTestCase):
self.assertStageResponse(response, flow, component="ak-stage-identification") self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True) response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
self.assertStageResponse(response, flow, component="ak-stage-access-denied") self.assertStageResponse(response, flow, component="ak-stage-access-denied")
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_json(self):
"""Test invalid JSON body"""
flow = create_test_flow()
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
with override_settings(TEST=False, DEBUG=False):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)
with self.assertRaises(ParseError):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)

View File

@ -55,7 +55,7 @@ from authentik.flows.planner import (
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import AccessDeniedStage, StageView from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
@ -234,12 +234,13 @@ class FlowExecutorView(APIView):
"""Handle exception in stage execution""" """Handle exception in stage execution"""
if settings.DEBUG or settings.TEST: if settings.DEBUG or settings.TEST:
raise exc raise exc
capture_exception(exc)
self._logger.warning(exc) self._logger.warning(exc)
Event.new( if not should_ignore_exception(exc):
action=EventAction.SYSTEM_EXCEPTION, capture_exception(exc)
message=exception_to_string(exc), Event.new(
).from_http(self.request) action=EventAction.SYSTEM_EXCEPTION,
message=exception_to_string(exc),
).from_http(self.request)
challenge = FlowErrorChallenge(self.request, exc) challenge = FlowErrorChallenge(self.request, exc)
challenge.is_valid(raise_exception=True) challenge.is_valid(raise_exception=True)
return to_stage_response(self.request, HttpChallengeResponse(challenge)) return to_stage_response(self.request, HttpChallengeResponse(challenge))

View File

@ -14,6 +14,7 @@ from django_redis.exceptions import ConnectionInterrupted
from docker.errors import DockerException from docker.errors import DockerException
from h11 import LocalProtocolError from h11 import LocalProtocolError
from ldap3.core.exceptions import LDAPException from ldap3.core.exceptions import LDAPException
from psycopg.errors import Error
from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError, ResponseError from redis.exceptions import RedisError, ResponseError
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
@ -44,6 +45,49 @@ class SentryIgnoredException(Exception):
"""Base Class for all errors that are suppressed, and not sent to sentry.""" """Base Class for all errors that are suppressed, and not sent to sentry."""
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
class SentryTransport(HttpTransport): class SentryTransport(HttpTransport):
"""Custom sentry transport with custom user-agent""" """Custom sentry transport with custom user-agent"""
@ -101,56 +145,17 @@ def traces_sampler(sampling_context: dict) -> float:
return float(CONFIG.get("error_reporting.sample_rate", 0.1)) return float(CONFIG.get("error_reporting.sample_rate", 0.1))
def should_ignore_exception(exc: Exception) -> bool:
"""Check if an exception should be dropped"""
return isinstance(exc, ignored_classes)
def before_send(event: dict, hint: dict) -> dict | None: def before_send(event: dict, hint: dict) -> dict | None:
"""Check if error is database error, and ignore if so""" """Check if error is database error, and ignore if so"""
from psycopg.errors import Error
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
exc_value = None exc_value = None
if "exc_info" in hint: if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"] _, exc_value, _ = hint["exc_info"]
if isinstance(exc_value, ignored_classes): if should_ignore_exception(exc_value):
LOGGER.debug("dropping exception", exc=exc_value) LOGGER.debug("dropping exception", exc=exc_value)
return None return None
if "logger" in event: if "logger" in event:

View File

@ -2,7 +2,7 @@
from django.test import TestCase from django.test import TestCase
from authentik.lib.sentry import SentryIgnoredException, before_send from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
class TestSentry(TestCase): class TestSentry(TestCase):
@ -10,8 +10,8 @@ class TestSentry(TestCase):
def test_error_not_sent(self): def test_error_not_sent(self):
"""Test SentryIgnoredError not sent""" """Test SentryIgnoredError not sent"""
self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)})) self.assertTrue(should_ignore_exception(SentryIgnoredException()))
def test_error_sent(self): def test_error_sent(self):
"""Test error sent""" """Test error sent"""
self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) self.assertFalse(should_ignore_exception(ValueError()))

View File

@ -1,15 +1,13 @@
"""authentik outpost signals""" """authentik outpost signals"""
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import AuthenticatedSession, Provider, User from authentik.core.models import AuthenticatedSession, Provider
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
@ -82,14 +80,6 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
@receiver(user_logged_out)
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to providers"""
if not request.session or not request.session.session_key:
return
outpost_session_end.delay(request.session.session_key)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted""" """Catch logout by expiring sessions being deleted"""

View File

@ -1,23 +1,10 @@
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import AuthenticatedSession, User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out)
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_): def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout""" """Revoke tokens upon user logout"""

View File

@ -555,6 +555,8 @@ class TokenView(View):
provider: OAuth2Provider | None = None provider: OAuth2Provider | None = None
params: TokenParams | None = None params: TokenParams | None = None
params_class = TokenParams
provider_class = OAuth2Provider
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = super().dispatch(request, *args, **kwargs) response = super().dispatch(request, *args, **kwargs)
@ -574,12 +576,14 @@ class TokenView(View):
op="authentik.providers.oauth2.post.parse", op="authentik.providers.oauth2.post.parse",
): ):
client_id, client_secret = extract_client_auth(request) client_id, client_secret = extract_client_auth(request)
self.provider = OAuth2Provider.objects.filter(client_id=client_id).first() self.provider = self.provider_class.objects.filter(client_id=client_id).first()
if not self.provider: if not self.provider:
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id) LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
raise TokenError("invalid_client") raise TokenError("invalid_client")
CTX_AUTH_VIA.set("oauth_client_secret") CTX_AUTH_VIA.set("oauth_client_secret")
self.params = TokenParams.parse(request, self.provider, client_id, client_secret) self.params = self.params_class.parse(
request, self.provider, client_id, client_secret
)
with start_span( with start_span(
op="authentik.providers.oauth2.post.response", op="authentik.providers.oauth2.post.response",

View File

@ -66,7 +66,10 @@ class RACClientConsumer(AsyncWebsocketConsumer):
def init_outpost_connection(self): def init_outpost_connection(self):
"""Initialize guac connection settings""" """Initialize guac connection settings"""
self.token = ( self.token = (
ConnectionToken.filter_not_expired(token=self.scope["url_route"]["kwargs"]["token"]) ConnectionToken.filter_not_expired(
token=self.scope["url_route"]["kwargs"]["token"],
session__session__session_key=self.scope["session"].session_key,
)
.select_related("endpoint", "provider", "session", "session__user") .select_related("endpoint", "provider", "session", "session__user")
.first() .first()
) )

View File

@ -2,13 +2,11 @@
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import AuthenticatedSession
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import ( from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION, RAC_CLIENT_GROUP_SESSION,
@ -17,21 +15,6 @@ from authentik.providers.rac.consumer_client import (
from authentik.providers.rac.models import ConnectionToken, Endpoint from authentik.providers.rac.models import ConnectionToken, Endpoint
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Disconnect any open RAC connections"""
if not request.session or not request.session.session_key:
return
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": request.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_): def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer() layer = get_channel_layer()

View File

@ -87,3 +87,22 @@ class TestRACViews(APITestCase):
) )
body = loads(flow_response.content) body = loads(flow_response.content)
self.assertEqual(body["component"], "ak-stage-access-denied") self.assertEqual(body["component"], "ak-stage-access-denied")
def test_different_session(self):
"""Test request"""
self.client.force_login(self.user)
response = self.client.get(
reverse(
"authentik_providers_rac:start",
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
)
)
self.assertEqual(response.status_code, 302)
flow_response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
body = loads(flow_response.content)
next_url = body["to"]
self.client.logout()
final_response = self.client.get(next_url)
self.assertEqual(final_response.url, reverse("authentik_core:if-user"))

View File

@ -68,7 +68,10 @@ class RACInterface(InterfaceView):
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# Early sanity check to ensure token still exists # Early sanity check to ensure token still exists
token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first() token = ConnectionToken.filter_not_expired(
token=self.kwargs["token"],
session__session__session_key=request.session.session_key,
).first()
if not token: if not token:
return redirect("authentik_core:if-user") return redirect("authentik_core:if-user")
self.token = token self.token = token

View File

@ -5,7 +5,6 @@ from itertools import batched
from django.db import transaction from django.db import transaction
from pydantic import ValidationError from pydantic import ValidationError
from pydanticscim.group import GroupMember from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp
from authentik.core.models import Group from authentik.core.models import Group
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.mapper import PropertyMappingManager
@ -20,7 +19,12 @@ from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import ( from authentik.providers.scim.clients.exceptions import (
SCIMRequestException, SCIMRequestException,
) )
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest from authentik.providers.scim.clients.schema import (
SCIM_GROUP_SCHEMA,
PatchOp,
PatchOperation,
PatchRequest,
)
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.providers.scim.models import ( from authentik.providers.scim.models import (
SCIMMapping, SCIMMapping,

View File

@ -1,5 +1,7 @@
"""Custom SCIM schemas""" """Custom SCIM schemas"""
from enum import Enum
from pydantic import Field from pydantic import Field
from pydanticscim.group import Group as BaseGroup from pydanticscim.group import Group as BaseGroup
from pydanticscim.responses import PatchOperation as BasePatchOperation from pydanticscim.responses import PatchOperation as BasePatchOperation
@ -65,6 +67,21 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
) )
class PatchOp(str, Enum):
replace = "replace"
remove = "remove"
add = "add"
@classmethod
def _missing_(cls, value):
value = value.lower()
for member in cls:
if member.lower() == value:
return member
return None
class PatchRequest(BasePatchRequest): class PatchRequest(BasePatchRequest):
"""PatchRequest which correctly sets schemas""" """PatchRequest which correctly sets schemas"""
@ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest):
class PatchOperation(BasePatchOperation): class PatchOperation(BasePatchOperation):
"""PatchOperation with optional path""" """PatchOperation with optional path"""
op: PatchOp
path: str | None path: str | None

View File

@ -27,7 +27,7 @@ from structlog.stdlib import get_logger
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
from authentik import get_full_version from authentik import get_full_version
from authentik.lib.sentry import before_send from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
@ -81,7 +81,7 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception) LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception)
CTX_TASK_ID.set(...) CTX_TASK_ID.set(...)
if before_send({}, {"exc_info": (None, exception, None)}) is not None: if not should_ignore_exception(exception):
Event.new( Event.new(
EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id
).save() ).save()

View File

@ -1,13 +1,49 @@
"""authentik database backend""" """authentik database backend"""
from django.core.checks import Warning
from django.db.backends.base.validation import BaseDatabaseValidation
from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
class DatabaseValidation(BaseDatabaseValidation):
def check(self, **kwargs):
return self._check_encoding()
def _check_encoding(self):
"""Throw a warning when the server_encoding is not UTF-8 or
server_encoding and client_encoding are mismatched"""
messages = []
with self.connection.cursor() as cursor:
cursor.execute("SHOW server_encoding;")
server_encoding = cursor.fetchone()[0]
cursor.execute("SHOW client_encoding;")
client_encoding = cursor.fetchone()[0]
if server_encoding != client_encoding:
messages.append(
Warning(
"PostgreSQL Server and Client encoding are mismatched: Server: "
f"{server_encoding}, Client: {client_encoding}",
id="ak.db.W001",
)
)
if server_encoding != "UTF8":
messages.append(
Warning(
f"PostgreSQL Server encoding is not UTF8: {server_encoding}",
id="ak.db.W002",
)
)
return messages
class DatabaseWrapper(BaseDatabaseWrapper): class DatabaseWrapper(BaseDatabaseWrapper):
"""database backend which supports rotating credentials""" """database backend which supports rotating credentials"""
validation_class = DatabaseValidation
def get_connection_params(self): def get_connection_params(self):
"""Refresh DB credentials before getting connection params""" """Refresh DB credentials before getting connection params"""
conn_params = super().get_connection_params() conn_params = super().get_connection_params()

View File

@ -61,6 +61,22 @@ class SessionMiddleware(UpstreamSessionMiddleware):
pass pass
return session_key return session_key
@staticmethod
def encode_session(session_key: str, user: User):
payload = {
"sid": session_key,
"iss": "authentik",
"sub": "anonymous",
"authenticated": user.is_authenticated,
"acr": ACR_AUTHENTIK_SESSION,
}
if user.is_authenticated:
payload["sub"] = user.uid
value = encode(payload=payload, key=SIGNING_HASH)
if settings.TEST:
value = session_key
return value
def process_request(self, request: HttpRequest): def process_request(self, request: HttpRequest):
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
session_key = SessionMiddleware.decode_session_key(raw_session) session_key = SessionMiddleware.decode_session_key(raw_session)
@ -117,21 +133,9 @@ class SessionMiddleware(UpstreamSessionMiddleware):
"request completed. The user may have logged " "request completed. The user may have logged "
"out in a concurrent request, for example." "out in a concurrent request, for example."
) from None ) from None
payload = {
"sid": request.session.session_key,
"iss": "authentik",
"sub": "anonymous",
"authenticated": request.user.is_authenticated,
"acr": ACR_AUTHENTIK_SESSION,
}
if request.user.is_authenticated:
payload["sub"] = request.user.uid
value = encode(payload=payload, key=SIGNING_HASH)
if settings.TEST:
value = request.session.session_key
response.set_cookie( response.set_cookie(
settings.SESSION_COOKIE_NAME, settings.SESSION_COOKIE_NAME,
value, SessionMiddleware.encode_session(request.session.session_key, request.user),
max_age=max_age, max_age=max_age,
expires=expires, expires=expires,
domain=settings.SESSION_COOKIE_DOMAIN, domain=settings.SESSION_COOKIE_DOMAIN,

View File

@ -0,0 +1,277 @@
"""Test SCIM Group"""
from json import dumps
from uuid import uuid4
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.sources.scim.models import (
SCIMSource,
SCIMSourceGroup,
)
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
class TestSCIMGroups(APITestCase):
"""Test SCIM Group view"""
def setUp(self) -> None:
self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id())
def test_group_list(self):
"""Test full group list"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_group_list_single(self):
"""Test full group list (single group)"""
group = Group.objects.create(name=generate_id())
user = create_test_user()
group.users.add(user)
SCIMSourceGroup.objects.create(
source=self.source,
group=group,
id=str(uuid4()),
)
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(group.pk),
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
SCIMGroupSchema.model_validate_json(response.content, strict=True)
def test_group_create(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members(self):
"""Test group create"""
user = create_test_user()
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{
"displayName": generate_id(),
"externalId": ext_id,
"members": [{"value": str(user.uuid)}],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members_empty(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_duplicate(self):
"""Test group create (duplicate)"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 409)
self.assertJSONEqual(
response.content,
{
"detail": "Group with ID exists already.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "uniqueness",
"status": 409,
},
)
def test_group_update(self):
"""Test group update"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
def test_group_update_non_existent(self):
"""Test group update"""
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(uuid4()),
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=404)
self.assertJSONEqual(
response.content,
{
"detail": "Group not found.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": 404,
},
)
def test_group_patch_add(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "Add",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertTrue(group.users.filter(pk=user.pk).exists())
def test_group_patch_remove(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
group.users.add(user)
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "remove",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertFalse(group.users.filter(pk=user.pk).exists())
def test_group_delete(self):
"""Test group delete"""
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=204)

View File

@ -177,3 +177,51 @@ class TestSCIMUsers(APITestCase):
SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"],
"0123456789", "0123456789",
) )
def test_user_update(self):
"""Test user update"""
user = create_test_user()
existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
data=dumps(
{
"id": str(existing.pk),
"userName": generate_id(),
"externalId": ext_id,
"emails": [
{
"primary": True,
"value": user.email,
}
],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_user_delete(self):
"""Test user delete"""
user = create_test_user()
SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 204)

View File

@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.sources.scim.models import SCIMSource from authentik.sources.scim.models import SCIMSource
@ -26,6 +27,7 @@ class SCIMTokenAuth(BaseAuthentication):
_username, _, password = b64decode(key.encode()).decode().partition(":") _username, _, password = b64decode(key.encode()).decode().partition(":")
token = self.check_token(password, source_slug) token = self.check_token(password, source_slug)
if token: if token:
CTX_AUTH_VIA.set("scim_basic")
return (token.user, token) return (token.user, token)
return None return None
@ -52,4 +54,5 @@ class SCIMTokenAuth(BaseAuthentication):
token = self.check_token(key, source_slug) token = self.check_token(key, source_slug)
if not token: if not token:
return None return None
CTX_AUTH_VIA.set("scim_token")
return (token.user, token) return (token.user, token)

View File

@ -1,13 +1,11 @@
"""SCIM Utils""" """SCIM Utils"""
from typing import Any from typing import Any
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.core.paginator import Page, Paginator from django.core.paginator import Page, Paginator
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import resolve
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
@ -46,7 +44,7 @@ class SCIMView(APIView):
logger: BoundLogger logger: BoundLogger
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
parser_classes = [SCIMParser] parser_classes = [SCIMParser, JSONParser]
renderer_classes = [SCIMRenderer] renderer_classes = [SCIMRenderer]
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
@ -56,28 +54,6 @@ class SCIMView(APIView):
def get_authenticators(self): def get_authenticators(self):
return [SCIMTokenAuth(self)] return [SCIMTokenAuth(self)]
def patch_resolve_value(self, raw_value: dict) -> User | Group | None:
"""Attempt to resolve a raw `value` attribute of a patch operation into
a database model"""
model = User
query = {}
if "$ref" in raw_value:
url = urlparse(raw_value["$ref"])
if match := resolve(url.path):
if match.url_name == "v2-users":
model = User
query = {"pk": int(match.kwargs["user_id"])}
elif "type" in raw_value:
match raw_value["type"]:
case "User":
model = User
query = {"pk": int(raw_value["value"])}
case "Group":
model = Group
else:
return None
return model.objects.filter(**query).first()
def filter_parse(self, request: Request): def filter_parse(self, request: Request):
"""Parse the path of a Patch Operation""" """Parse the path of a Patch Operation"""
path = request.query_params.get("filter") path = request.query_params.get("filter")

View File

@ -0,0 +1,58 @@
from enum import Enum
from pydanticscim.responses import SCIMError as BaseSCIMError
from rest_framework.exceptions import ValidationError
class SCIMErrorTypes(Enum):
invalid_filter = "invalidFilter"
too_many = "tooMany"
uniqueness = "uniqueness"
mutability = "mutability"
invalid_syntax = "invalidSyntax"
invalid_path = "invalidPath"
no_target = "noTarget"
invalid_value = "invalidValue"
invalid_vers = "invalidVers"
sensitive = "sensitive"
class SCIMError(BaseSCIMError):
scimType: SCIMErrorTypes | None = None
detail: str | None = None
class SCIMValidationError(ValidationError):
status_code = 400
default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400)
def __init__(self, detail: SCIMError | None):
if detail is None:
detail = self.default_detail
detail.status = self.status_code
self.detail = detail.model_dump(mode="json", exclude_none=True)
class SCIMConflictError(SCIMValidationError):
status_code = 409
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
scimType=SCIMErrorTypes.uniqueness,
status=self.status_code,
)
)
class SCIMNotFoundError(SCIMValidationError):
status_code = 404
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
status=self.status_code,
)
)

View File

@ -4,19 +4,25 @@ from uuid import uuid4
from django.db.models import Q from django.db.models import Q
from django.db.transaction import atomic from django.db.transaction import atomic
from django.http import Http404, QueryDict from django.http import QueryDict
from django.urls import reverse from django.urls import reverse
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from pydanticscim.group import GroupMember from pydanticscim.group import GroupMember
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from scim2_filter_parser.attr_paths import AttrPath
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
from authentik.sources.scim.models import SCIMSourceGroup from authentik.sources.scim.models import SCIMSourceGroup
from authentik.sources.scim.views.v2.base import SCIMObjectView from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import (
SCIMConflictError,
SCIMNotFoundError,
SCIMValidationError,
)
class GroupsView(SCIMObjectView): class GroupsView(SCIMObjectView):
@ -27,7 +33,7 @@ class GroupsView(SCIMObjectView):
def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
"""Convert Group to SCIM data""" """Convert Group to SCIM data"""
payload = SCIMGroupModel( payload = SCIMGroupModel(
schemas=[SCIM_USER_SCHEMA], schemas=[SCIM_GROUP_SCHEMA],
id=str(scim_group.group.pk), id=str(scim_group.group.pk),
externalId=scim_group.id, externalId=scim_group.id,
displayName=scim_group.group.name, displayName=scim_group.group.name,
@ -58,7 +64,7 @@ class GroupsView(SCIMObjectView):
if group_id: if group_id:
connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("Group not found.")
return Response(self.group_to_scim(connection)) return Response(self.group_to_scim(connection))
connections = ( connections = (
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
@ -119,7 +125,7 @@ class GroupsView(SCIMObjectView):
).first() ).first()
if connection: if connection:
self.logger.debug("Found existing group") self.logger.debug("Found existing group")
return Response(status=409) raise SCIMConflictError("Group with ID exists already.")
connection = self.update_group(None, request.data) connection = self.update_group(None, request.data)
return Response(self.group_to_scim(connection), status=201) return Response(self.group_to_scim(connection), status=201)
@ -129,10 +135,44 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id source=self.source, group__group_uuid=group_id
).first() ).first()
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("Group not found.")
connection = self.update_group(connection, request.data) connection = self.update_group(connection, request.data)
return Response(self.group_to_scim(connection), status=200) return Response(self.group_to_scim(connection), status=200)
@atomic
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
"""Patch group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise SCIMNotFoundError("Group not found.")
for _op in request.data.get("Operations", []):
operation = PatchOperation.model_validate(_op)
if operation.op.lower() not in ["add", "remove", "replace"]:
raise SCIMValidationError()
attr_path = AttrPath(f'{operation.path} eq ""', {})
if attr_path.first_path == ("members", None, None):
# FIXME: this can probably be de-duplicated
if operation.op == PatchOp.add:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.add(*User.objects.filter(query))
elif operation.op == PatchOp.remove:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.remove(*User.objects.filter(query))
return Response(self.group_to_scim(connection), status=200)
@atomic @atomic
def delete(self, request: Request, group_id: str, **kwargs) -> Response: def delete(self, request: Request, group_id: str, **kwargs) -> Response:
"""Delete group handler""" """Delete group handler"""
@ -140,7 +180,7 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id source=self.source, group__group_uuid=group_id
).first() ).first()
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("Group not found.")
connection.group.delete() connection.group.delete()
connection.delete() connection.delete()
return Response(status=204) return Response(status=204)

View File

@ -1,11 +1,11 @@
"""SCIM Meta views""" """SCIM Meta views"""
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
class ResourceTypesView(SCIMView): class ResourceTypesView(SCIMView):
@ -138,7 +138,7 @@ class ResourceTypesView(SCIMView):
resource = [x for x in resource_types if x.get("id") == resource_type] resource = [x for x in resource_types if x.get("id") == resource_type]
if resource: if resource:
return Response(resource[0]) return Response(resource[0])
raise Http404 raise SCIMNotFoundError("Resource not found.")
return Response( return Response(
{ {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -3,12 +3,12 @@
from json import loads from json import loads
from django.conf import settings from django.conf import settings
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
with open( with open(
settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json",
@ -44,7 +44,7 @@ class SchemaView(SCIMView):
schema = [x for x in schemas if x.get("id") == schema_uri] schema = [x for x in schemas if x.get("id") == schema_uri]
if schema: if schema:
return Response(schema[0]) return Response(schema[0])
raise Http404 raise SCIMNotFoundError("Schema not found.")
return Response( return Response(
{ {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -33,6 +33,8 @@ class ServiceProviderConfigView(SCIMView):
{ {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"authenticationSchemes": auth_schemas, "authenticationSchemes": auth_schemas,
# We only support patch for groups currently, so don't broadly advertise it.
# Implementations that require Group patch will use it regardless of this flag.
"patch": {"supported": False}, "patch": {"supported": False},
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": { "filter": {

View File

@ -4,7 +4,7 @@ from uuid import uuid4
from django.db.models import Q from django.db.models import Q
from django.db.transaction import atomic from django.db.transaction import atomic
from django.http import Http404, QueryDict from django.http import QueryDict
from django.urls import reverse from django.urls import reverse
from pydanticscim.user import Email, EmailKind, Name from pydanticscim.user import Email, EmailKind, Name
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -16,6 +16,7 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import User as SCIMUserModel from authentik.providers.scim.clients.schema import User as SCIMUserModel
from authentik.sources.scim.models import SCIMSourceUser from authentik.sources.scim.models import SCIMSourceUser
from authentik.sources.scim.views.v2.base import SCIMObjectView from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError
class UsersView(SCIMObjectView): class UsersView(SCIMObjectView):
@ -69,7 +70,7 @@ class UsersView(SCIMObjectView):
.first() .first()
) )
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("User not found.")
return Response(self.user_to_scim(connection)) return Response(self.user_to_scim(connection))
connections = ( connections = (
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
@ -122,7 +123,7 @@ class UsersView(SCIMObjectView):
).first() ).first()
if connection: if connection:
self.logger.debug("Found existing user") self.logger.debug("Found existing user")
return Response(status=409) raise SCIMConflictError("Group with ID exists already.")
connection = self.update_user(None, request.data) connection = self.update_user(None, request.data)
return Response(self.user_to_scim(connection), status=201) return Response(self.user_to_scim(connection), status=201)
@ -130,7 +131,7 @@ class UsersView(SCIMObjectView):
"""Update user handler""" """Update user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("User not found.")
self.update_user(connection, request.data) self.update_user(connection, request.data)
return Response(self.user_to_scim(connection), status=200) return Response(self.user_to_scim(connection), status=200)
@ -139,7 +140,7 @@ class UsersView(SCIMObjectView):
"""Delete user handler""" """Delete user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection: if not connection:
raise Http404 raise SCIMNotFoundError("User not found.")
connection.user.delete() connection.user.delete()
connection.delete() connection.delete()
return Response(status=204) return Response(status=204)

View File

@ -1,6 +1,7 @@
"""Validation stage challenge checking""" """Validation stage challenge checking"""
from json import loads from json import loads
from typing import TYPE_CHECKING
from urllib.parse import urlencode from urllib.parse import urlencode
from django.http import HttpRequest from django.http import HttpRequest
@ -36,10 +37,12 @@ from authentik.stages.authenticator_email.models import EmailDevice
from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_sms.models import SMSDevice
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING:
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
class DeviceChallenge(PassiveSerializer): class DeviceChallenge(PassiveSerializer):
@ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer):
def get_challenge_for_device( def get_challenge_for_device(
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device
) -> dict: ) -> dict:
"""Generate challenge for a single device""" """Generate challenge for a single device"""
if isinstance(device, WebAuthnDevice): if isinstance(device, WebAuthnDevice):
return get_webauthn_challenge(request, stage, device) return get_webauthn_challenge(stage_view, stage, device)
if isinstance(device, EmailDevice): if isinstance(device, EmailDevice):
return {"email": mask_email(device.email)} return {"email": mask_email(device.email)}
# Code-based challenges have no hints # Code-based challenges have no hints
@ -64,26 +67,30 @@ def get_challenge_for_device(
def get_webauthn_challenge_without_user( def get_webauthn_challenge_without_user(
request: HttpRequest, stage: AuthenticatorValidateStage stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage
) -> dict: ) -> dict:
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check """Same as `get_webauthn_challenge`, but allows any client device. We can then later check
who the device belongs to.""" who the device belongs to."""
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
authentication_options = generate_authentication_options( authentication_options = generate_authentication_options(
rp_id=get_rp_id(request), rp_id=get_rp_id(stage_view.request),
allow_credentials=[], allow_credentials=[],
user_verification=UserVerificationRequirement(stage.webauthn_user_verification), user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
) )
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
authentication_options.challenge
)
return loads(options_to_json(authentication_options)) return loads(options_to_json(authentication_options))
def get_webauthn_challenge( def get_webauthn_challenge(
request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None stage_view: "AuthenticatorValidateStageView",
stage: AuthenticatorValidateStage,
device: WebAuthnDevice | None = None,
) -> dict: ) -> dict:
"""Send the client a challenge that we'll check later""" """Send the client a challenge that we'll check later"""
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
allowed_credentials = [] allowed_credentials = []
@ -94,12 +101,14 @@ def get_webauthn_challenge(
allowed_credentials.append(user_device.descriptor) allowed_credentials.append(user_device.descriptor)
authentication_options = generate_authentication_options( authentication_options = generate_authentication_options(
rp_id=get_rp_id(request), rp_id=get_rp_id(stage_view.request),
allow_credentials=allowed_credentials, allow_credentials=allowed_credentials,
user_verification=UserVerificationRequirement(stage.webauthn_user_verification), user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
) )
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
authentication_options.challenge
)
return loads(options_to_json(authentication_options)) return loads(options_to_json(authentication_options))
@ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
"""Validate WebAuthn Challenge""" """Validate WebAuthn Challenge"""
request = stage_view.request request = stage_view.request
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
stage: AuthenticatorValidateStage = stage_view.executor.current_stage stage: AuthenticatorValidateStage = stage_view.executor.current_stage
try: try:
credential = parse_authentication_credential_json(data) credential = parse_authentication_credential_json(data)

View File

@ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
data={ data={
"device_class": device_class, "device_class": device_class,
"device_uid": device.pk, "device_uid": device.pk,
"challenge": get_challenge_for_device(self.request, stage, device), "challenge": get_challenge_for_device(self, stage, device),
"last_used": device.last_used, "last_used": device.last_used,
} }
) )
@ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
"device_class": DeviceClasses.WEBAUTHN, "device_class": DeviceClasses.WEBAUTHN,
"device_uid": -1, "device_uid": -1,
"challenge": get_webauthn_challenge_without_user( "challenge": get_webauthn_challenge_without_user(
self.request, self,
self.executor.current_stage, self.executor.current_stage,
), ),
"last_used": None, "last_used": None,

View File

@ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice, WebAuthnDevice,
WebAuthnDeviceType, WebAuthnDeviceType,
) )
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
from authentik.stages.identification.models import IdentificationStage, UserFields from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
@ -103,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
webauthn_user_verification=UserVerification.PREFERRED, webauthn_user_verification=UserVerification.PREFERRED,
) )
challenge = get_challenge_for_device(request, stage, webauthn_device) plan = FlowPlan("")
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
del challenge["challenge"] del challenge["challenge"]
self.assertEqual( self.assertEqual(
challenge, challenge,
@ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
validate_challenge_webauthn( validate_challenge_webauthn(
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user {},
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
self.user,
) )
def test_device_challenge_webauthn_restricted(self): def test_device_challenge_webauthn_restricted(self):
@ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0, sign_count=0,
rp_id=generate_id(), rp_id=generate_id(),
) )
challenge = get_challenge_for_device(request, stage, webauthn_device) plan = FlowPlan("")
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
self.assertEqual( self.assertEqual(
challenge, challenge["allowCredentials"],
{ [
"allowCredentials": [ {
{ "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", "type": "public-key",
"type": "public-key", }
} ],
], )
"challenge": bytes_to_base64url(webauthn_challenge), self.assertIsNotNone(challenge["challenge"])
"rpId": "testserver", self.assertEqual(
"timeout": 60000, challenge["rpId"],
"userVerification": "preferred", "testserver",
}, )
self.assertEqual(
challenge["timeout"],
60000,
)
self.assertEqual(
challenge["userVerification"],
"preferred",
) )
def test_get_challenge_userless(self): def test_get_challenge_userless(self):
@ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0, sign_count=0,
rp_id=generate_id(), rp_id=generate_id(),
) )
challenge = get_webauthn_challenge_without_user(request, stage) plan = FlowPlan("")
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] stage_view = AuthenticatorValidateStageView(
self.assertEqual( FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
challenge,
{
"allowCredentials": [],
"challenge": bytes_to_base64url(webauthn_challenge),
"rpId": "testserver",
"timeout": 60000,
"userVerification": "preferred",
},
) )
challenge = get_webauthn_challenge_without_user(stage_view, stage)
self.assertEqual(challenge["allowCredentials"], [])
self.assertIsNotNone(challenge["challenge"])
self.assertEqual(challenge["rpId"], "testserver")
self.assertEqual(challenge["timeout"], 60000)
self.assertEqual(challenge["userVerification"], "preferred")
def test_validate_challenge_unrestricted(self): def test_validate_challenge_unrestricted(self):
"""Test webauthn authentication (unrestricted webauthn device)""" """Test webauthn authentication (unrestricted webauthn device)"""
@ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
session[SESSION_KEY_PLAN] = plan plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
session[SESSION_KEY_PLAN] = plan plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None, "last_used": None,
} }
] ]
session[SESSION_KEY_PLAN] = plan plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
@ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
) )
stage_view = AuthenticatorValidateStageView( plan = FlowPlan(flow.pk.hex)
FlowExecutorView(flow=flow, current_stage=stage), request=request plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
)
request = get_request("/")
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )
request.session.save() request = get_request("/")
stage_view = AuthenticatorValidateStageView( stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=flow, current_stage=stage), request=request FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request
) )
request.META["SERVER_NAME"] = "localhost" request.META["SERVER_NAME"] = "localhost"
request.META["SERVER_PORT"] = "9000" request.META["SERVER_PORT"] = "9000"

View File

@ -25,6 +25,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
"resident_key_requirement", "resident_key_requirement",
"device_type_restrictions", "device_type_restrictions",
"device_type_restrictions_obj", "device_type_restrictions_obj",
"max_attempts",
] ]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.11 on 2025-06-13 22:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_webauthn",
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
),
]
operations = [
migrations.AddField(
model_name="authenticatorwebauthnstage",
name="max_attempts",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True) device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
max_attempts = models.PositiveIntegerField(default=0)
@property @property
def serializer(self) -> type[BaseSerializer]: def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_webauthn.api.stages import ( from authentik.stages.authenticator_webauthn.api.stages import (

View File

@ -5,12 +5,13 @@ from uuid import UUID
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.translation import gettext as __
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from webauthn import options_to_json from webauthn import options_to_json
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
from webauthn.helpers.exceptions import InvalidRegistrationResponse from webauthn.helpers.exceptions import WebAuthnException
from webauthn.helpers.structs import ( from webauthn.helpers.structs import (
AttestationConveyancePreference, AttestationConveyancePreference,
AuthenticatorAttachment, AuthenticatorAttachment,
@ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import (
) )
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge" PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge"
PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt"
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
@ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
def validate_response(self, response: dict) -> dict: def validate_response(self, response: dict) -> dict:
"""Validate webauthn challenge response""" """Validate webauthn challenge response"""
challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]
try: try:
registration: VerifiedRegistration = verify_registration_response( registration: VerifiedRegistration = verify_registration_response(
@ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
expected_rp_id=get_rp_id(self.request), expected_rp_id=get_rp_id(self.request),
expected_origin=get_origin(self.request), expected_origin=get_origin(self.request),
) )
except InvalidRegistrationResponse as exc: except WebAuthnException as exc:
self.stage.logger.warning("registration failed", exc=exc) self.stage.logger.warning("registration failed", exc=exc)
raise ValidationError(f"Registration failed. Error: {exc}") from None raise ValidationError(f"Registration failed. Error: {exc}") from None
@ -114,9 +116,10 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response_class = AuthenticatorWebAuthnChallengeResponse response_class = AuthenticatorWebAuthnChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge: def get_challenge(self, *args, **kwargs) -> Challenge:
# clear session variables prior to starting a new registration
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage: AuthenticatorWebAuthnStage = self.executor.current_stage stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
# clear flow variables prior to starting a new registration
self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
user = self.get_pending_user() user = self.get_pending_user()
# library accepts none so we store null in the database, but if there is a value # library accepts none so we store null in the database, but if there is a value
@ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
attestation=AttestationConveyancePreference.DIRECT, attestation=AttestationConveyancePreference.DIRECT,
) )
self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge
self.request.session.save()
return AuthenticatorWebAuthnChallenge( return AuthenticatorWebAuthnChallenge(
data={ data={
"registration": loads(options_to_json(registration_options)), "registration": loads(options_to_json(registration_options)),
@ -153,6 +155,24 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response.user = self.get_pending_user() response.user = self.get_pending_user()
return response return response
def challenge_invalid(self, response):
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1
if (
stage.max_attempts > 0
and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts
):
return self.executor.stage_invalid(
__(
"Exceeded maximum attempts. "
"Contact your {brand} administrator for help.".format(
brand=self.request.brand.branding_title
)
)
)
return super().challenge_invalid(response)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated # Webauthn Challenge has already been validated
webauthn_credential: VerifiedRegistration = response.validated_data["response"] webauthn_credential: VerifiedRegistration = response.validated_data["response"]
@ -179,6 +199,3 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
else: else:
return self.executor.stage_invalid("Device with Credential ID already exists.") return self.executor.stage_invalid("Device with Credential ID already exists.")
return self.executor.stage_ok() return self.executor.stage_ok()
def cleanup(self):
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)

View File

@ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice, WebAuthnDevice,
WebAuthnDeviceType, WebAuthnDeviceType,
) )
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
@ -57,6 +57,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
response = self.client.get( response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
) )
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
session = self.client.session session = self.client.session
self.assertStageResponse( self.assertStageResponse(
@ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"name": self.user.username, "name": self.user.username,
"displayName": self.user.name, "displayName": self.user.name,
}, },
"challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]), "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]),
"pubKeyCredParams": [ "pubKeyCredParams": [
{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -8}, {"type": "public-key", "alg": -8},
@ -97,11 +100,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"""Test registration""" """Test registration"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
) )
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -146,11 +149,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
) )
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -209,11 +212,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
) )
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -259,11 +262,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
) )
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save() session.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -298,3 +301,109 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_max_retries(self):
"""Test registration (exceeding max retries)"""
self.stage.max_attempts = 2
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
# first failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-authenticator-webauthn",
response_errors={
"response": [
{
"string": (
"Registration failed. Error: Unable to decode "
"client_data_json bytes as JSON"
),
"code": "invalid",
}
]
},
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
# Second failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-access-denied",
error_message=(
"Exceeded maximum attempts. Contact your authentik administrator for help."
),
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())

View File

@ -27,7 +27,6 @@
</table> </table>
</td> </td>
</tr> </tr>
<td>
{% endblock %} {% endblock %}
{% block sub_content %} {% block sub_content %}

View File

@ -1,6 +1,7 @@
"""Serializer for tenants models""" """Serializer for tenants models"""
from django_tenants.utils import get_public_schema_name from django_tenants.utils import get_public_schema_name
from rest_framework.fields import JSONField
from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
@ -12,6 +13,8 @@ from authentik.tenants.models import Tenant
class SettingsSerializer(ModelSerializer): class SettingsSerializer(ModelSerializer):
"""Settings Serializer""" """Settings Serializer"""
footer_links = JSONField(required=False)
class Meta: class Meta:
model = Tenant model = Tenant
fields = [ fields = [

View File

@ -16,6 +16,7 @@ def check_embedded_outpost_disabled(app_configs, **kwargs):
"Embedded outpost must be disabled when tenants API is enabled.", "Embedded outpost must be disabled when tenants API is enabled.",
hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to " hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to "
"True, or disable the tenants API by setting tenants.enabled to False", "True, or disable the tenants API by setting tenants.enabled to False",
id="ak.tenants.E001",
) )
] ]
return [] return []

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2025.6.2 Blueprint schema", "title": "authentik 2025.6.3 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"
@ -496,6 +496,46 @@
} }
} }
}, },
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_providers_apple_psso.appleplatformssoprovider"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_providers_apple_psso.appleplatformssoprovider_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_providers_apple_psso.appleplatformssoprovider"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_providers_apple_psso.appleplatformssoprovider"
}
}
},
{ {
"type": "object", "type": "object",
"required": [ "required": [
@ -5028,6 +5068,22 @@
"authentik_policies_unique_password.delete_userpasswordhistory", "authentik_policies_unique_password.delete_userpasswordhistory",
"authentik_policies_unique_password.view_uniquepasswordpolicy", "authentik_policies_unique_password.view_uniquepasswordpolicy",
"authentik_policies_unique_password.view_userpasswordhistory", "authentik_policies_unique_password.view_userpasswordhistory",
"authentik_providers_apple_psso.add_appledevice",
"authentik_providers_apple_psso.add_appledeviceuser",
"authentik_providers_apple_psso.add_applenonce",
"authentik_providers_apple_psso.add_appleplatformssoprovider",
"authentik_providers_apple_psso.change_appledevice",
"authentik_providers_apple_psso.change_appledeviceuser",
"authentik_providers_apple_psso.change_applenonce",
"authentik_providers_apple_psso.change_appleplatformssoprovider",
"authentik_providers_apple_psso.delete_appledevice",
"authentik_providers_apple_psso.delete_appledeviceuser",
"authentik_providers_apple_psso.delete_applenonce",
"authentik_providers_apple_psso.delete_appleplatformssoprovider",
"authentik_providers_apple_psso.view_appledevice",
"authentik_providers_apple_psso.view_appledeviceuser",
"authentik_providers_apple_psso.view_applenonce",
"authentik_providers_apple_psso.view_appleplatformssoprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovider", "authentik_providers_google_workspace.add_googleworkspaceprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovidergroup", "authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
"authentik_providers_google_workspace.add_googleworkspaceprovidermapping", "authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
@ -5599,6 +5655,43 @@
} }
} }
}, },
"model_authentik_providers_apple_psso.appleplatformssoprovider": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
}
},
"required": []
},
"model_authentik_providers_apple_psso.appleplatformssoprovider_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_appleplatformssoprovider",
"change_appleplatformssoprovider",
"delete_appleplatformssoprovider",
"view_appleplatformssoprovider"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_providers_google_workspace.googleworkspaceprovider": { "model_authentik_providers_google_workspace.googleworkspaceprovider": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7342,6 +7435,7 @@
"authentik.enterprise", "authentik.enterprise",
"authentik.enterprise.audit", "authentik.enterprise.audit",
"authentik.enterprise.policies.unique_password", "authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.apple_psso",
"authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf", "authentik.enterprise.providers.ssf",
@ -7452,6 +7546,7 @@
"authentik_core.token", "authentik_core.token",
"authentik_enterprise.license", "authentik_enterprise.license",
"authentik_policies_unique_password.uniquepasswordpolicy", "authentik_policies_unique_password.uniquepasswordpolicy",
"authentik_providers_apple_psso.appleplatformssoprovider",
"authentik_providers_google_workspace.googleworkspaceprovider", "authentik_providers_google_workspace.googleworkspaceprovider",
"authentik_providers_google_workspace.googleworkspaceprovidermapping", "authentik_providers_google_workspace.googleworkspaceprovidermapping",
"authentik_providers_microsoft_entra.microsoftentraprovider", "authentik_providers_microsoft_entra.microsoftentraprovider",
@ -9674,6 +9769,22 @@
"authentik_policies_unique_password.delete_userpasswordhistory", "authentik_policies_unique_password.delete_userpasswordhistory",
"authentik_policies_unique_password.view_uniquepasswordpolicy", "authentik_policies_unique_password.view_uniquepasswordpolicy",
"authentik_policies_unique_password.view_userpasswordhistory", "authentik_policies_unique_password.view_userpasswordhistory",
"authentik_providers_apple_psso.add_appledevice",
"authentik_providers_apple_psso.add_appledeviceuser",
"authentik_providers_apple_psso.add_applenonce",
"authentik_providers_apple_psso.add_appleplatformssoprovider",
"authentik_providers_apple_psso.change_appledevice",
"authentik_providers_apple_psso.change_appledeviceuser",
"authentik_providers_apple_psso.change_applenonce",
"authentik_providers_apple_psso.change_appleplatformssoprovider",
"authentik_providers_apple_psso.delete_appledevice",
"authentik_providers_apple_psso.delete_appledeviceuser",
"authentik_providers_apple_psso.delete_applenonce",
"authentik_providers_apple_psso.delete_appleplatformssoprovider",
"authentik_providers_apple_psso.view_appledevice",
"authentik_providers_apple_psso.view_appledeviceuser",
"authentik_providers_apple_psso.view_applenonce",
"authentik_providers_apple_psso.view_appleplatformssoprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovider", "authentik_providers_google_workspace.add_googleworkspaceprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovidergroup", "authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
"authentik_providers_google_workspace.add_googleworkspaceprovidermapping", "authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
@ -13310,6 +13421,12 @@
"format": "uuid" "format": "uuid"
}, },
"title": "Device type restrictions" "title": "Device type restrictions"
},
"max_attempts": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Max attempts"
} }
}, },
"required": [] "required": []

View File

@ -1,6 +1,8 @@
version: 1 version: 1
metadata: metadata:
name: OIDC conformance testing name: OpenID Conformance testing
labels:
blueprints.goauthentik.io/instantiate: "false"
entries: entries:
- identifiers: - identifiers:
managed: goauthentik.io/providers/oauth2/scope-address managed: goauthentik.io/providers/oauth2/scope-address
@ -21,38 +23,72 @@ entries:
attrs: attrs:
name: "authentik default OAuth Mapping: OpenID 'phone'" name: "authentik default OAuth Mapping: OpenID 'phone'"
scope_name: phone scope_name: phone
description: "General phone Information" description: "General phone information"
expression: | expression: |
return { return {
"phone_number": "+1234", "phone_number": "+1234",
"phone_number_verified": True, "phone_number_verified": True,
} }
- identifiers:
managed: goauthentik.io/providers/oauth2/scope-profile-oidc-standard
model: authentik_providers_oauth2.scopemapping
attrs:
name: "OIDC conformance profile"
scope_name: profile
description: "General profile information"
expression: |
return {
# Because authentik only saves the user's full name, and has no concept of first and last names,
# the full name is used as given name.
# You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.username,
"nickname": request.user.username,
"groups": [group.name for group in request.user.ak_groups.all()],
"website" : "foo",
"zoneinfo" : "foo",
"birthdate" : "2000",
"gender" : "foo",
"profile" : "foo",
"middle_name" : "foo",
"locale" : "foo",
"picture" : "foo",
"updated_at" : 1748557810,
"family_name" : "foo",
}
- model: authentik_providers_oauth2.oauth2provider - model: authentik_providers_oauth2.oauth2provider
id: provider id: oidc-conformance-1
identifiers: identifiers:
name: provider name: oidc-conformance-1
attrs: attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global issuer_mode: global
client_id: 4054d882aff59755f2f279968b97ce8806a926e1 client_id: 4054d882aff59755f2f279968b97ce8806a926e1
client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867
redirect_uris: | redirect_uris:
https://localhost:8443/test/a/authentik/callback - matching_mode: strict
https://localhost.emobix.co.uk:8443/test/a/authentik/callback url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings: 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-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application - model: authentik_core.application
identifiers: identifiers:
slug: conformance slug: oidc-conformance-1
attrs: attrs:
provider: !KeyOf provider provider: !KeyOf oidc-conformance-1
name: Conformance name: OIDC Conformance (1)
- model: authentik_providers_oauth2.oauth2provider - model: authentik_providers_oauth2.oauth2provider
id: oidc-conformance-2 id: oidc-conformance-2
@ -60,22 +96,27 @@ entries:
name: oidc-conformance-2 name: oidc-conformance-2
attrs: attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global issuer_mode: global
client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26
client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789
redirect_uris: | redirect_uris:
https://localhost:8443/test/a/authentik/callback - matching_mode: strict
https://localhost.emobix.co.uk:8443/test/a/authentik/callback url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings: 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-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application - model: authentik_core.application
identifiers: identifiers:
slug: oidc-conformance-2 slug: oidc-conformance-2
attrs: attrs:
provider: !KeyOf oidc-conformance-2 provider: !KeyOf oidc-conformance-2
name: OIDC Conformance name: OIDC Conformance (2)

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -55,7 +55,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

6
go.mod
View File

@ -6,7 +6,7 @@ require (
beryju.io/ldap v0.1.0 beryju.io/ldap v0.1.0
github.com/avast/retry-go/v4 v4.6.1 github.com/avast/retry-go/v4 v4.6.1
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.33.0 github.com/getsentry/sentry-go v0.34.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/runtime v0.28.0
@ -23,13 +23,13 @@ require (
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
github.com/pires/go-proxyproto v0.8.1 github.com/pires/go-proxyproto v0.8.1
github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_golang v1.22.0
github.com/redis/go-redis/v9 v9.10.0 github.com/redis/go-redis/v9 v9.11.0
github.com/sethvargo/go-envconfig v1.3.0 github.com/sethvargo/go-envconfig v1.3.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2 github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025062.3 goauthentik.io/api/v3 v3.2025063.1
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0 golang.org/x/sync v0.15.0

12
go.sum
View File

@ -71,8 +71,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= github.com/getsentry/sentry-go v0.34.0 h1:1FCHBVp8TfSc8L10zqSwXUZNiOSF+10qw4czjarTiY4=
github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/getsentry/sentry-go v0.34.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@ -251,8 +251,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
@ -298,8 +298,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA= goauthentik.io/api/v3 v3.2025063.1 h1:zvKhZTESgMY/SNiLuTs7G0YleBnev1v7+S9Xd6PZ9bc=
goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= goauthentik.io/api/v3 v3.2025063.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -33,4 +33,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2025.6.2" const VERSION = "2025.6.3"

View File

@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"aws-cdk": "^2.1019.1", "aws-cdk": "^2.1019.2",
"cross-env": "^7.0.3" "cross-env": "^7.0.3"
}, },
"engines": { "engines": {
@ -17,9 +17,9 @@
} }
}, },
"node_modules/aws-cdk": { "node_modules/aws-cdk": {
"version": "2.1019.1", "version": "2.1019.2",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1019.1.tgz", "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1019.2.tgz",
"integrity": "sha512-G2jxKuTsYTrYZX80CDApCrKcZ+AuFxxd+b0dkb0KEkfUsela7RqrDGLm5wOzSCIc3iH6GocR8JDVZuJ+0nNuKg==", "integrity": "sha512-LkWZ3IKBkfCPTCu60t4Wb9JMSkb+0Uzk+HIxZeW5sFohq8bxDGV0OP1hcqEC2+KbVYRn7q+YhMeSJ/FOQcgpiw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

@ -10,7 +10,7 @@
"node": ">=20" "node": ">=20"
}, },
"devDependencies": { "devDependencies": {
"aws-cdk": "^2.1019.1", "aws-cdk": "^2.1019.2",
"cross-env": "^7.0.3" "cross-env": "^7.0.3"
} }
} }

View File

@ -26,7 +26,7 @@ Parameters:
Description: authentik Docker image Description: authentik Docker image
AuthentikVersion: AuthentikVersion:
Type: String Type: String
Default: 2025.6.2 Default: 2025.6.3
Description: authentik Docker image tag Description: authentik Docker image tag
AuthentikServerCPU: AuthentikServerCPU:
Type: Number Type: Number

View File

@ -10,7 +10,7 @@ from typing import Any
from psycopg import Connection, Cursor, connect from psycopg import Connection, Cursor, connect
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG, django_db_config
LOGGER = get_logger() LOGGER = get_logger()
ADV_LOCK_UID = 1000 ADV_LOCK_UID = 1000
@ -115,9 +115,13 @@ def run_migrations():
execute_from_command_line(["", "migrate_schemas"]) execute_from_command_line(["", "migrate_schemas"])
if CONFIG.get_bool("tenants.enabled", False): if CONFIG.get_bool("tenants.enabled", False):
execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"]) execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"])
execute_from_command_line( # Run django system checks for all databases
["", "check"] + ([] if CONFIG.get_bool("debug") else ["--deploy"]) check_args = ["", "check"]
) for label in django_db_config(CONFIG).keys():
check_args.append(f"--database={label}")
if not CONFIG.get_bool("debug"):
check_args.append("--deploy")
execute_from_command_line(check_args)
finally: finally:
release_lock(curr) release_lock(curr)
curr.close() curr.close()

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-19 00:10+0000\n" "POT-Creation-Date: 2025-06-25 00:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -109,10 +109,6 @@ msgstr ""
msgid "User does not have access to application." msgid "User does not have access to application."
msgstr "" msgstr ""
#: authentik/core/api/devices.py
msgid "Extra description not available"
msgstr ""
#: authentik/core/api/groups.py #: authentik/core/api/groups.py
msgid "Cannot set group as parent of itself." msgid "Cannot set group as parent of itself."
msgstr "" msgstr ""

Binary file not shown.

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