Compare commits

..

184 Commits

Author SHA1 Message Date
Ken Sternberg
8a1563c805 Add the part detail to the overlay, so that it can be addressed by customizing CSS.
Updated the documenting comment to show the changes and call out the use of `light()` as an
idiom.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes
2026-05-07 15:41:54 -07:00
Ken Sternberg
e65fbed0a5 Merge branch 'web/element/new-drawer' into web/flow/expose-flow-in-lightdom
* web/element/new-drawer:
2026-05-07 15:20:04 -07:00
Ken Sternberg
5199e8b482 Merge branch 'main' into web/element/new-drawer
* main: (30 commits)
  locale: fix de_DE locale placeholder (#22130)
  website/docs: stages cleanup (#21558)
  website: bump the build group in /website with 3 updates (#22104)
  web: bump basic-ftp from 5.3.0 to 5.3.1 in /web (#22131)
  web: bump knip from 6.7.0 to 6.9.0 in /web (#22107)
  lifecycle/aws: bump aws-cdk from 2.1119.0 to 2.1120.0 in /lifecycle/aws (#22105)
  web: bump @sentry/browser from 10.50.0 to 10.51.0 in /web in the sentry group across 1 directory (#22106)
  ci: bump taiki-e/install-action from 2.75.30 to 2.76.0 in /.github/actions/setup (#22108)
  core, web: update translations (#22129)
  stage/authenticator*: expand attempt throttling to email- and sms-based 2FA (#21751)
  translate: Updates for project authentik and language pt_PT (#22122)
  translate: Updates for project authentik and language no_NO (#22120)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#22128)
  translate: Updates for project authentik and language ja_JP (#22118)
  translate: Updates for project authentik and language ru_RU (#22119)
  translate: Updates for project authentik and language tr_TR (#22125)
  translate: Updates for project authentik and language pl_PL (#22124)
  translate: Updates for project authentik and language pt_BR (#22111)
  translate: Updates for project authentik and language it_IT (#22123)
  translate: Updates for project authentik and language zh-Hans (#22121)
  ...
2026-05-07 15:17:19 -07:00
Ken Sternberg
3d38c5b428 Some minor cleanup of Flow
# What

1.  Rename `ak-brand-footer` to `ak-brand-links`; this brings the filename in-line with the component name.

2.  Clean up the imports; the initial load does not need both ak-flow and ak-flow-executor, since the first imports the second. ak-flow does not need ak-flow-card; in fact, ak-flow-executor should not need it either, since that’s a stage thing. On the other hand, `ak-brand-links` *does* need to be in the `index.entrypoint.ts` file, since it will be needed by the Django template.
2026-05-07 15:16:44 -07:00
Ken Sternberg
43e035c936 Merge branch 'main' into web/flow/expose-flow-in-lightdom
* main:
  locale: fix de_DE locale placeholder (#22130)
  website/docs: stages cleanup (#21558)
  website: bump the build group in /website with 3 updates (#22104)
  web: bump basic-ftp from 5.3.0 to 5.3.1 in /web (#22131)
  web: bump knip from 6.7.0 to 6.9.0 in /web (#22107)
2026-05-07 14:42:43 -07:00
Jens L.
9a23785059 locale: fix de_DE locale placeholder (#22130)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-07 22:49:40 +02:00
Dominic R
a3a29d0648 website/docs: stages cleanup (#21558)
* website/docs: stages cleanup

* apply suggestions

* docs review

* website/docs: address review feedback for stages cleanup

Co-authored-by: Codex <codex@openai.com>

* website/docs: fix expression policy stage links

Co-authored-by: Codex <codex@openai.com>

* website/docs: fix SAML user write stage link

---------

Co-authored-by: Codex <codex@openai.com>
2026-05-07 14:36:54 -04:00
Ken Sternberg
da98c6fa98 Merge branch 'main' into web/flow/expose-flow-in-lightdom
* main: (25 commits)
  lifecycle/aws: bump aws-cdk from 2.1119.0 to 2.1120.0 in /lifecycle/aws (#22105)
  web: bump @sentry/browser from 10.50.0 to 10.51.0 in /web in the sentry group across 1 directory (#22106)
  ci: bump taiki-e/install-action from 2.75.30 to 2.76.0 in /.github/actions/setup (#22108)
  core, web: update translations (#22129)
  stage/authenticator*: expand attempt throttling to email- and sms-based 2FA (#21751)
  translate: Updates for project authentik and language pt_PT (#22122)
  translate: Updates for project authentik and language no_NO (#22120)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#22128)
  translate: Updates for project authentik and language ja_JP (#22118)
  translate: Updates for project authentik and language ru_RU (#22119)
  translate: Updates for project authentik and language tr_TR (#22125)
  translate: Updates for project authentik and language pl_PL (#22124)
  translate: Updates for project authentik and language pt_BR (#22111)
  translate: Updates for project authentik and language it_IT (#22123)
  translate: Updates for project authentik and language zh-Hans (#22121)
  translate: Updates for project authentik and language cs_CZ (#22115)
  translate: Updates for project authentik and language fr_FR (#22117)
  translate: Updates for project authentik and language fi_FI (#22114)
  translate: Updates for project authentik and language de_DE (#22113)
  translate: Updates for project authentik and language es_ES (#22116)
  ...
2026-05-07 11:03:58 -07:00
dependabot[bot]
5683c66426 website: bump the build group in /website with 3 updates (#22104)
* website: bump the build group in /website with 3 updates

Bumps the build group in /website with 3 updates: [@rspack/binding-darwin-arm64](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack), [@rspack/binding-linux-arm64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) and [@rspack/binding-linux-x64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack).


Updates `@rspack/binding-darwin-arm64` from 1.7.11 to 2.0.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v2.0.0/packages/rspack)

Updates `@rspack/binding-linux-arm64-gnu` from 1.7.11 to 2.0.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v2.0.0/packages/rspack)

Updates `@rspack/binding-linux-x64-gnu` from 1.7.11 to 2.0.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v2.0.0/packages/rspack)

---
updated-dependencies:
- dependency-name: "@rspack/binding-darwin-arm64"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: build
- dependency-name: "@rspack/binding-linux-arm64-gnu"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: build
- dependency-name: "@rspack/binding-linux-x64-gnu"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: build
...

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

* dependabot pls

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>
2026-05-07 19:40:31 +02:00
dependabot[bot]
b5323cdd97 web: bump basic-ftp from 5.3.0 to 5.3.1 in /web (#22131)
Bumps [basic-ftp](https://github.com/patrickjuchli/basic-ftp) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/patrickjuchli/basic-ftp/releases)
- [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.3.0...v5.3.1)

---
updated-dependencies:
- dependency-name: basic-ftp
  dependency-version: 5.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 19:40:13 +02:00
dependabot[bot]
f422c359e1 web: bump knip from 6.7.0 to 6.9.0 in /web (#22107)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.7.0 to 6.9.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.9.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 19:38:17 +02:00
dependabot[bot]
5a8a7d24d0 lifecycle/aws: bump aws-cdk from 2.1119.0 to 2.1120.0 in /lifecycle/aws (#22105)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1119.0 to 2.1120.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1120.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1120.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 19:18:17 +02:00
dependabot[bot]
8b098ce0c1 web: bump @sentry/browser from 10.50.0 to 10.51.0 in /web in the sentry group across 1 directory (#22106)
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 10.50.0 to 10.51.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/10.50.0...10.51.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 10.51.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>
2026-05-07 19:18:08 +02:00
dependabot[bot]
25a4125375 ci: bump taiki-e/install-action from 2.75.30 to 2.76.0 in /.github/actions/setup (#22108)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.30 to 2.76.0.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](db5fb34fa7...711e1c3275)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.76.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 19:17:58 +02:00
authentik-automation[bot]
ffead56be4 core, web: update translations (#22129)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-07 17:14:39 +00:00
Alexander Tereshkin
93abd2e041 stage/authenticator*: expand attempt throttling to email- and sms-based 2FA (#21751)
* stages/authenticator*: enable attempt throttling for email- and sms-based second authentication factor

* stages/authenticator*: add throttling tests

* stage/authenticator_validate: add throttling documentation

* Update website/docs/add-secure-apps/flows-stages/stages/authenticator_validate/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

* Update website/docs/add-secure-apps/flows-stages/stages/authenticator_validate/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

* stages/authenticator_validate: update docs wording

* Update website/docs/add-secure-apps/flows-stages/stages/authenticator_validate/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

* Update website/docs/add-secure-apps/flows-stages/stages/authenticator_validate/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

* Update website/docs/add-secure-apps/flows-stages/stages/authenticator_validate/index.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

---------

Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-05-07 12:12:06 -05:00
transifex-integration[bot]
f1d3664c96 translate: Updates for project authentik and language pt_PT (#22122)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:37:13 +00:00
transifex-integration[bot]
1f46ed7bab translate: Updates for project authentik and language no_NO (#22120)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:34:16 +00:00
authentik-automation[bot]
8d75cddbbd stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#22128)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-07 16:25:34 +00:00
transifex-integration[bot]
f840249c11 translate: Updates for project authentik and language ja_JP (#22118)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:25:13 +00:00
transifex-integration[bot]
a0e9159571 translate: Updates for project authentik and language ru_RU (#22119)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:23:28 +00:00
transifex-integration[bot]
868e61029f translate: Updates for project authentik and language tr_TR (#22125)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:22:38 +00:00
transifex-integration[bot]
8707d2dadf translate: Updates for project authentik and language pl_PL (#22124)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:22:35 +00:00
transifex-integration[bot]
9e9ceda831 translate: Updates for project authentik and language pt_BR (#22111)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:22:19 +00:00
transifex-integration[bot]
cf9ebcc6af translate: Updates for project authentik and language it_IT (#22123)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:22:16 +00:00
transifex-integration[bot]
e42158fa33 translate: Updates for project authentik and language zh-Hans (#22121)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:22:02 +00:00
transifex-integration[bot]
d66391fbbc translate: Updates for project authentik and language cs_CZ (#22115)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:21:10 +00:00
transifex-integration[bot]
cf9459d5db translate: Updates for project authentik and language fr_FR (#22117)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:16:09 +00:00
transifex-integration[bot]
7c1ee63c11 translate: Updates for project authentik and language fi_FI (#22114)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:15:08 +00:00
transifex-integration[bot]
86cc2fef2e translate: Updates for project authentik and language de_DE (#22113)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:14:38 +00:00
transifex-integration[bot]
12d981dfaf translate: Updates for project authentik and language es_ES (#22116)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:12:24 +00:00
Ken Sternberg
e751386d4d This commit puts the Login input sequence into the lightDOM.
# WHAT

Fixes a number of nitpicks with the code:

1.  Removes the sentinel when our node is disconnected while the component remains connected; this happens when a predicated chooses to, and then not to, render a part of the host template.
2.  Avoids re-rendering the slot when it’s already present.
3.  Fixes a type-checking issue in `light` directive that forbade the DOM elements from being exported correctly.
4.  Removes dead code about `#form` from the Executor.
5.  Removes a stray semicolon from the footer template.
2026-05-07 09:05:03 -07:00
transifex-integration[bot]
8f7903f3e9 translate: Updates for project authentik and language bg_BG (#22112)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-07 16:00:31 +00:00
Marc 'risson' Schmitt
b420e4fdbd packages/django-dramatiq-postgres/broker: avoid task processing stopping on decode error (#22110) 2026-05-07 15:35:21 +00:00
Ken Sternberg
72d42bc32b Removed the helper. 2026-05-06 15:40:37 -07:00
Ken Sternberg
a3e9788f79 Prettier having opinions. 2026-05-06 15:39:02 -07:00
Ken Sternberg
19f3b80f85 Fix typo; ensure form is defined in global space. 2026-05-06 15:11:41 -07:00
Ken Sternberg
da79d23b79 No idea what's happening here. 2026-05-06 14:33:58 -07:00
Ken Sternberg
a438eda64b End-to-end lightDOM! 2026-05-06 12:51:47 -07:00
Teffen Ellis
e50f093685 web/rac: Ignore empty remote clipboard payloads (#22067)
web/rac: ignore empty remote clipboard payloads

Some remote sessions (notably SSH) push empty or whitespace-only
clipboard updates that overwrite the user's local clipboard, leaving
subsequent paste attempts with nothing to deliver. Filter those payloads
in the StringReader.onend callback so the local clipboard is preserved.

Closes #21439

Co-authored-by: Agent (authentik-i21439-featured-elevated-kobicha) <279763771+playpen-agent@users.noreply.github.com>
2026-05-06 20:34:34 +02:00
Jens L.
cf05037761 api: make ordering null-aware (#22099)
* api: make ordering null-aware

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

* add types

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-06 20:34:24 +02:00
Jens L.
4d035d1eda web/admin: remove side-padding on user paths (#22088)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-06 19:33:46 +02:00
Ken Sternberg
da2424d531 This commit puts the FlowExecutor into the lightDOM.
We now have *three* different copies of the Login.css: One for Flow, one for FlowExecutor, and one used by all the stages. Their use cases have almost nothing to do with one another; they represent isolated and independent uses of CSS that we will eventually have to put into separate files of their own.
2026-05-06 10:05:55 -07:00
Ken Sternberg
44a138182b Merge branch 'web/element/new-drawer' into web/flow/expose-flow-in-lightdom
* web/element/new-drawer:
  Prettier has opinions, as usual.
  Extend ak-drawer to comply with the full specification; port ak-drawer to use Patternfly 5; vendor the Patternfly 5 subsystems directly responsible for the Drawer into the CSS.
  Nobody liked this choice.
  Re-arranged to avoid having a 'devDependencies' block.
  TSC (!) had opinions.
  ## What
  That was a stupid spelling error.
  Did I miss something?
  .
2026-05-06 09:41:57 -07:00
Ken Sternberg
e59c9f64d0 Merge branch 'main' into web/element/new-drawer
* main: (338 commits)
  root: ensure uv sync does not update uv.lock (#22084)
  core: bump dramatiq from 1.17.1 to 2.1.0 (#22076)
  web: Fix Vendored Lex package. Add Unit Tests  (#22083)
  core, web: update translations (#22074)
  website: bump the build group in /website with 6 updates (#22075)
  web: bump ip-address from 10.1.0 to 10.2.0 in /web (#22082)
  web: bump the swc group across 1 directory with 11 updates (#22078)
  ci: bump taiki-e/install-action from 2.75.29 to 2.75.30 in /.github/actions/setup (#22077)
  web: bump country-flag-icons from 1.6.16 to 1.6.17 in /web (#22079)
  web: bump yaml from 2.8.3 to 2.8.4 in /web (#22080)
  core: bump sentry from 0.47.0 to 0.48.0 (#22081)
  packages/client-ts: Fix TypeScript config, ESBuild warnings (#21863)
  web: fix identification stage OUIA attributes (#22049)
  stages/invitation: Invitation wizard (#20399)
  Web/release202604/nits 2 (#22040)
  web: Gracefully handle missing element construction. (#21787)
  root: update django to 5.2.14 (#22064)
  tenants: add option to mark flag as deprecated (#22063)
  web/stages: better wording for webauthn authenticator attachments options (#22062)
  web: bump vite from 8.0.8 to 8.0.10 in /web (#21842)
  ...
2026-05-06 09:31:20 -07:00
Ken Sternberg
cc9c06d975 Merge branch 'main' into dev
* main:
  root: ensure uv sync does not update uv.lock (#22084)
  core: bump dramatiq from 1.17.1 to 2.1.0 (#22076)
  web: Fix Vendored Lex package. Add Unit Tests  (#22083)
  core, web: update translations (#22074)
  website: bump the build group in /website with 6 updates (#22075)
  web: bump ip-address from 10.1.0 to 10.2.0 in /web (#22082)
  web: bump the swc group across 1 directory with 11 updates (#22078)
  ci: bump taiki-e/install-action from 2.75.29 to 2.75.30 in /.github/actions/setup (#22077)
  web: bump country-flag-icons from 1.6.16 to 1.6.17 in /web (#22079)
  web: bump yaml from 2.8.3 to 2.8.4 in /web (#22080)
  core: bump sentry from 0.47.0 to 0.48.0 (#22081)
  packages/client-ts: Fix TypeScript config, ESBuild warnings (#21863)
  web: fix identification stage OUIA attributes (#22049)
  stages/invitation: Invitation wizard (#20399)
  Web/release202604/nits 2 (#22040)
  web: Gracefully handle missing element construction. (#21787)
2026-05-06 08:24:44 -07:00
Marc 'risson' Schmitt
ebd18b466d root: ensure uv sync does not update uv.lock (#22084) 2026-05-06 14:48:59 +00:00
dependabot[bot]
b32df17513 core: bump dramatiq from 1.17.1 to 2.1.0 (#22076)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-05-06 14:42:29 +00:00
Teffen Ellis
1db6c3af8b web: Fix Vendored Lex package. Add Unit Tests (#22083)
* Fix API reference paths.

* Clean up vendored code.

* Flesh out test.

* Fix edgecase.

* Clean up return value.

* Fix linter.
2026-05-06 14:31:17 +00:00
authentik-automation[bot]
16de9d1b44 core, web: update translations (#22074)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-06 15:26:38 +02:00
dependabot[bot]
9aabe91119 website: bump the build group in /website with 6 updates (#22075)
Bumps the build group in /website with 6 updates:

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

Updates `@swc/core-darwin-arm64` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-arm64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-x64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/html-darwin-arm64` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/html-linux-arm64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/html-linux-x64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

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

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>
2026-05-06 14:16:14 +02:00
dependabot[bot]
77fae18259 web: bump ip-address from 10.1.0 to 10.2.0 in /web (#22082)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.1.0 to 10.2.0.
- [Commits](https://github.com/beaugunderson/ip-address/commits)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 13:06:44 +02:00
dependabot[bot]
f6ef4d5479 web: bump the swc group across 1 directory with 11 updates (#22078)
Bumps the swc group with 1 update in the /web directory: [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core).


Updates `@swc/core` from 1.15.32 to 1.15.33
- [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/commits/v1.15.33/packages/core)

Updates `@swc/core-darwin-arm64` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-darwin-x64` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-arm-gnueabihf` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-arm64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-arm64-musl` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-x64-gnu` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-linux-x64-musl` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-win32-arm64-msvc` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-win32-ia32-msvc` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

Updates `@swc/core-win32-x64-msvc` from 1.15.32 to 1.15.33
- [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.15.32...v1.15.33)

---
updated-dependencies:
- dependency-name: "@swc/core"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-darwin-x64"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm-gnueabihf"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm64-musl"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-x64-musl"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-arm64-msvc"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-ia32-msvc"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-x64-msvc"
  dependency-version: 1.15.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 12:49:05 +02:00
dependabot[bot]
b3ac4f9c4e ci: bump taiki-e/install-action from 2.75.29 to 2.75.30 in /.github/actions/setup (#22077)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.29 to 2.75.30.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](b5fddbb536...db5fb34fa7)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 12:48:52 +02:00
dependabot[bot]
86658f6f03 web: bump country-flag-icons from 1.6.16 to 1.6.17 in /web (#22079)
Bumps [country-flag-icons](https://gitlab.com/catamphetamine/country-flag-icons) from 1.6.16 to 1.6.17.
- [Changelog](https://gitlab.com/catamphetamine/country-flag-icons/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/catamphetamine/country-flag-icons/compare/v1.6.16...v1.6.17)

---
updated-dependencies:
- dependency-name: country-flag-icons
  dependency-version: 1.6.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 12:48:32 +02:00
dependabot[bot]
548ab05628 web: bump yaml from 2.8.3 to 2.8.4 in /web (#22080)
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.3 to 2.8.4.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.3...v2.8.4)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.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>
2026-05-06 12:48:23 +02:00
dependabot[bot]
459fa8e219 core: bump sentry from 0.47.0 to 0.48.0 (#22081)
Bumps [sentry](https://github.com/getsentry/sentry-rust) from 0.47.0 to 0.48.0.
- [Release notes](https://github.com/getsentry/sentry-rust/releases)
- [Changelog](https://github.com/getsentry/sentry-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-rust/compare/0.47.0...0.48.0)

---
updated-dependencies:
- dependency-name: sentry
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 12:48:13 +02:00
Teffen Ellis
e40187179d packages/client-ts: Fix TypeScript config, ESBuild warnings (#21863)
* packages/client-ts: drop composite/incremental from tsconfig template

Sync with goauthentik/client-ts#13. The flags are the mechanism of
the missing-dist release bug upstream; harmless in the monorepo (no
publishing) but pointless for a single-package, no-project-references
setup. Keeping the two trees aligned avoids drift.

Co-Authored-By: Agent (authentik-m-sync-packages-final-concrete-buff) <279763771+playpen-agent@users.noreply.github.com>

* Fix package not building.

---------

Co-authored-by: Agent (authentik-m-sync-packages-final-concrete-buff) <279763771+playpen-agent@users.noreply.github.com>
2026-05-06 12:29:46 +02:00
Dominic R
f6024a23ef web: fix identification stage OUIA attributes (#22049)
* web: fix identification stage OUIA attributes

* tests/e2e: update OUIA selectors for identification stage

Match the rename of ouiaId to data-ouia-component-id in
IdentificationStage.ts so the enroll and recovery flow tests can
locate the links again.
2026-05-06 02:31:17 +02:00
Marcelo Elizeche Landó
a8db2882ec stages/invitation: Invitation wizard (#20399) 2026-05-05 11:47:31 -05:00
Ken Sternberg
befc15ad92 Web/release202604/nits 2 (#22040)
* ## What

         window.authentik.flow = {
             "layout": "{{ flow.layout }}",
    +        "background": "{{ flow.background }}",
    +        "title": "{{ flow.title }}",
         };

Amends the `flow.html` template and `GlobalAuthentik` parser to include new parameters, `background` and `title`, in the flow-specific part of the configuration written to the HTML `<head>` object, and to provide those parameters to client code.

## Why

The `layout` is start-up critical: it tells the Flow interface how the admin wants the Flow page to look, and allows the HTML and CSS to be pre-aligned to that condition. `layout` is determined on a per-Flow bases, not a per-Stage basis; Flows are derived from a tuple of `(Brand, Application?)`, where the opening policy *may* direct a user to a different flow if the user reached authentik via a redirect from a specific application, but will otherwise fall back to the default Flow for the Brand.

The `background` is a field that is required if the `Flow`’s layout is of type `frame_background`; in this case, the part of the viewport not dedicated to the FlowExecutor is reserved for an `<iframe>` that will be filled in with whatever the administrator specifies. Although this gives it the same priority as `layout` (whether it’s provided or undefined) for describing the [chrome](https://developer.mozilla.org/en-US/docs/Glossary/Chrome) around a challenge, it is currently not provided to the application in the start-up config; it is provided in the `challenge` and renders the IFrame as part of the initial challenge.

This patch fixes that; if `layout` is provided, `background` ought to be as well, even if it’s empty. The execution of a Challenge ought not have any influence over the look and feel of the Flow-defined appearance *around* that Challenge.

I have added `title` as well; with that, all of the current theme-and-appearance related configuration details are placed into `<head>` and can be removed from the FlowExecutor.

Server-side, `background` is currently specified: `background = FileField(blank=True, default="")` which is … interesting since we also appear to store URLs in it. I don’t see anything in the FlowSerializer that would change that from a client’s point of view.

This patch furthers the effort to separate flow execution from flow presentation.

- \[🐰\] The code has been formatted (`make web`)

* The status label was using HTML booleans incorrectly. It is impossible for a boolean to be null. The default red was alarming, so I chose a neutral grey for the 'not default' state.

* It is not enough to provide a blank cell to ensure the header is spaced correctly; if the table is empty, that will collapse to zero width.  Providing the classes that go with the 'this cell may contain a toggle' provides the correct spacing as well.

* Fix inconsistent wording between menu and page; make the 'select type' radiocard and radiolist interfaces flush with the top of the form container, removing a weird jagged visual line between the menu and the content.

* Document adding 'toggle' to Table classes.

* Fix how the buttons for TablePage's empty state align; slots are still wonky when responding to content layout that we do not control ourselves.

* Do not show pagination controls when there are no pages to turn.

* Fix spacing after ak-alert in documentation show in the front-end.  Without this, headers and paragraphs were edging well into the alert's drop-shadow.

* Remove separator line from radio entries; P4-ism that was visually confusing.

* Make the empty state a slot, so it can be easily overriden, and provide a default if the slot isn't filled from a lightDOM entry. Add one to the columnWidth, since columnWidth doesn't include the action column; this fixes a visual tic where the empty state did not look correctly centered.
2026-05-05 09:43:53 -07:00
Teffen Ellis
2b48c27760 web: Gracefully handle missing element construction. (#21787)
* web: Gracefully handle missing element construction.

* web: Tailor missing element message based on debug capability. (#22048)

Show a developer-oriented hint when CanDebug is set, and an
end-user-friendly suggestion (refresh / clear cache) otherwise.

Co-authored-by: Agent (authentik-i21787-graceful-gross-chrome) <279763771+playpen-agent@users.noreply.github.com>

---------

Co-authored-by: Agent (authentik-i21787-graceful-gross-chrome) <279763771+playpen-agent@users.noreply.github.com>
2026-05-05 18:41:33 +02:00
Ken Sternberg
3e966ee89b Merge branch 'main' into dev
* main: (24 commits)
  root: update django to 5.2.14 (#22064)
  tenants: add option to mark flag as deprecated (#22063)
  web/stages: better wording for webauthn authenticator attachments options (#22062)
  web: bump vite from 8.0.8 to 8.0.10 in /web (#21842)
  api: set authenticated session user agent nullable properties (#22059)
  web/admin: redirect stage: adds mention of static url (#22060)
  web: bump axios from 1.15.0 to 1.16.0 in /web (#22058)
  providers/oauth2: override RedirectURITypeEnum capitalization for generated API (#22037)
  website/docs: document language settings (#21968)
  website/docs: document supported PostgreSQL versions (#21967)
  website: bump docusaurus-theme-openapi-docs from 5.0.1 to 5.0.2 in /website (#22052)
  web: bump the storybook group across 1 directory with 5 updates (#22024)
  revert: web: Consistent use of "User Dashboard" (#22038) (#22046)
  core: bump metrics-exporter-prometheus from 0.18.1 to 0.18.3 (#22057)
  core, web: update translations (#22047)
  core: bump cryptography from 47.0.0 to 48.0.0 (#22053)
  core: bump psycopg[c,pool] from 3.3.3 to 3.3.4 (#22054)
  ci: bump taiki-e/install-action from 2.75.28 to 2.75.29 in /.github/actions/setup (#22056)
  web: remove native fieldset borders from action groups (#21334)
  website/docs: document blueprint import options (#21973)
  ...
2026-05-05 09:17:23 -07:00
Jens L.
6be7b2f7b7 root: update django to 5.2.14 (#22064) 2026-05-05 15:49:16 +00:00
Jens L.
7cffbb4d07 tenants: add option to mark flag as deprecated (#22063)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-05 17:25:01 +02:00
Marcelo Elizeche Landó
5d629bec9b web/stages: better wording for webauthn authenticator attachments options (#22062)
better wording for webauthn authenticator attachments options
2026-05-05 17:02:55 +02:00
dependabot[bot]
5357f42029 web: bump vite from 8.0.8 to 8.0.10 in /web (#21842)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.8 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 14:50:15 +02:00
Dewi Roberts
716bc6e136 api: set authenticated session user agent nullable properties (#22059)
* Set properties to nullable and regenerate schema

* Make gen

* format

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-05 14:47:27 +02:00
Dewi Roberts
60355fdf80 web/admin: redirect stage: adds mention of static url (#22060)
Adds mention of static url, not just flow redirect
2026-05-05 14:46:56 +02:00
dependabot[bot]
828a380569 web: bump axios from 1.15.0 to 1.16.0 in /web (#22058)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 14:25:07 +02:00
Luca Sannitu
b04f8a6177 providers/oauth2: override RedirectURITypeEnum capitalization for generated API (#22037)
* fix(providers/oauth2): correct RedirectURITypeEnum capitalization in API schema

* fix: remove encoding artifacts introduced during client regeneration
2026-05-05 14:18:02 +02:00
Dominic R
ff190847f2 website/docs: document language settings (#21968) 2026-05-05 08:08:00 -04:00
Dominic R
a7339c7f87 website/docs: document supported PostgreSQL versions (#21967) 2026-05-05 08:07:24 -04:00
dependabot[bot]
38ae472f6c website: bump docusaurus-theme-openapi-docs from 5.0.1 to 5.0.2 in /website (#22052)
* website: bump docusaurus-theme-openapi-docs in /website

Bumps [docusaurus-theme-openapi-docs](https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/tree/HEAD/packages/docusaurus-theme-openapi-docs) from 5.0.1 to 5.0.2.
- [Release notes](https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/releases)
- [Changelog](https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/commits/v5.0.2/packages/docusaurus-theme-openapi-docs)

---
updated-dependencies:
- dependency-name: docusaurus-theme-openapi-docs
  dependency-version: 5.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

* bump

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>
2026-05-05 13:36:39 +02:00
dependabot[bot]
7d0656c6fa web: bump the storybook group across 1 directory with 5 updates (#22024)
Bumps the storybook group with 4 updates in the /web directory: [@storybook/addon-docs](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/docs), [@storybook/addon-links](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/links), [@storybook/web-components](https://github.com/storybookjs/storybook/tree/HEAD/code/renderers/web-components) and [@storybook/web-components-vite](https://github.com/storybookjs/storybook/tree/HEAD/code/frameworks/web-components-vite).


Updates `@storybook/addon-docs` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/docs)

Updates `@storybook/addon-links` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/links)

Updates `@storybook/web-components` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/renderers/web-components)

Updates `@storybook/web-components-vite` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/frameworks/web-components-vite)

Updates `storybook` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/core)

---
updated-dependencies:
- dependency-name: "@storybook/addon-docs"
  dependency-version: 10.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/addon-links"
  dependency-version: 10.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/web-components"
  dependency-version: 10.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/web-components-vite"
  dependency-version: 10.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: storybook
  dependency-version: 10.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: storybook
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 13:18:52 +02:00
Teffen Ellis
0bbe415b5b revert: web: Consistent use of "User Dashboard" (#22038) (#22046)
Revert "web: Consistent use of "User Dashboard" (#22038)"

This reverts commit d69433b314.
2026-05-05 13:17:40 +02:00
dependabot[bot]
e52c1b2bdc core: bump metrics-exporter-prometheus from 0.18.1 to 0.18.3 (#22057)
Bumps [metrics-exporter-prometheus](https://github.com/metrics-rs/metrics) from 0.18.1 to 0.18.3.
- [Changelog](https://github.com/metrics-rs/metrics/blob/main/release.toml)
- [Commits](https://github.com/metrics-rs/metrics/compare/metrics-exporter-prometheus-v0.18.1...metrics-exporter-prometheus-v0.18.3)

---
updated-dependencies:
- dependency-name: metrics-exporter-prometheus
  dependency-version: 0.18.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 13:09:40 +02:00
authentik-automation[bot]
5064167f28 core, web: update translations (#22047)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-05 13:08:29 +02:00
dependabot[bot]
bca0f51b53 core: bump cryptography from 47.0.0 to 48.0.0 (#22053)
Bumps [cryptography](https://github.com/pyca/cryptography) from 47.0.0 to 48.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/47.0.0...48.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 13:08:25 +02:00
dependabot[bot]
67c197e5a5 core: bump psycopg[c,pool] from 3.3.3 to 3.3.4 (#22054)
Bumps [psycopg[c,pool]](https://github.com/psycopg/psycopg) from 3.3.3 to 3.3.4.
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.3.3...3.3.4)

---
updated-dependencies:
- dependency-name: psycopg[c,pool]
  dependency-version: 3.3.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>
2026-05-05 13:08:21 +02:00
dependabot[bot]
32b17da699 ci: bump taiki-e/install-action from 2.75.28 to 2.75.29 in /.github/actions/setup (#22056)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.28 to 2.75.29.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](51cd0b8c04...b5fddbb536)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 13:08:16 +02:00
Dominic R
c75eed630a web: remove native fieldset borders from action groups (#21334)
* web: remove native fieldset borders from action groups

Refs:\n- https://authentiksecurity.slack.com/archives/C08C0SCU2JV/p1775085687040019\n- https://authentiksecurity.slack.com/archives/C08C0SCU2JV/p1774988472501059

* Use consistent naming.

* Fix up styles, selector specifics, compatibility mode.

* Fix field autocapitalization, keyboard behavior.

* Fix default height.

* Fix for mid-size tablet viewports.

- Helped with debugging on mobile.

* Fix linter warning.

---------

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-05-05 06:17:23 +02:00
Dominic R
9f17d6df96 website/docs: document blueprint import options (#21973)
* website/docs: document blueprint import options

Closes: https://github.com/goauthentik/authentik/issues/21204

* Update website/docs/customize/blueprints/index.mdx

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

* Update website/docs/customize/blueprints/working_with_blueprints.md

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

* Clarify blueprint docs

* Apply suggestions from code review

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-05 01:07:23 +00:00
Dominic R
13c8ad5c56 website/integrations: clarify Jellyfin LDAP bind permissions (#21975)
* website/integrations: clarify Jellyfin LDAP bind permissions

Closes: https://github.com/goauthentik/authentik/issues/9770

* website/docs: clarify jellyfin LDAP service account

* website/docs: link jellyfin LDAP setup steps

* Update index.md

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-05 00:58:10 +00:00
Marcelo Elizeche Landó
28209c03e2 docs: Improve docs on webauthn authenticator attachment (#22045)
Improve docs on webauthn authenticator attachment
2026-05-05 00:34:54 +00:00
Marcelo Elizeche Landó
f47cf08d8a website/docs: Add docs for webauthn hints feature (#20933)
* Add docs for webauthn hints feature

* remove accidentally added file

* Apply suggestions from code review

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

* Apply suggestions from code review

* Apply suggestions from code review

* Apply suggestions from code review

* point to our docs

---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-04 23:47:48 +00:00
Teffen Ellis
d69433b314 web: Consistent use of "User Dashboard" (#22038)
* Update app labels.

* Update docs.
2026-05-04 23:46:58 +02:00
Ken Sternberg
1860593069 Merge branch 'main' into dev
* main: (36 commits)
  website/integrations: actual budget: add env var  (#22036)
  website/docs: add webhook mapping examples (#21971)
  website/integrations: fix nextcloud LDAP group mapping (#21970)
  website/docs: preserve blueprint download filenames (#21969)
  web: Radio and Checkbox Input Revisions (#21792)
  providers/oauth: make rp init logout oidc certification changes (#21815)
  website/docs: document SCIM custom attributes (#21980)
  root: introduce allinone mode (#21990)
  website/docs: add SAML source mapping guidance (#21978)
  website/docs: fix misplaced AWS-LC clang warning (#22034)
  lifecycle/worker_process: fix healthchecks and metrics not reloading db connections after a failure (#21992)
  root: add more logging to worker requests (#21989)
  root/channels: use group_send_blocking where possible (#21993)
  core, web: update translations (#22014)
  translate: Updates for project authentik and language fr_FR (#22015)
  enterprise/providers/ssf: more conformance fixes (#21521)
  web/flows: update flow background (#22032)
  website/integrations: update NetBox OIDC config (#22018)
  web: bump @formatjs/intl-listformat from 8.3.2 to 8.3.4 in /web (#22026)
  web: bump knip from 6.6.3 to 6.7.0 in /web (#22027)
  ...
2026-05-04 14:15:35 -07:00
Gianluca Ulivi
849a6053ad website/integrations: actual budget: add env var (#22036)
Update index.mdx

Set auth method to oauth2 to use correct JWT algorithm

Signed-off-by: Gianluca Ulivi <22895603+GianlucaUlivi@users.noreply.github.com>
2026-05-04 19:09:21 +00:00
Dominic R
abdbe0269f website/docs: add webhook mapping examples (#21971)
* website/docs: add webhook mapping examples

Document event fields for generic webhook payload mappings.

Closes: https://github.com/goauthentik/authentik/issues/19335

* Update website/docs/sys-mgmt/events/transports.md

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

* Update website/docs/sys-mgmt/events/transports.md

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-04 18:08:54 +00:00
Dominic R
55384c384a website/integrations: fix nextcloud LDAP group mapping (#21970)
Set Nextcloud's LDAP group-member association to member (AD).

Closes: https://github.com/goauthentik/authentik/issues/21696
2026-05-04 13:44:15 -04:00
Dominic R
06fd68f076 website/docs: preserve blueprint download filenames (#21969)
* website/docs: preserve blueprint download filenames

Use a shared DownloadLink component for bundled blueprint downloads.

Closes: https://github.com/goauthentik/authentik/issues/20089

* website/docs: use download link for lockdown blueprint
2026-05-04 13:41:44 -04:00
Teffen Ellis
d35ab99b2d web: Radio and Checkbox Input Revisions (#21792)
* Flesh out checkbox group and radio style alignment.

* Fix input order, phrasing.

* fix radio not selecting default value if default value is falsey

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

* align items in empty state primary

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

* fix required flag

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

* Fix casing.

* consistent casing

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-04 19:12:18 +02:00
Connor Peshek
a3b0180049 providers/oauth: make rp init logout oidc certification changes (#21815)
* providers/oauth: make rp init logout oidc certification changes

* update test

* slight rework

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

* fix tests

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

* add oidc certification tests

* test

* fix backchannel url

* make urls uniform

* update to main

* remove env bind

* cleanup patch

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

* fixup

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

* add traefik healthcheck

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

* fix healthcheck

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-05-04 19:11:59 +02:00
Dominic R
88a545f4fb website/docs: document SCIM custom attributes (#21980)
* website/docs: document SCIM custom attributes

Add a SCIM provider example for custom extension attributes.

Closes: https://github.com/goauthentik/authentik/issues/14202

* website/docs: clarify SCIM custom attributes mapping

* website/docs: link SCIM mapping setup guidance

* Update index.md

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-05-04 14:44:47 +00:00
Marc 'risson' Schmitt
ba62507fc2 root: introduce allinone mode (#21990) 2026-05-04 16:43:11 +02:00
Dominic R
82fc2e2c80 website/docs: add SAML source mapping guidance (#21978) 2026-05-04 10:14:25 -04:00
Marc 'risson' Schmitt
80b3739640 website/docs: fix misplaced AWS-LC clang warning (#22034) 2026-05-04 15:41:57 +02:00
Marc 'risson' Schmitt
1258e1eada lifecycle/worker_process: fix healthchecks and metrics not reloading db connections after a failure (#21992) 2026-05-04 15:06:30 +02:00
Marc 'risson' Schmitt
96ed17e760 root: add more logging to worker requests (#21989) 2026-05-04 15:06:28 +02:00
Marc 'risson' Schmitt
4b17468b6e root/channels: use group_send_blocking where possible (#21993) 2026-05-04 14:53:22 +02:00
authentik-automation[bot]
c834681251 core, web: update translations (#22014)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-04 14:41:55 +02:00
transifex-integration[bot]
9edd7cfbda translate: Updates for project authentik and language fr_FR (#22015)
translate: Translate web/xliff/en.xlf in fr_FR

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

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-04 14:24:04 +02:00
Jens L.
4851179522 enterprise/providers/ssf: more conformance fixes (#21521)
* enterprise/providers/ssf: more conformance fixes

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

* include request when possible

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

* remove null state

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

* t

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

* re-gen & format

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

* remove None state

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

* fix ci

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

* revert a thing

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

* fix tests

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

* fix ssf conformance test

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

* no subtest

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

* fix network

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

* add test for stream update

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-04 14:11:21 +02:00
Jens L.
685f920de2 web/flows: update flow background (#22032)
* web/flows: update flow background

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

* Optimised images with calibre/image-actions

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-04 14:11:10 +02:00
Dominic R
3b4d51b0c5 website/integrations: update NetBox OIDC config (#22018) 2026-05-04 07:17:13 -04:00
dependabot[bot]
a1098d00b7 web: bump @formatjs/intl-listformat from 8.3.2 to 8.3.4 in /web (#22026)
Bumps [@formatjs/intl-listformat](https://github.com/formatjs/formatjs) from 8.3.2 to 8.3.4.
- [Release notes](https://github.com/formatjs/formatjs/releases)
- [Commits](https://github.com/formatjs/formatjs/compare/@formatjs/intl-listformat@8.3.2...@formatjs/intl-listformat@8.3.4)

---
updated-dependencies:
- dependency-name: "@formatjs/intl-listformat"
  dependency-version: 8.3.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>
2026-05-04 12:22:15 +02:00
dependabot[bot]
0d4984b964 web: bump knip from 6.6.3 to 6.7.0 in /web (#22027)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.6.3 to 6.7.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.7.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:22:05 +02:00
dependabot[bot]
38330df1f9 core: bump metrics from 0.24.3 to 0.24.5 (#22030)
Bumps [metrics](https://github.com/metrics-rs/metrics) from 0.24.3 to 0.24.5.
- [Changelog](https://github.com/metrics-rs/metrics/blob/main/release.toml)
- [Commits](https://github.com/metrics-rs/metrics/compare/metrics-v0.24.3...metrics-v0.24.5)

---
updated-dependencies:
- dependency-name: metrics
  dependency-version: 0.24.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>
2026-05-04 12:21:22 +02:00
dependabot[bot]
8b03c36d5a core: bump github.com/getsentry/sentry-go from 0.46.0 to 0.46.1 (#22019)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.46.0 to 0.46.1.
- [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.46.0...v0.46.1)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.46.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:13:02 +02:00
dependabot[bot]
07a53a101c website: bump the docusaurus group in /website with 10 updates (#22020)
Bumps the docusaurus group in /website with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@docusaurus/preset-classic](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic) | `3.10.0` | `3.10.1` |
| [@docusaurus/core](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus) | `3.10.0` | `3.10.1` |
| [@docusaurus/faster](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-faster) | `3.10.0` | `3.10.1` |
| [@docusaurus/module-type-aliases](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-module-type-aliases) | `3.10.0` | `3.10.1` |
| [@docusaurus/plugin-client-redirects](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-plugin-client-redirects) | `3.10.0` | `3.10.1` |
| [@docusaurus/plugin-content-docs](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-plugin-content-docs) | `3.10.0` | `3.10.1` |
| [@docusaurus/theme-common](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-theme-common) | `3.10.0` | `3.10.1` |
| [@docusaurus/tsconfig](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-tsconfig) | `3.10.0` | `3.10.1` |
| [@docusaurus/types](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-types) | `3.10.0` | `3.10.1` |
| [@docusaurus/theme-mermaid](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-theme-mermaid) | `3.10.0` | `3.10.1` |


Updates `@docusaurus/preset-classic` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-preset-classic)

Updates `@docusaurus/core` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus)

Updates `@docusaurus/faster` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-faster)

Updates `@docusaurus/module-type-aliases` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-module-type-aliases)

Updates `@docusaurus/plugin-client-redirects` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-plugin-client-redirects)

Updates `@docusaurus/plugin-content-docs` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-plugin-content-docs)

Updates `@docusaurus/theme-common` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-theme-common)

Updates `@docusaurus/tsconfig` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-tsconfig)

Updates `@docusaurus/types` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-types)

Updates `@docusaurus/theme-mermaid` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-theme-mermaid)

---
updated-dependencies:
- dependency-name: "@docusaurus/preset-classic"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/core"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/faster"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/module-type-aliases"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/plugin-client-redirects"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/plugin-content-docs"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/theme-common"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/tsconfig"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/types"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/theme-mermaid"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:58 +02:00
dependabot[bot]
a3db2ce6a3 core: bump packaging from 26.1 to 26.2 (#22021)
Bumps [packaging](https://github.com/pypa/packaging) from 26.1 to 26.2.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/26.1...26.2)

---
updated-dependencies:
- dependency-name: packaging
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:53 +02:00
dependabot[bot]
5487cdb874 core: bump aws-cdk-lib from 2.250.0 to 2.251.0 (#22022)
Bumps [aws-cdk-lib](https://github.com/aws/aws-cdk) from 2.250.0 to 2.251.0.
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/compare/v2.250.0...v2.251.0)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.251.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:49 +02:00
dependabot[bot]
2d5160d09b ci: bump int128/docker-manifest-create-action from 2.19.0 to 2.20.0 (#22025)
Bumps [int128/docker-manifest-create-action](https://github.com/int128/docker-manifest-create-action) from 2.19.0 to 2.20.0.
- [Release notes](https://github.com/int128/docker-manifest-create-action/releases)
- [Commits](7df7f9e221...fa55f72001)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:45 +02:00
dependabot[bot]
973fe0bd65 web: bump dompurify from 3.4.1 to 3.4.2 in /web (#22028)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.1 to 3.4.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:41 +02:00
dependabot[bot]
58b5e605de ci: bump taiki-e/install-action from 2.75.25 to 2.75.28 in /.github/actions/setup (#22029)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.25 to 2.75.28.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](1329c298aa...51cd0b8c04)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:12:37 +02:00
Chris
626e23b87a fix(rbac): ensure migration 0056 runs before 0010 removes group field (#21964)
fix(rbac): ensure migration 0056 runs before group field is removed

Migration 0010 removes the `group` FK from the Role model, but
migration 0056 (authentik_core) queries `group_id` on Role as part of
a data migration to move guardian permissions to RBAC roles.

When upgrading from 2025.x, Django's migration executor can schedule
0010 before 0056 because neither depends on the other — only 0056
depends on 0008. This causes a FieldError at runtime:

  Cannot resolve keyword 'group_id' into field.

Adding 0056 as a dependency of 0010 enforces the correct ordering:
the data migration that reads `group_id` must complete before the
schema migration that removes it.
2026-05-04 10:48:30 +02:00
Matthew
3559beba9c website/integrations: add OneUptime SAML integration guide (#21534)
* website/integrations: add OneUptime SAML integration guide

* website/integrations: populate OneUptime SAML integration guide

* wip

* remove link

* website/integrations: simplify OneUptime SAML setup

---------

Co-authored-by: Dominic R <dominic@sdko.org>
2026-05-04 03:03:53 +00:00
authentik-automation[bot]
0b6d3a2850 core, web: update translations (#22013)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-02 18:26:22 +02:00
dependabot[bot]
56ca192391 website: bump the build group in /website with 6 updates (#22000)
Bumps the build group in /website with 6 updates:

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

Updates `@swc/core-darwin-arm64` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-arm64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-x64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/html-darwin-arm64` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/html-linux-arm64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/html-linux-x64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

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

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>
2026-05-02 18:25:36 +02:00
Ken Sternberg
6df62aaa2a web: fix a few visual nits reported after the latest release (#22012)
* ## What

         window.authentik.flow = {
             "layout": "{{ flow.layout }}",
    +        "background": "{{ flow.background }}",
    +        "title": "{{ flow.title }}",
         };

Amends the `flow.html` template and `GlobalAuthentik` parser to include new parameters, `background` and `title`, in the flow-specific part of the configuration written to the HTML `<head>` object, and to provide those parameters to client code.

## Why

The `layout` is start-up critical: it tells the Flow interface how the admin wants the Flow page to look, and allows the HTML and CSS to be pre-aligned to that condition. `layout` is determined on a per-Flow bases, not a per-Stage basis; Flows are derived from a tuple of `(Brand, Application?)`, where the opening policy *may* direct a user to a different flow if the user reached authentik via a redirect from a specific application, but will otherwise fall back to the default Flow for the Brand.

The `background` is a field that is required if the `Flow`’s layout is of type `frame_background`; in this case, the part of the viewport not dedicated to the FlowExecutor is reserved for an `<iframe>` that will be filled in with whatever the administrator specifies. Although this gives it the same priority as `layout` (whether it’s provided or undefined) for describing the [chrome](https://developer.mozilla.org/en-US/docs/Glossary/Chrome) around a challenge, it is currently not provided to the application in the start-up config; it is provided in the `challenge` and renders the IFrame as part of the initial challenge.

This patch fixes that; if `layout` is provided, `background` ought to be as well, even if it’s empty. The execution of a Challenge ought not have any influence over the look and feel of the Flow-defined appearance *around* that Challenge.

I have added `title` as well; with that, all of the current theme-and-appearance related configuration details are placed into `<head>` and can be removed from the FlowExecutor.

Server-side, `background` is currently specified: `background = FileField(blank=True, default="")` which is … interesting since we also appear to store URLs in it. I don’t see anything in the FlowSerializer that would change that from a client’s point of view.

This patch furthers the effort to separate flow execution from flow presentation.

- \[🐰\] The code has been formatted (`make web`)

* The status label was using HTML booleans incorrectly. It is impossible for a boolean to be null. The default red was alarming, so I chose a neutral grey for the 'not default' state.

* It is not enough to provide a blank cell to ensure the header is spaced correctly; if the table is empty, that will collapse to zero width.  Providing the classes that go with the 'this cell may contain a toggle' provides the correct spacing as well.

* Fix inconsistent wording between menu and page; make the 'select type' radiocard and radiolist interfaces flush with the top of the form container, removing a weird jagged visual line between the menu and the content.

* Document adding 'toggle' to Table classes.
2026-05-02 18:25:23 +02:00
transifex-integration[bot]
ca344a64c4 translate: Updates for project authentik and language fr_FR (#22008)
* translate: Translate django.po in fr_FR

100% translated source file: 'django.po'
on 'fr_FR'.

* translate: Translate web/xliff/en.xlf in fr_FR

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

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-05-02 18:07:19 +02:00
Jens L.
a0cdd81f71 tests: add mixin to launch traefik for tests requiring SSL (#22011)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-01 18:23:13 +02:00
Dominic R
8eff4c7e0b website/docs: document air-gapped upgrades (#21972)
* website/docs: document air-gapped upgrades

Explain how to prepare mirrored artifacts for air-gapped upgrades.

Closes: https://github.com/goauthentik/authentik/issues/21376

* Update website/docs/install-config/air-gapped.mdx

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2026-05-01 11:54:37 -04:00
Ken Sternberg
41e6a9860b Merge branch 'main' into dev
* main:
  web/admin: use bindings form for app entitlements (#22007)
  website/integrations: Add guide to integrate Technitium DNS with authentik (#21826)
  website/docs: clarify M2M scope requests (#21977)
  website/docs: clarify LDAP TLS verification (#21974)
  website/docs: clarify blueprint identifiers (#21976)
  website/docs: document promoted sources (#21979)
  lifecycle/aws: bump aws-cdk from 2.1118.4 to 2.1119.0 in /lifecycle/aws (#22001)
  web: bump the swc group across 1 directory with 11 updates (#22004)
  core: bump uvicorn[standard] from 0.45.0 to 0.46.0 (#22002)
  web: bump @sentry/browser from 10.49.0 to 10.50.0 in /web in the sentry group across 1 directory (#22003)
  ci: bump taiki-e/install-action from 2.75.23 to 2.75.25 in /.github/actions/setup (#22005)
  core: bump reqwest from 0.13.2 to 0.13.3 (#22006)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#21999)
  core, web: update translations (#21998)
  enterprise: account lockdown (#18615)
  enterprise/lifecycle: remove one review per object limitation (#21046)
2026-05-01 07:43:48 -07:00
Jens L.
d241a0e8f1 web/admin: use bindings form for app entitlements (#22007)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-01 16:28:46 +02:00
Simon Cinca
ebfc01fcda website/integrations: Add guide to integrate Technitium DNS with authentik (#21826)
Co-authored-by: Dominic R <dominic@sdko.org>
2026-05-01 15:12:24 +02:00
Dominic R
4b0e8a411b website/docs: clarify M2M scope requests (#21977) 2026-05-01 13:11:59 +00:00
Dominic R
9bf6595fc6 website/docs: clarify LDAP TLS verification (#21974) 2026-05-01 09:09:14 -04:00
Dominic R
5c07e845d2 website/docs: clarify blueprint identifiers (#21976)
Closes: https://github.com/goauthentik/authentik/issues/15601
2026-05-01 08:45:38 -04:00
Dominic R
4f76232e7c website/docs: document promoted sources (#21979)
Closes: https://discord.com/channels/809154715984199690/809154716507963434/1499225991778926612
2026-05-01 08:00:33 -04:00
dependabot[bot]
846f8a7e30 lifecycle/aws: bump aws-cdk from 2.1118.4 to 2.1119.0 in /lifecycle/aws (#22001)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1118.4 to 2.1119.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1119.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1119.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 12:49:17 +02:00
dependabot[bot]
fa1c3490c3 web: bump the swc group across 1 directory with 11 updates (#22004)
Bumps the swc group with 1 update in the /web directory: [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core).


Updates `@swc/core` from 1.15.30 to 1.15.32
- [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/commits/v1.15.32/packages/core)

Updates `@swc/core-darwin-arm64` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-darwin-x64` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-arm-gnueabihf` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-arm64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-arm64-musl` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-x64-gnu` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-linux-x64-musl` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-win32-arm64-msvc` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-win32-ia32-msvc` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

Updates `@swc/core-win32-x64-msvc` from 1.15.30 to 1.15.32
- [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.15.30...v1.15.32)

---
updated-dependencies:
- dependency-name: "@swc/core"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-darwin-x64"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm-gnueabihf"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-arm64-musl"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-linux-x64-musl"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-arm64-msvc"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-ia32-msvc"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
- dependency-name: "@swc/core-win32-x64-msvc"
  dependency-version: 1.15.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swc
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 12:49:14 +02:00
dependabot[bot]
a35edf7d0f core: bump uvicorn[standard] from 0.45.0 to 0.46.0 (#22002)
Bumps [uvicorn[standard]](https://github.com/Kludex/uvicorn) from 0.45.0 to 0.46.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.45.0...0.46.0)

---
updated-dependencies:
- dependency-name: uvicorn[standard]
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 12:49:12 +02:00
dependabot[bot]
9d4d5b7133 web: bump @sentry/browser from 10.49.0 to 10.50.0 in /web in the sentry group across 1 directory (#22003)
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 10.49.0 to 10.50.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/10.49.0...10.50.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 10.50.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>
2026-05-01 12:49:08 +02:00
dependabot[bot]
8d91a76bc9 ci: bump taiki-e/install-action from 2.75.23 to 2.75.25 in /.github/actions/setup (#22005)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.23 to 2.75.25.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](481c34c1cf...1329c298aa)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 12:49:04 +02:00
dependabot[bot]
6910428a93 core: bump reqwest from 0.13.2 to 0.13.3 (#22006)
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.2 to 0.13.3.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.2...v0.13.3)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 12:49:01 +02:00
authentik-automation[bot]
cb181d388a stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#21999)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-01 12:48:51 +02:00
authentik-automation[bot]
aad4b6f925 core, web: update translations (#21998)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-05-01 02:01:58 -03:00
Dominic R
821b74d7c1 enterprise: account lockdown (#18615) 2026-04-30 23:02:46 +00:00
Alexander Tereshkin
8963d29ab4 enterprise/lifecycle: remove one review per object limitation (#21046)
* enterprise/lifecycle: allow multiple rules to apply to a single object (and thus, multiple concurrent reviews)

* enterprise/lifecyle: add missing migration to allow multiple lifecycle rules per object, add tests, update documentation

* enterprise/lifecycle: add a bit of padding to individual review iterations on Review tab for better visual separation

* enterprise/lifecycle: remove validation preventing the creation of multiple lifecycle rules for one object type

* enterprise/lifecycle: change the approach to querying the list of reviews with user_is_reviewer annotation to prevent duplicate rows

* enterprise/lifecycle: add custom per-type logic to get object name for use in a notification to prevent texts like "Review is due for Group Group X"

* enterprise/lifecycle: updated wording on lifecycle rule form and preview banner padding

* enterprise/lifecycle: remove task list from lifecycle rules and switch to using per-rule schedules

* enterprise/lifecycle: add a title to the lifecycle tab

* Revert "enterprise/lifecycle: remove task list from lifecycle rules and switch to using per-rule schedules"

This reverts commit 8a060015b693f65f651a71bdb0c47092d3463af1.

* enterprise/lifecycle: remove task list from the lifecycle rule list page and attach the tasks to the schedule

* enterprise/lifecycle: add proper caption when there are no reviews for an object

* enterprise/lifecycle: attach individual apply_lifecycle_rule tasks to the schedule when launched from apply_lifecycle_rules

* enterprise/lifecycle: update generated API clients

* enterprise/lifecycle: update wording

* enterprise/lifecycle: fix ts issues after rebase

* Update website/docs/sys-mgmt/object-lifecycle-management.md

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>

* enterprise/lifecycle: remove fmall code artifact

---------

Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-04-30 14:11:07 -05:00
Ken Sternberg
2f5ad8600a Merge branch 'main' into dev
* main:
  web: bump knip from 6.6.0 to 6.6.3 in /web (#21981)
  packages/ak-common/tracing: make log level lowercase (#21991)
  root: only allow listen failure in dev (#21987)
  flows: preserve signed background URLs in CSS (#21868)
  core, web: update translations (#21966)
  core: fix search for app entitlements failing (#21944)
  ci: bump taiki-e/install-action from 2.75.22 to 2.75.23 in /.github/actions/setup (#21982)
  website/integrations: Refactor and cleanup GitHub Enterprise (#21685)
  web: Clear remember me before navigation. (#21647)
  web: bump knip from 6.4.1 to 6.6.0 in /web (#21957)
  core: bump github.com/getsentry/sentry-go from 0.45.1 to 0.46.0 (#21955)
  core: bump uvicorn[standard] from 0.44.0 to 0.45.0 (#21956)
  core: bump rustls from 0.23.39 to 0.23.40 (#21958)
  core: support hashed password in users API + automated install (#18686)
  core, web: update translations (#21952)
  providers/saml: generate issuer url when provider is set on app (#18022)
2026-04-30 10:42:32 -07:00
dependabot[bot]
699360064e web: bump knip from 6.6.0 to 6.6.3 in /web (#21981)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.6.0 to 6.6.3.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.6.3/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 17:45:56 +02:00
Marc 'risson' Schmitt
3f94f830fc packages/ak-common/tracing: make log level lowercase (#21991) 2026-04-30 14:58:10 +00:00
Marc 'risson' Schmitt
aaba353a9e root: only allow listen failure in dev (#21987) 2026-04-30 14:17:48 +02:00
Dominic R
abdff1c877 flows: preserve signed background URLs in CSS (#21868)
* flows: preserve signed background URLs in CSS

Flow background URLs can include signed S3 query parameters with & separators. These values are rendered inside <style> tags, where Django autoescaping changes & to &amp;; browsers then request the literal escaped query string from S3, causing 400 responses for presigned background images.

Mark the flow background URL values as safe in the CSS-only template contexts used by the standard flow interface, the SFE flow page, and the full-screen login background. Regression coverage asserts that signed URL query separators are preserved in the rendered CSS for both standard and SFE flows.

Co-authored-by: Codex <codex@openai.com>

* flows: preserve signed background URLs in CSS

* fix unrelated test

---------

Co-authored-by: Codex <codex@openai.com>
2026-04-30 07:53:41 -04:00
authentik-automation[bot]
16fd8183b0 core, web: update translations (#21966)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-04-30 13:41:38 +02:00
Jens L.
d3eaa3a4d9 core: fix search for app entitlements failing (#21944) 2026-04-30 13:41:11 +02:00
dependabot[bot]
02aba83017 ci: bump taiki-e/install-action from 2.75.22 to 2.75.23 in /.github/actions/setup (#21982)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 13:40:21 +02:00
Dominic R
e78c43e9d9 website/integrations: Refactor and cleanup GitHub Enterprise (#21685) 2026-04-30 07:11:27 -04:00
Teffen Ellis
d6c0ae21de web: Clear remember me before navigation. (#21647)
* web: Clear remember me before navigation.

* web: fix stray > in "Not you?" link and add Playwright regression for #21571

Move the closing > of the opening <a> tag so the rendered link text no longer
carries a leading > glyph. Add a browser test that seeds the identification
stage with enable_remember_me, walks the identify -> password -> "Not you?"
path, and asserts the link text, the cleared username field, and the cleared
remember-me localStorage key.
Co-Authored-By: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>

* Flesh out remember me lifecycle. Fix edgecases where it doesn't keep up with the e2e suite.

* Fix for submit events, labels.

---------

Co-authored-by: Agent (authentik-i21647-current-instant-chili) <279763771+playpen-agent@users.noreply.github.com>
2026-04-29 23:54:42 +02:00
Ken Sternberg
0d0690a315 Merge branch 'main' into dev
* main: (269 commits)
  root: fix rust build with uv-installed Python (#21858)
  core: add support for hiding applications from the user dashboard (#21530)
  core: bump ruff from 0.15.11 to 0.15.12 (#21871)
  packages/ak-axum/router: add X-Powered-By to all responses (#21940)
  core: bump microsoft-kiota-serialization-form from 1.9.8 to v1.10.1 (#21909)
  core: bump pytest-randomly from 4.0.1 to 4.1.0 (#21873)
  core: users/groups reduce number of database queries (#20431)
  core: bump types-channels from 4.3.0.20260408 to 4.3.0.20260421 (#21872)
  ci: bump taiki-e/install-action from 2.75.21 to 2.75.22 in /.github/actions/setup (#21877)
  core, web: update translations (#21870)
  sources/oauth: ensure user ID is returned as str (#21880)
  translate: Updates for project authentik and language no_NO (#21862)
  core: bump maxminddb from 3.0.0 to v3.1.1 (#21907)
  core: bump prometheus-client from 0.24.0 to v0.25.0 (#21919)
  core: bump azure-identity from 1.25.1 to v1.25.3 (#21886)
  core: bump aiohttp from 3.13.4 to v3.13.5 (#21882)
  core: bump anyio from 4.12.1 to v4.13.0 (#21883)
  core: bump asgiref from 3.11.0 to v3.11.1 (#21884)
  core: bump azure-core from 1.38.0 to v1.39.0 (#21885)
  core: bump blessed from 1.25.0 to v1.38.0 (#21887)
  ...
2026-04-28 10:15:59 -07:00
Ken Sternberg
6c795c3773 Merge branch 'main' into web/element/new-drawer 2026-04-14 10:00:50 -07:00
Ken Sternberg
a3d9b98788 Merge branch 'main' into web/element/new-drawer
* main:
  providers/oauth2: allow cross provider token introspection for federated providers (#21513)
  lifecycle/container: fix OCI image labels (#21574)
  packages/django-dramatiq-postgres: reset db connections in raise_connection_error (#21577)
  core: refresh signed media URLs in flows (#21553)
  events: don't log cacheentry events (#21597)
  web: bump prettier from 3.8.1 to 3.8.2 in /web (#21568)
  providers/oauth2: fix time logic in refresh_token_threshold (#21537)
  web: bump chromedriver from 147.0.1 to 147.0.2 in /web (#21564)
  web: bump axios from 1.13.6 to 1.15.0 in /web (#21528)
  web: bump the bundler group across 1 directory with 3 updates (#21562)
  website/docs: clean up Kubernetes outpost integration page (#21587)
  web: bump follow-redirects from 1.15.11 to 1.16.0 in /web (#21596)
  web: bump lodash-es from 4.17.23 to 4.18.1 in /web (#21595)
  web/admin: fix log viewer layout for application access check (#21594)
  ci: bump softprops/action-gh-release from 2.6.1 to 2.6.2 (#21589)
  ci: bump calibreapp/image-actions from 03c976c29803442fc4040a9de5509669e7759b81 to 4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 (#21590)
  ci: bump actions/create-github-app-token from 3.0.0 to 3.1.1 (#21591)
  website/integrations: fix Microsoft 365 federation guide (#21054)
  website/docs: Update unique email expression policy to exclude current user (#21555)
  lib/sync/outgoing: avoid expensive query to get number of sync pages (#21575)
2026-04-14 09:50:04 -07:00
Ken Sternberg
8f9c5e07ce Prettier has opinions, as usual. 2026-04-13 09:55:25 -07:00
Ken Sternberg
72294bdb4c Extend ak-drawer to comply with the full specification; port ak-drawer to use Patternfly 5; vendor the Patternfly 5 subsystems directly responsible for the Drawer into the CSS.
This drawer is completely independent of Patternfly 4; it brings everything in-house, everything is under `ak-v2-c-drawer`, and we read our variables from `ak-v2-global` entries as part of the style folder.

The contents of the folder are slotted, so they’re part of the parent DOM and parent CSS context, and can be controlled from there without having to do any magic on the Drawer.

To comply with the standards of the HTML disclosure pattern, the drawer uses `expanded` instead of `open`; it listens for an event to trigger open/close; it emits a `toggle` event when completed. Shortcoming: to completely comply with the disclosure pattern, it should emit a `beforeToggle` to let other clients intercept the request and prevent it from happening, but we don’t do that yet.

Unlike the previous drawer, this one has `resizable`, `position`, `inline/static`, and responsive width breakpoints, all features of the Patternfly 5 React web-component. The resizable variant gives you a visible handle, and even responds to keyborad controls.

Along with the native control through CSS Custom Properties, every part of the component has a `part` declaration, so if you *really* want to customize the thing that’s now possible.

Unlike the Patternfly 5 React version, we impose **no** structure on the internals of the component; no padding, no margin, no header/main/footer segmentation. That pattern is universal, and doesn’t need to be specified for each and every component. If you need that, build it into whatever element you put into the unnamed “main” or `panel` slots.

There is a comprehensive Storybook story page for the component.

To meet the requirements of the Drawer, of the LightDOM project, and of the Patternfly 5 vendoring port.
2026-04-13 09:52:10 -07:00
Ken Sternberg
a1857c5a22 Merge branch 'main' into web/element/new-drawer
* main:
  web: build system had some legacy stuff that I found confusing while working on the CSS ordering (#20698)
  core: bump ruff from 0.15.9 to 0.15.10 (#21559)
  core: bump types-jwcrypto from 1.5.0.20260408 to 1.5.7.20260409 (#21561)
  core: bump lxml from 6.0.3 to 6.0.4 (#21560)
  ci: bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#21566)
  ci: bump docker/build-push-action from 7.0.0 to 7.1.0 (#21563)
  ci: bump actions/upload-artifact from 7.0.0 to 7.0.1 (#21565)
  core: bump library/golang from `da39430` to `c0074c7` in /lifecycle/container (#21567)
  ci: bump taiki-e/install-action from 2.75.1 to 2.75.5 in /.github/actions/setup (#21569)
  web: bump mermaid from 11.13.0 to 11.14.0 in /web (#21527)
  core, web: update translations (#21552)
  web: bump basic-ftp from 5.2.1 to 5.2.2 in /web (#21543)
  web: User Wizard, Modal Revisions Merge Branch (#21336)
  web/elements: default @listen target to host element and add split-button Dropdown (#21512)
  ci: parallel tests (#21515)
  website/docs: add another sentence to First Steps about restricting access to apps (#21517)
  web/elements: add scrollbar helpers and apply to Interface (#21511)
  web/elements: add viewport helpers and extend intersection observer (#21508)
2026-04-13 08:38:18 -07:00
Ken Sternberg
b0b4a2a946 Nobody liked this choice. 2026-04-10 17:22:03 -07:00
Ken Sternberg
3141537d9e Re-arranged to avoid having a 'devDependencies' block. 2026-04-10 17:20:55 -07:00
Ken Sternberg
c96b78ceb0 TSC (!) had opinions. 2026-04-10 17:16:57 -07:00
Ken Sternberg
ea98b059e7 ## What
Extend ak-drawer to comply with the full specification; port ak-drawer to use Patternfly 5; vendor the Patternfly 5 subsystems directly responsible for the Drawer into the CSS.

## Why

To meet the requirements of the Drawer, of the LightDOM project, and of the Patternfly 5 vendoring port.

## Details

The Drawer’s internal CSS is now entirely within the Lit framework; the controlling CSS is namespaced to `ak-v2-c--drawer` and placed into the global CSS. Every bit of the drawer has a `part` name, so it can be customized to your heart’s content.

Added stylelint to make sure I’m doing this correctly.
2026-04-10 17:13:08 -07:00
Ken Sternberg
0e94df4977 Merge branch 'main' into web/build-system/lint-2026-03-03
* main: (455 commits)
  web/e2e: accept options in NavigatorFixture.waitForPathname (#21507)
  web/styles: switch to upstream RedHat variable fonts and brighten orange palette (#21509)
  web/styles: add ak-c-loading-skeleton CSS component (#21510)
  core, web: update translations (#21532)
  core: bump lxml from 6.0.2 to 6.0.3 (#21523)
  core: bump library/node from `45babd1` to `9707cd4` in /lifecycle/container (#21522)
  tasks: better error message for Retry exceptions (#18235)
  web/admin: fix user list avatar (#21531)
  core: bump django from v5.2.12 to 5.2.13 (#21520)
  core: add cooldown to dependabot (#21286)
  web/admin: include avatar in user list page (#21518)
  events: add index on Event.user.pk (#19576)
  ci: always run apt update (#21516)
  enterprise/search: move QL to open source] (#21484)
  core: add logging when session decode fails (#21514)
  website/docs: Refactor email configuration (#21130)
  core: bump types-ldap3 from 2.9.13.20260402 to 2.9.13.20260408 (#21493)
  packages/ak-common/db: init (#21357)
  packages/ak-axum/extract/host: init (#21323)
  web: bump knip from 6.3.0 to 6.3.1 in /web (#21505)
  ...
2026-04-10 08:31:02 -07:00
Ken Sternberg
639f02a06e Merge branch 'main' into dev
* main: (36 commits)
  web/e2e: accept options in NavigatorFixture.waitForPathname (#21507)
  web/styles: switch to upstream RedHat variable fonts and brighten orange palette (#21509)
  web/styles: add ak-c-loading-skeleton CSS component (#21510)
  core, web: update translations (#21532)
  core: bump lxml from 6.0.2 to 6.0.3 (#21523)
  core: bump library/node from `45babd1` to `9707cd4` in /lifecycle/container (#21522)
  tasks: better error message for Retry exceptions (#18235)
  web/admin: fix user list avatar (#21531)
  core: bump django from v5.2.12 to 5.2.13 (#21520)
  core: add cooldown to dependabot (#21286)
  web/admin: include avatar in user list page (#21518)
  events: add index on Event.user.pk (#19576)
  ci: always run apt update (#21516)
  enterprise/search: move QL to open source] (#21484)
  core: add logging when session decode fails (#21514)
  website/docs: Refactor email configuration (#21130)
  core: bump types-ldap3 from 2.9.13.20260402 to 2.9.13.20260408 (#21493)
  packages/ak-common/db: init (#21357)
  packages/ak-axum/extract/host: init (#21323)
  web: bump knip from 6.3.0 to 6.3.1 in /web (#21505)
  ...
2026-04-10 08:28:55 -07:00
Ken Sternberg
1a5f7b06be Merge branch 'main' into dev
* main: (38 commits)
  website/integrations: update FortiGate SSLVPN doc (#21475)
  ci: cache apt install (#21480)
  packages/ak-common: use imports where possible (#21478)
  packages/ak-axum/server: cleanup unix socket (#21477)
  packages/ak-common, ak-axum: improve logging (#21476)
  packages/ak-axum/extract/scheme: init (#21322)
  core: fix policy binding objects not being nullable (#21421)
  packages/ak-axum/extract/client_ip: init (#21321)
  translate: Updates for project authentik and language fr_FR (#21474)
  website: bump the docusaurus group in /website with 10 updates (#21452)
  packages/docusaurus-config: update config for docusaurus 3.10 (#21471)
  packages/ak-axum/extract/trusted_proxy: init (#21320)
  web: Fix duplicate Turnstile widgets after extended idle (#21380)
  packages/ak-axum/accept/proxy_protocol: init (#21319)
  web: bump chromedriver from 147.0.0 to 147.0.1 in /web (#21467)
  ci: fix `docker-push-variables` (#21470)
  core, web: update translations (#21450)
  docs,ci: fix main daily compose downloads + release template (#21448)
  web: bump the storybook group across 1 directory with 5 updates (#21460)
  core: bump cryptography from 46.0.6 to 46.0.7 (#21456)
  ...
2026-04-08 14:46:44 -07:00
Ken Sternberg
118d34ae43 Merge branch 'main' into dev
* main: (58 commits)
  packages/ak-axum/error: init (#21315)
  packages/ak-axum: init (#21313)
  website: bump the build group across 1 directory with 9 updates (#21396)
  core: bump jwcrypto from 1.5.6 to 1.5.7 (#21423)
  web: bump fuse.js from 7.1.0 to 7.3.0 in /web (#21429)
  web: bump the bundler group across 1 directory with 3 updates (#21425)
  web: bump cspell from 9.7.0 to 10.0.0 (#21427)
  web: bump knip from 6.1.0 to 6.3.0 in /web (#21428)
  sources/ldap: Switch to new connection tracking, deprecated attribute-based connection (#21392)
  packages/ak-common/mode: init (#21259)
  packages/ak-common/tracing: init (#21263)
  web/admin: Improve WS-Fed algo selection logic (#20881)
  packages/ak-common/tls: init (#21262)
  packages/ak-common/config: add set helper for tests (#21356)
  tasks: allow retry for rejected tasks only (#21433)
  core, web: update translations (#21394)
  website/docs: clarify file upload troubleshooting (#21361)
  ci: bump aws-actions/configure-aws-credentials from 6.0.0 to 6.1.0 (#21424)
  core: bump uvicorn[standard] from 0.43.0 to 0.44.0 (#21422)
  ci: bump taiki-e/install-action from 2.73.0 to 2.74.0 in /.github/actions/setup (#21426)
  ...
2026-04-07 08:39:03 -07:00
Ken Sternberg
ca6fd3d700 Merge branch 'main' into dev
* main:
  website/docs: Clean up PostgreSQL documentation (#21131)
2026-04-02 13:16:29 -07:00
Ken Sternberg
380349a136 Merge branch 'main' into dev
* main: (26 commits)
  root: fix compose generation for patch releases release candidates (#21353)
  web: bump @swc/cli from 0.8.0 to 0.8.1 in /web in the swc group across 1 directory (#21300)
  providers/proxy: fix oidc client not using socket in embedded outpost (#21280)
  packages/client-rust: fix portable sed usage (#21337)
  packages/ak-common/tokio/proxy_procotol: init (#21311)
  packages/ak-common/config: init (#21256)
  core: bump beryju.io/ldap from 0.1.0 to 0.2.1 (#21235)
  web: bump @sentry/browser from 10.46.0 to 10.47.0 in /web in the sentry group across 1 directory (#21297)
  packages/ak-common/arbiter: init (#21253)
  website/docs: fix full dev setup ordering (#21332)
  core: bump types-docker from 7.1.0.20260328 to 7.1.0.20260402 (#21342)
  packages/ak-common: rename from ak-lib (#21314)
  root: fix rustfmt config (#21312)
  core: bump types-ldap3 from 2.9.13.20260319 to 2.9.13.20260402 (#21343)
  web: bump the bundler group across 1 directory with 4 updates (#21345)
  core: bump aiohttp from 3.13.3 to 3.13.4 (#21333)
  core, web: update translations (#21335)
  lifecycle/aws: bump aws-cdk from 2.1115.1 to 2.1116.0 in /lifecycle/aws (#21338)
  core: bump types-requests from 2.33.0.20260327 to 2.33.0.20260402 (#21339)
  core: bump django-stubs[compatible-mypy] from 6.0.1 to 6.0.2 (#21340)
  ...
2026-04-02 09:54:27 -07:00
Ken Sternberg
faf515e4b1 Merge branch 'main' into dev
* main:
  packages/django-postgres-cache: fix expiry and delete (#21307)
  website/docs: entra scim: add note about validator (#21273)
  core: bump djangorestframework-stubs[compatible-mypy] from 3.16.8 to 3.16.9 (#21294)
  web: bump @xmldom/xmldom from 0.8.11 to 0.8.12 in /web (#21301)
  blueprints: rework one-time import (#18074)
  lifecycle/aws: bump aws-cdk from 2.1115.0 to 2.1115.1 in /lifecycle/aws (#21293)
  core, web: update translations (#21288)
  core: bump sentry-sdk from 2.56.0 to 2.57.0 (#21295)
  core: bump aws-cdk-lib from 2.245.0 to 2.246.0 (#21296)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#21290)
  ci: bump getsentry/action-release from 3.5.0 to 3.6.0 (#21298)
  ci: bump taiki-e/install-action from 2.70.3 to 2.70.4 in /.github/actions/setup (#21299)
  website/docs: add example recovery flow with MFA (#19497)
  website/docs: format cache settings (#21289)
  source/saml: Add forceauthn to saml authnrequest (#20883)
2026-04-01 08:34:17 -07:00
Ken Sternberg
3d40620b8c Merge branch 'main' into dev
* main:
  translate: Updates for project authentik and language fr_FR (#21285)
  packages/django-postgres-cache: rework to use ORM (#17771)
  providers/saml: Fix redirect for saml slo (#21258)
  core: fix provider not nullable (#21275)
  website/docs: ad source: add note about ldap signing (#21274)
  website/api: update API clients doc (#21202)
  ci: bump taiki-e/install-action from 2.70.2 to 2.70.3 in /.github/actions/setup (#21267)
  lifecycle/aws: bump aws-cdk from 2.1114.1 to 2.1115.0 in /lifecycle/aws (#21265)
  core, web: update translations (#21264)
  packages/ak-lib: init (#21257)
  website/docs: document group_uuid as a property for group object (#20865)
  web/flow: extract lifecycle events peripheral to stage management into their own controllers (#20898)
  core: bump pygments from 2.19.2 to 2.20.0 (#21260)
  website/docs: add grafana dashboard (#21254)
2026-03-31 13:23:45 -07:00
Ken Sternberg
7d7e2e91f6 Merge branch 'main' into dev
* main: (52 commits)
  stages/authenticator_webauthn: save attestation certificate when creating credential (#20095)
  web/admin: fix missing icon on app view page (#21251)
  web/elements: allow table per-column options (#21250)
  ci: bump actions/setup-go from 6.3.0 to 6.4.0 (#21245)
  web: bump knip from 6.0.6 to 6.1.0 in /web (#21241)
  web: bump globby from 16.1.1 to 16.2.0 in /web (#21242)
  core: bump types-requests from 2.32.4.20260324 to 2.33.0.20260327 (#21236)
  core: bump types-docker from 7.1.0.20260322 to 7.1.0.20260328 (#21237)
  core: bump aws-cdk-lib from 2.244.0 to 2.245.0 (#21238)
  ci: bump int128/docker-manifest-create-action from 2.16.0 to 2.17.0 (#21244)
  ci: bump astral-sh/setup-uv from 7.6.0 to 8.0.0 in /.github/actions/setup (#21246)
  ci: bump taiki-e/install-action from 2.69.12 to 2.70.2 in /.github/actions/setup (#21247)
  ci: bump actions/setup-go from 6.3.0 to 6.4.0 in /.github/actions/setup (#21248)
  core, web: update translations (#21233)
  translate: Updates for project authentik and language fr_FR (#21214)
  web/admin: polish recent events, various button alignments and labels (#21232)
  outposts: Create separate metrics service in Kubernetes (#21229)
  events: fix exception in volume endpoint, adjust simple table size (#21230)
  core: Application stats, device events & cleanup (#21225)
  core: bump axllent/mailpit from v1.29.4 to v1.29.5 in /tests/e2e (#21226)
  ...
2026-03-30 10:13:12 -07:00
Ken Sternberg
631c3c788d Merge branch 'main' into dev
* main:
  website/integrations: add OAUTH_AUTO_REDIRECT for karakeep (#21180)
  website/integrations: beszel: add email scope (#21176)
  web: lint/small type errors (#21179)
  packages/django-dramatiq-postgres: add index for (queue_name, state, eta) (#21175)
  root: add git attributes for generated/vendored (#21177)
  web: bump vite from 8.0.2 to 8.0.3 in /web (#21171)
  core, web: Vendored client follow-ups (#21174)
  website: Enable gtag in production (#21151)
2026-03-26 13:31:25 -07:00
Ken Sternberg
7aa992079e Merge branch 'main' into dev
* main: (21 commits)
  root: cleanup API generation (#21172)
  packages/client-ts: init (#21120)
  core, web: update translations (#21159)
  website: bump @goauthentik/docusaurus-config from 2.5.1 to 2.6.0 in /website in the docusaurus group (#21161)
  core: bump cryptography from 46.0.5 to 46.0.6 (#21162)
  core: bump library/node from 25.8.1-trixie to 25.8.2-trixie in /website (#21163)
  ci: bump taiki-e/install-action from 2.69.9 to 2.69.10 in /.github/actions/setup (#21164)
  web: bump the goauthentik group across 1 directory with 3 updates (#21165)
  web: bump typescript from 5.9.3 to 6.0.2 in /web (#21107)
  web/flows: fix continuous flow leftovers (#21158)
  web: bump picomatch from 4.0.3 to 4.0.4 (#21157)
  web: bump yaml from 2.8.2 to 2.8.3 (#21156)
  website: bump picomatch in /website (#21155)
  web: bump smol-toml from 1.6.0 to 1.6.1 (#21154)
  web: bump picomatch from 2.3.1 to 2.3.2 in /web (#21153)
  web: bump smol-toml from 1.6.0 to 1.6.1 in /web (#21152)
  root: optimise api client generation speed (#21141)
  website/integrations: nextcloud add back-channel logout documentation (#21147)
  core: bump requests from 2.32.5 to 2.33.0 (#21146)
  web: bump chromedriver from 146.0.5 to 146.0.6 in /web (#21128)
  ...
2026-03-26 08:13:28 -07:00
Ken Sternberg
23536f9625 Merge branch 'main' into dev
* main: (26 commits)
  endpoints/connectors: fix enabled flag not respected (#21144)
  web: bump vite from 7.3.1 to 8.0.2 in /web (#21109)
  website/docs: add a single page about our user interface, document Consent stage (#20533)
  website: bump the build group across 1 directory with 9 updates (#21127)
  web: bump knip from 5.88.1 to 6.0.5 in /web (#21129)
  core: bump drf-spectacular from 0.28.0 to 0.29.0 (#19420)
  packages/client-go: init (#21139)
  providers/proxy: Add a default maxResponseBodySize to Traefik Middleware (#21111)
  core: bump library/nginx from `dec7a90` to `7150b3a` in /website (#21137)
  core: bump gunicorn from 25.1.0 to 25.2.0 (#21134)
  core: bump github.com/getsentry/sentry-go from 0.43.0 to 0.44.1 (#21122)
  core: bump astral-sh/uv from 0.11.0 to 0.11.1 in /lifecycle/container (#21135)
  ci: bump taiki-e/install-action from 2.69.8 to 2.69.9 in /.github/actions/setup (#21136)
  web/a11y: Modals, Command Palette (Merge branch) (#17812)
  website/docs: document file picker values (#20994)
  packages/client-rust: init (#21117)
  core: bump sentry-sdk from 2.55.0 to 2.56.0 (#21124)
  events: add helper to log deprecation configuration_warning message (#21115)
  core: bump djangorestframework from 3.17.0 to 3.17.1 (#21126)
  core: bump twilio from 9.10.3 to 9.10.4 (#21123)
  ...
2026-03-25 09:59:07 -07:00
Ken Sternberg
b6d8df057e Merge branch 'main' into dev
* main:
  core: remove filter_not_expired for QS (#18274)
  tenants: fix default schema in initial migration (#21114)
  core: bump django-stubs[compatible-mypy] from 5.2.9 to 6.0.1 (#21099)
  core, web: update translations (#21097)
  lifecycle/aws: bump aws-cdk from 2.1112.0 to 2.1113.0 in /lifecycle/aws (#21098)
  core: bump types-requests from 2.32.4.20260107 to 2.32.4.20260324 (#21100)
  core: bump constructs from 10.5.1 to 10.6.0 (#21101)
  core: bump astral-sh/uv from 0.10.12 to 0.11.0 in /lifecycle/container (#21103)
  ci: bump taiki-e/install-action from 2.69.6 to 2.69.7 in /.github/actions/setup (#21104)
  web: bump flatted from 3.4.1 to 3.4.2 (#21076)
  core: bump goauthentik.io/api/v3 to 3.2026.5.0-rc1-1774286095 (#21089)
  core: bump cbor2 from 5.8.0 to 5.9.0 (#21094)
  ci: fix cherry-pick action generating empty title (#21091)
  web: bump the swc group across 1 directory with 11 updates (#21070)
  web: bump yaml from 2.8.2 to 2.8.3 in /web (#21071)
  core: add flag for future default behaviour of requiring a binding to access an application (#16247)
2026-03-24 07:52:19 -07:00
Ken Sternberg
7c60c42449 Merge branch 'main' into dev
* main: (22 commits)
  ci: rotate GH App private key (#21085)
  internal/web: remove authentication for metrics (#21077)
  lib/config: explicit some defaults (#21079)
  internal: remove unix sockets on shutdown (#21081)
  ci: fix escaping in cherry-pick action (#21082)
  lib/config: support printing multiple values (#21080)
  root: fix rust setup (#21078)
  core: bump types-docker from 7.1.0.20260109 to 7.1.0.20260322 (#21062)
  policies: remove BufferedPolicyAccessView leftovers (#21057)
  core: bump axllent/mailpit from v1.29.3 to v1.29.4 in /tests/e2e (#21061)
  core: bump types-channels from 4.3.0.20250822 to 4.3.0.20260321 (#21063)
  core: bump github.com/jackc/pgx/v5 from 5.8.0 to 5.9.1 (#21059)
  translate: Updates for project authentik and language fr_FR (#21056)
  ci: bump taiki-e/install-action from 2.69.2 to 2.69.6 in /.github/actions/setup (#21068)
  web: bump the storybook group across 1 directory with 5 updates (#21031)
  web: bump knip from 5.88.0 to 5.88.1 in /web (#21033)
  web: bump type-fest from 5.4.4 to 5.5.0 in /web (#21032)
  events: prevent exception when events contains incompatible unicode (#21048)
  web/admin: handle non-string values in formatUUID to prevent Event Log crash (#20804)
  events: avoid implicitly setting context from login_failed event (#21045)
  ...
2026-03-23 09:41:37 -07:00
Ken Sternberg
00375472e7 Merge branch 'main' into dev
* main: (36 commits)
  website: fix typos (#20996)
  internal/outpost/ak: fix ws URL on outpost restart (#21041)
  sources/ldap: fix incorrect error response for invalid sync_users_password (#21016)
  website/docs: add missing dependencies for linux dev environment (#21020)
  core, web: update translations (#21021)
  web: bump flatted from 3.4.1 to 3.4.2 in /web (#21037)
  web: bump @sentry/browser from 10.44.0 to 10.45.0 in /web in the sentry group across 1 directory (#21022)
  website: bump flatted from 3.4.1 to 3.4.2 in /website (#21038)
  core: bump astral-sh/uv from 0.10.11 to 0.10.12 in /lifecycle/container (#21027)
  ci: bump actions-rust-lang/setup-rust-toolchain from 1.15.3 to 1.15.4 in /.github/actions/setup (#21030)
  ci: bump taiki-e/install-action from 2.68.26 to 2.69.2 in /.github/actions/setup (#21029)
  core: bump goauthentik/fips-debian from `7baeeaa` to `7726387` in /lifecycle/container (#21028)
  core: bump aws-cdk-lib from 2.243.0 to 2.244.0 (#21026)
  core: bump types-ldap3 from 2.9.13.20251121 to 2.9.13.20260319 (#21024)
  core: bump ruff from 0.15.6 to 0.15.7 (#21023)
  core: bump goauthentik/fips-python from `859ad57` to `bf45eb7` in /lifecycle/container (#21025)
  website/integrations: fix AWS SCIM with Identity Center (#21017)
  root: allow listening on multiple IPs (#20930)
  website: switch docs analytics to gtag (#20993)
  web: link file picker to docs (#20995)
  ...
2026-03-20 09:52:57 -07:00
Ken Sternberg
4b51838f42 ## What
window.authentik.flow = {
             "layout": "{{ flow.layout }}",
    +        "background": "{{ flow.background }}",
    +        "title": "{{ flow.title }}",
         };

Amends the `flow.html` template and `GlobalAuthentik` parser to include new parameters, `background` and `title`, in the flow-specific part of the configuration written to the HTML `<head>` object, and to provide those parameters to client code.

## Why

The `layout` is start-up critical: it tells the Flow interface how the admin wants the Flow page to look, and allows the HTML and CSS to be pre-aligned to that condition. `layout` is determined on a per-Flow bases, not a per-Stage basis; Flows are derived from a tuple of `(Brand, Application?)`, where the opening policy *may* direct a user to a different flow if the user reached authentik via a redirect from a specific application, but will otherwise fall back to the default Flow for the Brand.

The `background` is a field that is required if the `Flow`’s layout is of type `frame_background`; in this case, the part of the viewport not dedicated to the FlowExecutor is reserved for an `<iframe>` that will be filled in with whatever the administrator specifies. Although this gives it the same priority as `layout` (whether it’s provided or undefined) for describing the [chrome](https://developer.mozilla.org/en-US/docs/Glossary/Chrome) around a challenge, it is currently not provided to the application in the start-up config; it is provided in the `challenge` and renders the IFrame as part of the initial challenge.

This patch fixes that; if `layout` is provided, `background` ought to be as well, even if it’s empty. The execution of a Challenge ought not have any influence over the look and feel of the Flow-defined appearance *around* that Challenge.

I have added `title` as well; with that, all of the current theme-and-appearance related configuration details are placed into `<head>` and can be removed from the FlowExecutor.

Server-side, `background` is currently specified: `background = FileField(blank=True, default="")` which is … interesting since we also appear to store URLs in it. I don’t see anything in the FlowSerializer that would change that from a client’s point of view.

This patch furthers the effort to separate flow execution from flow presentation.

- \[🐰\] The code has been formatted (`make web`)
2026-03-18 15:25:11 -07:00
Ken Sternberg
7f9f2a13c2 That was a stupid spelling error. 2026-03-11 09:56:34 -07:00
Ken Sternberg
759d8e5fdc Merge branch 'main' into web/build-system/lint-2026-03-03
* main: (50 commits)
  web/flow/stages: permit the form handler to look in the light or shadowDOM for controls (#20832)
  web/style/flow: flow css barrel file (#20833)
  web/flow: provide labels for the stage import-and-invoke table (#20834)
  core: bump goauthentik/fips-python from `3636935` to `46b26b8` in /lifecycle/container (#20842)
  core: bump library/nginx from `0236ee0` to `d0913a1` in /website (#20843)
  core, web: update translations (#20835)
  core: bump goauthentik/fips-debian from `0975985` to `2517845` in /lifecycle/container (#20841)
  core: bump twilio from 9.10.2 to 9.10.3 (#20838)
  core: bump aws-cdk-lib from 2.241.0 to 2.242.0 (#20840)
  ci: bump astral-sh/setup-uv from 7.3.1 to 7.4.0 in /.github/actions/setup (#20844)
  providers/SCIM: Add discover support (#20658)
  core: allow interfaces to specify alternative stylesheets (#20774)
  website/docs: update agent docs (#20782)
  core, web: update translations (#20809)
  lifecycle/aws: bump aws-cdk from 2.1109.0 to 2.1110.0 in /lifecycle/aws (#20810)
  core: bump axllent/mailpit from v1.29.2 to v1.29.3 in /tests/e2e (#20811)
  core: bump cachetools from 7.0.4 to 7.0.5 (#20812)
  core: bump goauthentik/fips-python from `b481db2` to `3636935` in /lifecycle/container (#20814)
  core: bump goauthentik/fips-debian from `6c9197b` to `0975985` in /lifecycle/container (#20815)
  web: bump the storybook group across 1 directory with 5 updates (#20816)
  ...
2026-03-11 09:50:48 -07:00
Ken Sternberg
9793422848 Merge branch 'main' into web/build-system/lint-2026-03-03 2026-03-06 15:13:28 -08:00
Ken Sternberg
c533681caf Merge branch 'main' into web/build-system/lint-2026-03-03 2026-03-04 10:19:05 -08:00
Ken Sternberg
a7f47171c5 Merge branch 'main' into web/build-system/lint-2026-03-03 2026-03-04 07:52:48 -08:00
Ken Sternberg
6eaf23cd6d Did I miss something? 2026-03-04 07:41:31 -08:00
Ken Sternberg
d4ea46e857 . 2026-03-03 10:27:33 -08:00
488 changed files with 56718 additions and 13638 deletions

View File

@@ -49,7 +49,7 @@ runs:
if: ${{ contains(inputs.dependencies, 'python') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: uv sync --all-extras --dev --frozen
run: uv sync --all-extras --dev --locked
- name: Setup rust (stable)
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1
@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
uses: taiki-e/install-action@711e1c3275189d76dcc4d34ddea63bf96ac49090 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
@@ -104,7 +104,7 @@ runs:
working-directory: ${{ inputs.working-directory }}
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
docker compose -f .github/actions/setup/compose.yml up -d --wait
cd web && npm ci
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}

View File

@@ -8,8 +8,14 @@ services:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1"]
interval: 1s
timeout: 5s
retries: 60
restart: always
s3:
container_name: s3

View File

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

View File

@@ -282,10 +282,18 @@ jobs:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
- name: oidc_basic
glob: tests/openid_conformance/test_oidc_basic.py
- name: oidc_implicit
glob: tests/openid_conformance/test_oidc_implicit.py
- name: oidc_rp-initiated
glob: tests/openid_conformance/test_oidc_rp_initiated.py
- name: oidc_frontchannel
glob: tests/openid_conformance/test_oidc_frontchannel.py
- name: oidc_backchannel
glob: tests/openid_conformance/test_oidc_backchannel.py
- name: ssf_transmitter
glob: tests/openid_conformance/test_ssf_transmitter.py
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env

5
.gitignore vendored
View File

@@ -229,6 +229,11 @@ source_docs/
### Golang ###
/vendor/
server
proxy
ldap
rac
radius
### Docker ###
tests/openid_conformance/exports/*.zip

158
Cargo.lock generated
View File

@@ -17,18 +17,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -203,6 +191,7 @@ dependencies = [
"tokio",
"tracing",
"uuid",
"which",
]
[[package]]
@@ -1014,6 +1003,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "evmap"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
dependencies = [
"hashbag",
"left-right",
"smallvec",
]
[[package]]
name = "eyre"
version = "0.6.12"
@@ -1230,6 +1230,21 @@ dependencies = [
"slab",
]
[[package]]
name = "generator"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1311,6 +1326,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbag"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1868,6 +1889,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "left-right"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]]
name = "libc"
version = "0.2.183"
@@ -1939,6 +1971,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1978,21 +2023,22 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.3"
version = "0.24.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071"
dependencies = [
"ahash",
"portable-atomic",
"rapidhash",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.1"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
"base64 0.22.1",
"evmap",
"indexmap",
"metrics",
"metrics-util",
@@ -2011,7 +2057,7 @@ dependencies = [
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.2",
"rand 0.9.4",
"rand_xoshiro",
"sketches-ddsketch",
]
@@ -2698,7 +2744,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
@@ -2758,9 +2804,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -2813,6 +2859,15 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rapidhash"
version = "4.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59"
dependencies = [
"rustversion",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2871,9 +2926,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.13.2"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3105,6 +3160,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3142,9 +3203,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "sentry"
version = "0.47.0"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb25f439f97d26fea01d717fa626167ceffcd981addaa670001e70505b72acbb"
checksum = "e8ac94aab850a23d7507307cc505332ed2bafd36c65930dfc5c43610f9e9b477"
dependencies = [
"cfg_aliases",
"httpdate",
@@ -3163,9 +3224,9 @@ dependencies = [
[[package]]
name = "sentry-backtrace"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a8c2c1bd5c1f735e84f28b48e7d72efcaafc362b7541bc8253e60e8fcdffc6"
checksum = "dc84c325ace9ca2388e510fe7d6672b5d60cd8b3bd0eb4bb4ee8314c323cd686"
dependencies = [
"backtrace",
"regex",
@@ -3174,9 +3235,9 @@ dependencies = [
[[package]]
name = "sentry-contexts"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b88a90baa654d7f0e1f4b667f6b434293d9f72c71bef16b197c76af5b7d5803"
checksum = "896c1ab62dbfe1746fb262bbf72e6feb2fb9dfb2c14709077bf71beb532e44b2"
dependencies = [
"hostname",
"libc",
@@ -3188,11 +3249,11 @@ dependencies = [
[[package]]
name = "sentry-core"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ac170a5bba8bec6e3339c90432569d89641fa7a3d3e4f44987d24f0762e6adf"
checksum = "d5f5abf20c42cb1593ec1638976e2647da55f79bccac956444c1707b6cce259a"
dependencies = [
"rand 0.9.2",
"rand 0.9.4",
"sentry-types",
"serde",
"serde_json",
@@ -3201,9 +3262,9 @@ dependencies = [
[[package]]
name = "sentry-debug-images"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd9646a972b57896d4a92ed200cf76139f8e30b3cfd03b6662ae59926d26633c"
checksum = "4b88bbe6a760d5724bb40689827e82e8db1e275947df2c59abe171bfc30bb671"
dependencies = [
"findshlibs",
"sentry-core",
@@ -3211,9 +3272,9 @@ dependencies = [
[[package]]
name = "sentry-panic"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6127d3d304ba5ce0409401e85aae538e303a569f8dbb031bf64f9ba0f7174346"
checksum = "0260dcb52562b6a79ae7702312a26dba94b79fb5baee7301087529e5ca4e872e"
dependencies = [
"sentry-backtrace",
"sentry-core",
@@ -3221,9 +3282,9 @@ dependencies = [
[[package]]
name = "sentry-tower"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c5253dc4ad89863a866b93aeaaac1c9d60f2f774663b5024afe2d57e0a101c"
checksum = "d669616d5d5279b5712febfc80c343acc3695e499de0d101ed70fceacadf37f2"
dependencies = [
"sentry-core",
"tower-layer",
@@ -3232,9 +3293,9 @@ dependencies = [
[[package]]
name = "sentry-tracing"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c"
checksum = "a1c035f3a0a8671ae1a231c5b457abb68b71acba2bf3054dab2a09a9d4ea487e"
dependencies = [
"bitflags 2.11.0",
"sentry-backtrace",
@@ -3245,13 +3306,13 @@ dependencies = [
[[package]]
name = "sentry-types"
version = "0.47.0"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56780cb5597d676bf22e6c11d1f062eb4def46390ea3bfb047bcbcf7dfd19bdb"
checksum = "82d8e81058ec155992191f61c7b29bfa7b2cf12012131e7cdc0678020898a7c9"
dependencies = [
"debugid",
"hex",
"rand 0.9.2",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -4153,7 +4214,7 @@ dependencies = [
"http",
"httparse",
"log",
"rand 0.9.2",
"rand 0.9.4",
"sha1",
"thiserror 2.0.18",
]
@@ -4515,6 +4576,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "which"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "1.6.1"

View File

@@ -43,15 +43,15 @@ hyper-unix-socket = "= 0.6.1"
hyper-util = "= 0.1.20"
ipnet = { version = "= 2.12.0", features = ["serde"] }
json-subscriber = "= 0.2.8"
metrics = "= 0.24.3"
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
metrics = "= 0.24.5"
metrics-exporter-prometheus = { version = "= 0.18.3", default-features = false }
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
notify = "= 8.2.0"
pin-project-lite = "= 0.2.17"
pyo3 = "= 0.28.3"
pyo3-build-config = "= 0.28.3"
regex = "= 1.12.3"
reqwest = { version = "= 0.13.2", features = [
reqwest = { version = "= 0.13.3", features = [
"form",
"json",
"multipart",
@@ -67,7 +67,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"rustls",
] }
rustls = { version = "= 0.23.40", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
sentry = { version = "= 0.48.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
@@ -113,6 +113,7 @@ tracing-subscriber = { version = "= 0.3.23", features = [
] }
url = "= 2.5.8"
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
which = "= 8.0.2"
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
@@ -282,6 +283,7 @@ sqlx = { workspace = true, optional = true }
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
which.workspace = true
[lints]
workspace = true

View File

@@ -109,14 +109,11 @@ i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that requir
aws-cfn:
cd lifecycle/aws && npm i && $(UV) run npm run aws-cfn
run-server: ## Run the main authentik server process
$(UV) run ak server
run: ## Run the main authentik server and worker processes
$(UV) run ak allinone
run-worker: ## Run the main authentik worker process
$(UV) run ak worker
run-worker-watch: ## Run the authentik worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
run-watch: ## Run the authentik server and worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs,go --no-meta --notify -- $(UV) run ak allinone
core-i18n-extract:
$(UV) run ak makemessages \

36
authentik/api/ordering.py Normal file
View File

@@ -0,0 +1,36 @@
from django.db.models import F, QuerySet
from rest_framework.filters import OrderingFilter
from rest_framework.request import Request
from rest_framework.views import APIView
class NullsAwareOrderingFilter(OrderingFilter):
"""OrderingFilter that sorts NULL values consistently.
For any nullable field, NULLs are treated as the smallest possible value:
- ascending → NULLs appear first (nulls_first=True)
- descending → NULLs appear last (nulls_last=True)
"""
def _nullable_field_names(self, queryset: QuerySet) -> set[str]:
return {f.name for f in queryset.model._meta.get_fields() if hasattr(f, "null") and f.null}
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView):
queryset = super().filter_queryset(request, queryset, view)
ordering = queryset.query.order_by
if not ordering:
return queryset
nullable = self._nullable_field_names(queryset)
new_ordering = []
changed = False
for term in ordering:
name = term.lstrip("-")
if name in nullable:
changed = True
if term.startswith("-"):
new_ordering.append(F(name).desc(nulls_last=True))
else:
new_ordering.append(F(name).asc(nulls_first=True))
else:
new_ordering.append(term)
return queryset.order_by(*new_ordering) if changed else queryset

View File

@@ -0,0 +1,59 @@
from django.db.models import OrderBy
from django.test import TestCase
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from authentik.api.ordering import NullsAwareOrderingFilter
from authentik.core.models import Token, User
class MockView:
ordering_fields = "__all__"
ordering = None
class TestNullsAwareOrderingFilter(TestCase):
def setUp(self):
self.filter = NullsAwareOrderingFilter()
self.view = MockView()
factory = APIRequestFactory()
self._req = lambda ordering: Request(factory.get("/", {"ordering": ordering}))
def _order_by(self, model, ordering):
qs = model.objects.all()
return self.filter.filter_queryset(self._req(ordering), qs, self.view).query.order_by
def test_nullable_asc_nulls_first(self):
"""Ascending sort on a nullable field rewrites to nulls_first=True."""
(expr,) = self._order_by(User, "last_login")
self.assertIsInstance(expr, OrderBy)
self.assertFalse(expr.descending)
self.assertTrue(expr.nulls_first)
def test_nullable_desc_nulls_last(self):
"""Descending sort on a nullable field rewrites to nulls_last=True."""
(expr,) = self._order_by(User, "-last_login")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)
def test_non_nullable_passes_through(self):
"""Non-nullable fields are left as plain string terms."""
(expr,) = self._order_by(User, "username")
self.assertEqual(expr, "username")
def test_mixed_ordering(self):
"""Only nullable terms are rewritten; non-nullable terms pass through unchanged."""
first, second = self._order_by(User, "username,-last_login")
self.assertEqual(first, "username")
self.assertIsInstance(second, OrderBy)
self.assertTrue(second.descending)
self.assertTrue(second.nulls_last)
def test_expires_nullable(self):
"""expires on ExpiringModel is nullable and is rewritten correctly."""
(expr,) = self._order_by(Token, "-expires")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)

View File

@@ -1,31 +1,73 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from urllib.parse import urlencode
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.api.v3.urls import router
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.tenants.api.domains import DomainViewSet
from authentik.tenants.api.tenants import TenantViewSet
from authentik.tenants.utils import get_current_tenant
class TestModelViewSets(TestCase):
"""Test Viewset"""
def setUp(self):
self.user = create_test_admin_user()
self.factory = RequestFactory()
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
"""Test Viewset"""
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
def test_attrs(self: TestModelViewSets) -> None:
"""Test attributes we require on all viewsets"""
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
return tester
def test_ordering(self: TestModelViewSets) -> None:
"""Test that all ordering fields are correct"""
view = test_viewset.as_view({"get": "list"})
for ordering_field in test_viewset.ordering:
with self.subTest(ordering_field):
req = self.factory.get(
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
def test_search(self: TestModelViewSets) -> None:
"""Test that search fields are correct"""
view = test_viewset.as_view({"get": "list"})
req = self.factory.get(
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
cases = {
"attrs": test_attrs,
}
if full:
cases["ordering"] = test_ordering
cases["search"] = test_search
return cases
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
continue
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
for test, case in viewset_tester_factory(viewset, full=full).items():
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)

View File

@@ -1,5 +1,6 @@
"""Serializer mixin for managed models"""
from json import JSONDecodeError, loads
from typing import cast
from django.conf import settings
@@ -44,6 +45,7 @@ class BlueprintUploadSerializer(PassiveSerializer):
file = FileField(required=False)
path = CharField(required=False)
context = CharField(required=False, allow_blank=True)
def validate_path(self, path: str) -> str:
"""Ensure the path (if set) specified is retrievable"""
@@ -54,6 +56,18 @@ class BlueprintUploadSerializer(PassiveSerializer):
raise ValidationError(_("Blueprint file does not exist"))
return path
def validate_context(self, context: str) -> dict:
"""Parse context as a JSON object"""
if not context:
return {}
try:
parsed = loads(context)
except JSONDecodeError as exc:
raise ValidationError(_("Context must be valid JSON")) from exc
if not isinstance(parsed, dict):
raise ValidationError(_("Context must be a JSON object"))
return parsed
class ManagedSerializer:
"""Managed Serializer"""
@@ -126,7 +140,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
def check_blueprint_perms(blueprint: Blueprint, user: User, explicit_action: str | None = None):
"""Check for individual permissions for each model in a blueprint"""
for entry in blueprint.entries:
for entry in blueprint.iter_entries():
full_model = entry.get_model(blueprint)
app, __, model = full_model.partition(".")
perms = [
@@ -224,7 +238,8 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
).retrieve_file()
else:
raise ValidationError("Either path or file must be set")
importer = Importer.from_string(string_contents)
context = body.validated_data.get("context") or {}
importer = Importer.from_string(string_contents, context)
check_blueprint_perms(importer.blueprint, request.user)

View File

@@ -1,6 +1,6 @@
"""Test blueprints v1 api"""
from json import loads
from json import dumps, loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.urls import reverse
@@ -8,7 +8,11 @@ from rest_framework.test import APITestCase
from yaml import dump
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.stages.invitation.models import InvitationStage
from authentik.stages.user_write.models import UserWriteStage
TMP = mkdtemp("authentik-blueprints")
@@ -80,3 +84,107 @@ class TestBlueprintsV1API(APITestCase):
res.content.decode(),
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
)
def test_api_import_with_context(self):
"""Test that the import endpoint applies the supplied context to the real blueprint"""
slug = f"invitation-enrollment-{generate_id()}"
flow_name = f"Invitation Enrollment {generate_id()}"
stage_name = f"invitation-stage-{generate_id()}"
user_type = "internal"
continue_without_invitation = True
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": dumps(
{
"flow_slug": slug,
"flow_name": flow_name,
"stage_name": stage_name,
"continue_flow_without_invitation": continue_without_invitation,
"user_type": user_type,
}
),
},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.json()["success"])
flow = Flow.objects.get(slug=slug)
self.assertEqual(flow.name, flow_name)
self.assertEqual(flow.title, flow_name)
invitation_stage = InvitationStage.objects.get(name=stage_name)
self.assertEqual(
invitation_stage.continue_flow_without_invitation,
continue_without_invitation,
)
user_write_stage = UserWriteStage.objects.get(
name=f"invitation-enrollment-user-write-{slug}"
)
self.assertEqual(user_write_stage.user_type, user_type)
self.assertEqual(user_write_stage.user_path_template, f"users/{user_type}")
def test_api_import_blank_path(self):
"""Validator returns empty path unchanged (covers api.py:53)."""
with NamedTemporaryFile(mode="w+", suffix=".yaml") as file:
file.write(dump({"version": 1, "entries": []}))
file.flush()
file.seek(0)
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"path": "", "file": file},
format="multipart",
)
self.assertEqual(res.status_code, 200)
def test_api_import_unknown_path(self):
"""Path not in available blueprints is rejected (covers api.py:56)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"path": "does/not/exist.yaml"},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Blueprint file does not exist", res.content.decode())
def test_api_import_blank_context(self):
"""Blank context is normalized to empty dict (covers api.py:62)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "",
},
format="multipart",
)
self.assertEqual(res.status_code, 200)
def test_api_import_invalid_json_context(self):
"""Malformed JSON context raises ValidationError (covers api.py:65-66)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "{not json",
},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Context must be valid JSON", res.content.decode())
def test_api_import_non_object_context(self):
"""JSON context that isn't an object is rejected (covers api.py:68)."""
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={
"path": "example/flows-invitation-enrollment-minimal.yaml",
"context": "[1, 2, 3]",
},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("Context must be a JSON object", res.content.decode())

View File

@@ -1,8 +1,11 @@
"""Test blueprints v1"""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -42,3 +45,45 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
# Ensure objects do not exist
self.assertFalse(Flow.objects.filter(slug=flow_slug1))
self.assertFalse(Flow.objects.filter(slug=flow_slug2))
def test_enterprise_license_context_unlicensed(self):
"""Test enterprise license context defaults to a false boolean when unlicensed."""
license_key = LicenseKey("test", 0, "Test license", 0, 0)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], False)
def test_enterprise_license_context_licensed(self):
"""Test enterprise license context defaults to a true boolean when licensed."""
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], True)

View File

@@ -146,9 +146,7 @@ class Importer:
try:
from authentik.enterprise.license import LicenseKey
context["goauthentik.io/enterprise/licensed"] = (
LicenseKey.get_total().status().is_valid,
)
context["goauthentik.io/enterprise/licensed"] = LicenseKey.get_total().status().is_valid
except ModuleNotFoundError:
pass
return context

View File

@@ -64,6 +64,7 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"default_application",
"web_certificate",
"client_certificates",
@@ -117,6 +118,7 @@ class CurrentBrandSerializer(PassiveSerializer):
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
default_locale = CharField(read_only=True)
flags = SerializerMethodField()
@@ -154,6 +156,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"web_certificate",
"client_certificates",
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0011_alter_brand_branding_default_flow_background_and_more"),
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.AddField(
model_name="brand",
name="flow_lockdown",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_lockdown",
to="authentik_flows.flow",
),
),
]

View File

@@ -58,6 +58,9 @@ class Brand(SerializerModel):
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
flow_lockdown = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
)
default_application = models.ForeignKey(
"authentik_core.Application",

View File

@@ -20,11 +20,16 @@ class TestBrands(APITestCase):
def setUp(self):
super().setUp()
self.default_flags = {}
for flag in Flag.available(visibility="public"):
self.default_flags[flag().key] = flag.get()
Brand.objects.all().delete()
@property
def default_flags(self) -> dict[str, object]:
"""Get current public flags.
Some tests define temporary Flag subclasses, so this can't be cached in setUp.
"""
return {flag().key: flag.get() for flag in Flag.available(visibility="public")}
def test_current_brand(self):
"""Test Current brand API"""
brand = create_test_brand()

View File

@@ -47,7 +47,8 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
search_fields = [
"pbm_uuid",
"name",
"app",
"app__name",
"app__slug",
"attributes",
]
filterset_fields = [

View File

@@ -32,19 +32,19 @@ from authentik.rbac.decorators import permission_required
class UserAgentDeviceDict(TypedDict):
"""User agent device"""
brand: str
brand: str | None = None
family: str
model: str
model: str | None = None
class UserAgentOSDict(TypedDict):
"""User agent os"""
family: str
major: str
minor: str
patch: str
patch_minor: str
major: str | None = None
minor: str | None = None
patch: str | None = None
patch_minor: str | None = None
class UserAgentBrowserDict(TypedDict):

View File

@@ -563,6 +563,9 @@ class UsersFilter(FilterSet):
class UserViewSet(
ConditionalInheritance(
"authentik.enterprise.stages.account_lockdown.api.UserAccountLockdownMixin"
),
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
UsedByMixin,
ModelViewSet,

View File

@@ -1,6 +1,5 @@
"""authentik core signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_in
from django.core.cache import cache
@@ -59,7 +58,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
if device_cookie:
async_to_sync(layer.group_send)(
layer.group_send_blocking(
build_device_group(device_cookie),
{"type": "event.session.authenticated"},
)

View File

@@ -12,7 +12,7 @@
{% block head %}
<style data-id="static-styles">
:root {
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url|iriencode|safe }}");
}
</style>

View File

@@ -1,7 +1,6 @@
from datetime import datetime
from django.db.models import BooleanField as ModelBooleanField
from django.db.models import Case, Q, Value, When
from django.db.models import Exists, OuterRef, Q, Subquery
from django_filters.rest_framework import BooleanFilter, FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
@@ -14,7 +13,7 @@ from rest_framework.viewsets import GenericViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.lifecycle.api.reviews import ReviewSerializer
from authentik.enterprise.lifecycle.models import LifecycleIteration, ReviewState
from authentik.enterprise.lifecycle.models import LifecycleIteration, LifecycleRule, ReviewState
from authentik.enterprise.lifecycle.utils import (
ContentTypeField,
ReviewerGroupSerializer,
@@ -26,20 +25,25 @@ from authentik.enterprise.lifecycle.utils import (
from authentik.lib.utils.time import timedelta_from_string
class RelatedRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
reviewer_groups = ReviewerGroupSerializer(many=True, read_only=True)
min_reviewers = IntegerField(read_only=True)
reviewers = ReviewerUserSerializer(many=True, read_only=True)
class Meta:
model = LifecycleRule
fields = ["id", "name", "reviewer_groups", "min_reviewers", "reviewers"]
class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
content_type = ContentTypeField()
object_verbose = SerializerMethodField()
rule = RelatedRuleSerializer(read_only=True)
object_admin_url = SerializerMethodField(read_only=True)
grace_period_end = SerializerMethodField(read_only=True)
reviews = ReviewSerializer(many=True, read_only=True, source="review_set.all")
user_can_review = SerializerMethodField(read_only=True)
reviewer_groups = ReviewerGroupSerializer(
many=True, read_only=True, source="rule.reviewer_groups"
)
min_reviewers = IntegerField(read_only=True, source="rule.min_reviewers")
reviewers = ReviewerUserSerializer(many=True, read_only=True, source="rule.reviewers")
next_review_date = SerializerMethodField(read_only=True)
class Meta:
@@ -55,10 +59,8 @@ class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
"grace_period_end",
"next_review_date",
"reviews",
"rule",
"user_can_review",
"reviewer_groups",
"min_reviewers",
"reviewers",
]
read_only_fields = fields
@@ -88,43 +90,55 @@ class IterationViewSet(EnterpriseRequiredMixin, CreateModelMixin, GenericViewSet
queryset = LifecycleIteration.objects.all()
serializer_class = LifecycleIterationSerializer
ordering = ["-opened_on"]
ordering_fields = ["state", "content_type__model", "opened_on", "grace_period_end"]
ordering_fields = [
"state",
"content_type__model",
"rule__name",
"opened_on",
"grace_period_end",
]
filterset_class = LifecycleIterationFilterSet
def get_queryset(self):
user = self.request.user
return self.queryset.annotate(
user_is_reviewer=Case(
When(
Q(rule__reviewers=user)
| Q(rule__reviewer_groups__in=user.groups.all().with_ancestors()),
then=Value(True),
),
default=Value(False),
output_field=ModelBooleanField(),
user_is_reviewer=Exists(
LifecycleRule.objects.filter(
pk=OuterRef("rule_id"),
).filter(
Q(reviewers=user) | Q(reviewer_groups__in=user.groups.all().with_ancestors())
)
)
).distinct()
)
@extend_schema(
operation_id="lifecycle_iterations_list_latest",
responses={200: LifecycleIterationSerializer(many=True)},
)
@action(
detail=False,
pagination_class=None,
methods=["get"],
url_path=r"latest/(?P<content_type>[^/]+)/(?P<object_id>[^/]+)",
)
def latest_iteration(self, request: Request, content_type: str, object_id: str) -> Response:
def latest_iterations(self, request: Request, content_type: str, object_id: str) -> Response:
ct = parse_content_type(content_type)
try:
obj = (
self.get_queryset()
.filter(
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
)
.latest("opened_on")
latest_ids_subquery = (
LifecycleIteration.objects.filter(
rule=OuterRef("rule"),
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
)
except LifecycleIteration.DoesNotExist:
return Response(status=404)
serializer = self.get_serializer(obj)
.order_by("-opened_on")
.values("id")[:1]
)
latest_per_rule = LifecycleIteration.objects.filter(
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
).filter(id=Subquery(latest_ids_subquery))
serializer = self.get_serializer(latest_per_rule, many=True)
return Response(serializer.data)
@extend_schema(

View File

@@ -84,23 +84,6 @@ class LifecycleRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
raise ValidationError(
{"grace_period": _("Grace period must be shorter than the interval.")}
)
if "content_type" in attrs or "object_id" in attrs:
content_type = attrs.get("content_type", getattr(self.instance, "content_type", None))
object_id = attrs.get("object_id", getattr(self.instance, "object_id", None))
if content_type is not None and object_id is None:
existing = LifecycleRule.objects.filter(
content_type=content_type, object_id__isnull=True
)
if self.instance:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise ValidationError(
{
"content_type": _(
"Only one type-wide rule for each object type is allowed."
)
}
)
return attrs

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.11 on 2026-03-05 11:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_lifecycle", "0002_alter_lifecycleiteration_opened_on"),
]
operations = [
migrations.RemoveConstraint(
model_name="lifecyclerule",
name="uniq_lifecycle_rule_ct_null_object",
),
migrations.AlterUniqueTogether(
name="lifecyclerule",
unique_together=set(),
),
]

View File

@@ -56,14 +56,6 @@ class LifecycleRule(SerializerModel):
class Meta:
indexes = [models.Index(fields=["content_type"])]
unique_together = [["content_type", "object_id"]]
constraints = [
models.UniqueConstraint(
fields=["content_type"],
condition=Q(object_id__isnull=True),
name="uniq_lifecycle_rule_ct_null_object",
)
]
@property
def serializer(self) -> type[BaseSerializer]:
@@ -82,12 +74,6 @@ class LifecycleRule(SerializerModel):
qs = self.content_type.get_all_objects_for_this_type()
if self.object_id:
qs = qs.filter(pk=self.object_id)
else:
qs = qs.exclude(
pk__in=LifecycleRule.objects.filter(
content_type=self.content_type, object_id__isnull=False
).values_list(Cast("object_id", output_field=self._get_pk_field()), flat=True)
)
return qs
def _get_stale_iterations(self) -> QuerySet[LifecycleIteration]:
@@ -107,8 +93,7 @@ class LifecycleRule(SerializerModel):
def _get_newly_due_objects(self) -> QuerySet:
recent_iteration_ids = LifecycleIteration.objects.filter(
content_type=self.content_type,
object_id__isnull=False,
rule=self,
opened_on__gte=start_of_day(
timezone.now() + timedelta(days=1) - timedelta_from_string(self.interval)
),
@@ -214,9 +199,15 @@ class LifecycleIteration(SerializerModel, ManagedModel):
}
def initialize(self):
if (self.content_type.app_label, self.content_type.model) == ("authentik_core", "group"):
object_label = self.object.name
elif (self.content_type.app_label, self.content_type.model) == ("authentik_rbac", "role"):
object_label = self.object.name
else:
object_label = str(self.object)
event = Event.new(
EventAction.REVIEW_INITIATED,
message=_(f"Access review is due for {self.content_type.name} {str(self.object)}"),
message=_(f"Access review is due for {self.content_type.name.lower()} {object_label}"),
**self._get_event_args(),
)
event.save()

View File

@@ -3,6 +3,7 @@ from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from authentik.enterprise.lifecycle.models import LifecycleRule, ReviewState
from authentik.tasks.schedules.models import Schedule
@receiver(post_save, sender=LifecycleRule)
@@ -11,7 +12,9 @@ def post_rule_save(sender, instance: LifecycleRule, created: bool, **_):
apply_lifecycle_rule.send_with_options(
args=(instance.id,),
rel_obj=instance,
rel_obj=Schedule.objects.get(
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
),
)

View File

@@ -4,14 +4,17 @@ from dramatiq import actor
from authentik.core.models import User
from authentik.enterprise.lifecycle.models import LifecycleRule
from authentik.events.models import Event, Notification, NotificationTransport
from authentik.tasks.schedules.models import Schedule
@actor(description=_("Dispatch tasks to validate lifecycle rules."))
@actor(description=_("Dispatch tasks to apply lifecycle rules."))
def apply_lifecycle_rules():
for rule in LifecycleRule.objects.all():
apply_lifecycle_rule.send_with_options(
args=(rule.id,),
rel_obj=rule,
rel_obj=Schedule.objects.get(
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
),
)

View File

@@ -1,3 +1,4 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -19,6 +20,11 @@ class TestLifecycleRuleAPI(APITestCase):
self.content_type = ContentType.objects.get_for_model(Application)
self.reviewer_group = Group.objects.create(name=generate_id())
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_list_rules(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
@@ -190,6 +196,11 @@ class TestIterationAPI(APITestCase):
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_open_iterations(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
@@ -231,7 +242,7 @@ class TestIterationAPI(APITestCase):
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iteration",
"authentik_api:lifecycleiteration-latest-iterations",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
@@ -239,19 +250,20 @@ class TestIterationAPI(APITestCase):
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["object_id"], str(self.app.pk))
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["object_id"], str(self.app.pk))
def test_latest_iteration_not_found(self):
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iteration",
"authentik_api:lifecycleiteration-latest-iterations",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": "00000000-0000-0000-0000-000000000000",
},
)
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data, [])
def test_iteration_includes_user_can_review(self):
rule = LifecycleRule.objects.create(
@@ -279,6 +291,11 @@ class TestReviewAPI(APITestCase):
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def test_create_review(self):
rule = LifecycleRule.objects.create(
name=generate_id(),

View File

@@ -2,6 +2,7 @@ import datetime as dt
from datetime import timedelta
from unittest.mock import patch
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.utils import timezone
@@ -29,6 +30,11 @@ class TestLifecycleModels(TestCase):
def setUp(self):
self.factory = RequestFactory()
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def _get_request(self):
return self.factory.get("/")
@@ -438,31 +444,6 @@ class TestLifecycleModels(TestCase):
self.assertIn(app_one, objects)
self.assertIn(app_two, objects)
def test_rule_type_excludes_objects_with_specific_rules(self):
app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
# Create a specific rule for app_with_rule
LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_with_rule.pk),
interval="days=30",
)
# Create a type-level rule
type_rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
interval="days=60",
)
objects = list(type_rule.get_objects())
self.assertNotIn(app_with_rule, objects)
self.assertIn(app_without_rule, objects)
def test_rule_type_apply_creates_iterations_for_all_objects(self):
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
@@ -669,6 +650,73 @@ class TestLifecycleModels(TestCase):
self.assertIn(explicit_reviewer, reviewers)
self.assertIn(group_member, reviewers)
def test_multiple_rules_same_object_create_separate_iterations(self):
"""Two rules targeting the same object each create their own iteration."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
rule_one = self._create_rule_for_object(obj, interval="days=30", grace_period="days=10")
rule_two = self._create_rule_for_object(obj, interval="days=60", grace_period="days=20")
iterations = LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(obj.pk)
)
self.assertEqual(iterations.count(), 2)
iter_one = iterations.get(rule=rule_one)
iter_two = iterations.get(rule=rule_two)
self.assertEqual(iter_one.state, ReviewState.PENDING)
self.assertEqual(iter_two.state, ReviewState.PENDING)
self.assertNotEqual(iter_one.pk, iter_two.pk)
def test_multiple_rules_same_object_reviewed_independently(self):
"""Reviewing one rule's iteration does not affect the other rule's iteration."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
reviewer = create_test_user()
rule_one = self._create_rule_for_object(obj, min_reviewers=1)
rule_two = self._create_rule_for_object(obj, min_reviewers=1)
group = Group.objects.create(name=generate_id())
group.users.add(reviewer)
rule_one.reviewer_groups.add(group)
rule_two.reviewer_groups.add(group)
iter_one = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule_one
)
iter_two = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule_two
)
request = self._get_request()
# Review only rule_one's iteration
Review.objects.create(iteration=iter_one, reviewer=reviewer)
iter_one.on_review(request)
iter_one.refresh_from_db()
iter_two.refresh_from_db()
self.assertEqual(iter_one.state, ReviewState.REVIEWED)
self.assertEqual(iter_two.state, ReviewState.PENDING)
def test_type_rule_and_object_rule_both_create_iterations(self):
"""A type-level rule and an object-level rule both create iterations for the same object."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(obj)
object_rule = self._create_rule_for_object(obj, interval="days=30")
type_rule = self._create_rule_for_type(Application, interval="days=60")
iterations = LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(obj.pk)
)
self.assertEqual(iterations.count(), 2)
self.assertTrue(iterations.filter(rule=object_rule).exists())
self.assertTrue(iterations.filter(rule=type_rule).exists())
class TestLifecycleDateBoundaries(TestCase):
"""Verify that start_of_day normalization ensures correct overdue/due
@@ -679,6 +727,11 @@ class TestLifecycleDateBoundaries(TestCase):
ensures that the boundary is always at midnight, so millisecond variations
in task execution time do not affect results."""
@classmethod
def setUpTestData(cls):
config = apps.get_app_config("authentik_tasks_schedules")
config._on_startup_callback(None)
def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
app = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.2.12 on 2026-04-04 16:58
from django.db import migrations, models
import django.contrib.postgres.fields
class Migration(migrations.Migration):
@@ -40,4 +41,109 @@ class Migration(migrations.Migration):
]
),
),
migrations.AlterField(
model_name="stream",
name="events_requested",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(
choices=[
(
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"Caep Session Revoked",
),
(
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
"Caep Token Claims Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
"Caep Credential Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
"Caep Assurance Level Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
"Caep Device Compliance Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-established",
"Caep Session Established",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
"Caep Session Presented",
),
(
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
"Caep Risk Level Change",
),
(
"https://schemas.openid.net/secevent/ssf/event-type/verification",
"Set Verification",
),
]
),
default=list,
size=None,
),
),
migrations.AlterField(
model_name="stream",
name="status",
field=models.TextField(
choices=[
("enabled", "Enabled"),
("paused", "Paused"),
("disabled", "Disabled"),
("disabled_deleted", "Disabled Deleted"),
],
default="enabled",
),
),
migrations.AlterField(
model_name="streamevent",
name="type",
field=models.TextField(
choices=[
(
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"Caep Session Revoked",
),
(
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
"Caep Token Claims Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
"Caep Credential Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
"Caep Assurance Level Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
"Caep Device Compliance Change",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-established",
"Caep Session Established",
),
(
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
"Caep Session Presented",
),
(
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
"Caep Risk Level Change",
),
(
"https://schemas.openid.net/secevent/ssf/event-type/verification",
"Set Verification",
),
]
),
),
]

View File

@@ -24,8 +24,31 @@ class EventTypes(models.TextChoices):
"""SSF Event types supported by authentik"""
CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.1"""
CAEP_TOKEN_CLAIMS_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.2"""
CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.3"""
CAEP_ASSURANCE_LEVEL_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.4"""
CAEP_DEVICE_COMPLIANCE_CHANGE = (
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.5"""
CAEP_SESSION_ESTABLISHED = (
"https://schemas.openid.net/secevent/caep/event-type/session-established"
)
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.6"""
CAEP_SESSION_PRESENTED = "https://schemas.openid.net/secevent/caep/event-type/session-presented"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.7"""
CAEP_RISK_LEVEL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change"
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.8"""
SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification"
"""https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-8.1.4.1"""
class DeliveryMethods(models.TextChoices):
@@ -46,10 +69,12 @@ class SSFEventStatus(models.TextChoices):
class StreamStatus(models.TextChoices):
"""SSF Stream status"""
ENABLED = "enabled"
PAUSED = "paused"
DISABLED = "disabled"
DISABLED_DELETED = "disabled_deleted"
class SSFProvider(TasksModel, BackchannelProvider):

View File

@@ -108,13 +108,13 @@ def send_ssf_event(stream_uuid: UUID, event_data: dict[str, Any]):
event.save()
self.info("Event successfully sent", status=response.status_code)
# Cleanup, if we were the last pending message for this stream and it has been deleted
# (status=StreamStatus.DISABLED), then we can delete the stream
# (status=StreamStatus.DISABLED_DELETED), then we can delete the stream
if (
not StreamEvent.objects.filter(
stream=stream,
status__in=[SSFEventStatus.PENDING_FAILED, SSFEventStatus.PENDING_NEW],
).exists()
and stream.status == StreamStatus.DISABLED
and stream.status == StreamStatus.DISABLED_DELETED
):
LOGGER.info(
"Deleting inactive stream as all pending messages were sent.", stream=stream

View File

@@ -62,7 +62,7 @@ class TestSSFAuth(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_stream_add_oidc(self):
@@ -115,7 +115,7 @@ class TestSSFAuth(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_token_invalid(self):

View File

@@ -54,7 +54,7 @@ class TestStream(APITestCase):
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
self.assertEqual(
event.payload["events"],
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
)
def test_stream_add_poll(self):
@@ -96,7 +96,7 @@ class TestStream(APITestCase):
)
self.assertEqual(res.status_code, 204)
stream.refresh_from_db()
self.assertEqual(stream.status, StreamStatus.DISABLED)
self.assertEqual(stream.status, StreamStatus.DISABLED_DELETED)
def test_stream_get(self):
"""get stream"""
@@ -225,3 +225,26 @@ class TestStream(APITestCase):
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
)
self.assertEqual(res.status_code, 404)
def test_stream_status_update(self):
stream = Stream.objects.create(provider=self.provider)
res = self.client.post(
reverse(
"authentik_providers_ssf:stream-status",
kwargs={"application_slug": self.application.slug},
),
data={
"stream_id": str(stream.pk),
"status": StreamStatus.DISABLED,
},
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
)
self.assertEqual(res.status_code, 200)
stream.refresh_from_db()
self.assertJSONEqual(
res.content,
{
"stream_id": str(stream.pk),
"status": str(stream.status),
},
)

View File

@@ -33,7 +33,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -46,7 +46,7 @@ class TestTasks(APITestCase):
)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
def test_push_auth(self):
auth = generate_id()
@@ -58,7 +58,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -72,7 +72,7 @@ class TestTasks(APITestCase):
)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
def test_push_stream_disable(self):
auth = generate_id()
@@ -81,11 +81,11 @@ class TestTasks(APITestCase):
delivery_method=DeliveryMethods.RFC_PUSH,
endpoint_url="http://localhost/ssf-push",
authorization_header=auth,
status=StreamStatus.DISABLED,
status=StreamStatus.DISABLED_DELETED,
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:
@@ -95,7 +95,7 @@ class TestTasks(APITestCase):
).get_result(block=True, timeout=1)
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
self.assertFalse(Stream.objects.filter(pk=stream.pk).exists())
def test_push_error(self):
@@ -106,7 +106,7 @@ class TestTasks(APITestCase):
)
event_data = stream.prepare_event_payload(
EventTypes.SET_VERIFICATION,
{"state": None},
{},
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
with Mocker() as mocker:

View File

@@ -24,10 +24,10 @@ class SSFView(APIView):
class SSFStreamView(SSFView):
def get_object(self, any_status=False) -> Stream:
streams = Stream.objects.filter(provider=self.provider)
if not any_status:
streams = streams.filter(status__in=[StreamStatus.ENABLED, StreamStatus.PAUSED])
def get_object(self) -> Stream:
streams = Stream.objects.filter(provider=self.provider).exclude(
status=StreamStatus.DISABLED_DELETED
)
if "stream_id" in self.request.query_params:
streams = streams.filter(pk=self.request.query_params["stream_id"])
if "stream_id" in self.request.data:

View File

@@ -1,6 +1,6 @@
from uuid import uuid4
from django.http import HttpRequest
from django.http import Http404, HttpRequest
from django.urls import reverse
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
@@ -106,7 +106,11 @@ class StreamResponseSerializer(PassiveSerializer):
}
def get_events_supported(self, instance: Stream) -> list[str]:
return [x.value for x in EventTypes]
return [
EventTypes.CAEP_SESSION_REVOKED,
EventTypes.CAEP_CREDENTIAL_CHANGE,
EventTypes.SET_VERIFICATION,
]
class StreamView(SSFStreamView):
@@ -128,10 +132,9 @@ class StreamView(SSFStreamView):
LOGGER.info("Sending verification event", stream=instance)
send_ssf_events(
EventTypes.SET_VERIFICATION,
{
"state": None,
},
{},
stream_filter={"pk": instance.uuid},
request=request,
sub_id={"format": "opaque", "id": str(instance.uuid)},
)
response = StreamResponseSerializer(instance=instance, context={"request": request}).data
@@ -159,7 +162,9 @@ class StreamView(SSFStreamView):
def delete(self, request: Request, *args, **kwargs) -> Response:
stream = self.get_object()
stream.status = StreamStatus.DISABLED
if stream.status == StreamStatus.DISABLED_DELETED:
raise Http404
stream.status = StreamStatus.DISABLED_DELETED
stream.save()
return Response(status=204)
@@ -175,6 +180,7 @@ class StreamVerifyView(SSFStreamView):
"state": state,
},
stream_filter={"pk": stream.uuid},
request=request,
sub_id={"format": "opaque", "id": str(stream.uuid)},
)
return Response(status=204)
@@ -182,8 +188,25 @@ class StreamVerifyView(SSFStreamView):
class StreamStatusView(SSFStreamView):
class StreamStatusSerializer(PassiveSerializer):
stream_id = CharField()
status = ChoiceField(choices=StreamStatus.choices)
def get(self, request: Request, *args, **kwargs):
stream = self.get_object(any_status=True)
stream = self.get_object()
return Response(
{
"stream_id": str(stream.pk),
"status": str(stream.status),
}
)
def post(self, request: Request, *args, **kwargs):
stream = self.get_object()
serializer = self.StreamStatusSerializer(stream, data=request.data)
serializer.is_valid(raise_exception=True)
stream.status = serializer.validated_data["status"]
stream.save()
return Response(
{
"stream_id": str(stream.pk),

View File

@@ -14,6 +14,7 @@ TENANT_APPS = [
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",

View File

@@ -0,0 +1,141 @@
"""Account Lockdown Stage API Views"""
from django.utils.translation import gettext as _
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import PrimaryKeyRelatedField
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer
from authentik.core.models import (
User,
)
from authentik.enterprise.api import EnterpriseRequiredMixin, enterprise_action
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
can_lock_user,
get_lockdown_target_users,
)
from authentik.flows.api.stages import StageSerializer
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
LOGGER = get_logger()
class AccountLockdownStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""AccountLockdownStage Serializer"""
class Meta:
model = AccountLockdownStage
fields = StageSerializer.Meta.fields + [
"deactivate_user",
"set_unusable_password",
"delete_sessions",
"revoke_tokens",
"self_service_completion_flow",
]
class AccountLockdownStageViewSet(UsedByMixin, ModelViewSet):
"""AccountLockdownStage Viewset"""
queryset = AccountLockdownStage.objects.all()
serializer_class = AccountLockdownStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
class UserAccountLockdownSerializer(PassiveSerializer):
"""Choose the target account before starting the lockdown flow."""
user = PrimaryKeyRelatedField(
queryset=get_lockdown_target_users(),
required=False,
allow_null=True,
help_text=_("User to lock. If omitted, locks the current user (self-service)."),
)
class UserAccountLockdownMixin:
"""Enterprise account-lockdown API actions for UserViewSet."""
def _create_lockdown_flow_url(self, request: Request, user: User) -> str:
"""Create a flow URL for account lockdown.
The request body selects the target before the flow starts. The API
pre-plans the lockdown flow with the target as the pending user, so the
account lockdown stage can use the normal flow context.
"""
flow = request._request.brand.flow_lockdown
if flow is None:
raise ValidationError({"non_field_errors": [_("No lockdown flow configured.")]})
planner = FlowPlanner(flow)
planner.use_cache = False
try:
plan = planner.plan(request._request, {PLAN_CONTEXT_PENDING_USER: user})
except EmptyFlowException, FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": [_("Lockdown flow is not applicable.")]}
) from None
return plan.to_redirect(request._request, flow).url
@extend_schema(
description=_("Choose the target account, then return a flow link."),
request=UserAccountLockdownSerializer,
responses={
"200": OpenApiResponse(
response=LinkSerializer,
examples=[
OpenApiExample(
"Lockdown flow URL",
value={
"link": "https://example.invalid/if/flow/default-account-lockdown/",
},
response_only=True,
status_codes=["200"],
)
],
),
"400": OpenApiResponse(
description=_("No lockdown flow configured or the flow is not applicable")
),
"403": OpenApiResponse(
description=_("Permission denied (when targeting another user)")
),
},
)
@action(
detail=False,
methods=["POST"],
permission_classes=[IsAuthenticated],
url_path="account_lockdown",
)
@validate(UserAccountLockdownSerializer)
@enterprise_action
def account_lockdown(self, request: Request, body: UserAccountLockdownSerializer) -> Response:
"""Trigger account lockdown for a user.
If no user is specified, locks the current user (self-service).
When targeting another user, admin permissions are required.
Returns a flow link for the frontend to follow. The flow is pre-planned
with the target user as pending user for the lockdown stage.
"""
user = body.validated_data.get("user") or request.user
if not can_lock_user(request.user, user):
LOGGER.debug("Permission denied for account lockdown", user=request.user)
self.permission_denied(request)
flow_url = self._create_lockdown_flow_url(request, user)
LOGGER.debug("Returning lockdown flow URL", flow_url=flow_url, user=user.username)
return Response({"link": flow_url})

View File

@@ -0,0 +1,12 @@
"""authentik account lockdown stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageAccountLockdownConfig(EnterpriseConfig):
"""authentik account lockdown stage config"""
name = "authentik.enterprise.stages.account_lockdown"
label = "authentik_stages_account_lockdown"
verbose_name = "authentik Enterprise.Stages.Account Lockdown"
default = True

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.13 on 2026-04-19 21:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.CreateModel(
name="AccountLockdownStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"deactivate_user",
models.BooleanField(
default=True,
help_text="Deactivate the user account (set is_active to False)",
),
),
(
"set_unusable_password",
models.BooleanField(
default=True, help_text="Set an unusable password for the user"
),
),
(
"delete_sessions",
models.BooleanField(
default=True, help_text="Delete all active sessions for the user"
),
),
(
"revoke_tokens",
models.BooleanField(
default=True,
help_text="Revoke all tokens for the user (API, app password, recovery, verification, OAuth)",
),
),
(
"self_service_completion_flow",
models.ForeignKey(
blank=True,
help_text="Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="account_lockdown_stages",
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Account Lockdown Stage",
"verbose_name_plural": "Account Lockdown Stages",
},
bases=("authentik_flows.stage",),
),
]

View File

@@ -0,0 +1,62 @@
"""Account lockdown stage models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import Stage
class AccountLockdownStage(Stage):
"""Lock down a target user account."""
deactivate_user = models.BooleanField(
default=True,
help_text=_("Deactivate the user account (set is_active to False)"),
)
set_unusable_password = models.BooleanField(
default=True,
help_text=_("Set an unusable password for the user"),
)
delete_sessions = models.BooleanField(
default=True,
help_text=_("Delete all active sessions for the user"),
)
revoke_tokens = models.BooleanField(
default=True,
help_text=_(
"Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
),
)
self_service_completion_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="account_lockdown_stages",
help_text=_(
"Flow to redirect users to after self-service lockdown. "
"This flow should not require authentication since the user's session is deleted."
),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageSerializer
return AccountLockdownStageSerializer
@property
def view(self) -> type[View]:
from authentik.enterprise.stages.account_lockdown.stage import AccountLockdownStageView
return AccountLockdownStageView
@property
def component(self) -> str:
return "ak-stage-account-lockdown-form"
class Meta:
verbose_name = _("Account Lockdown Stage")
verbose_name_plural = _("Account Lockdown Stages")

View File

@@ -0,0 +1,345 @@
"""Account lockdown stage logic"""
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
from django.db.models import Model, QuerySet
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dramatiq.actor import Actor
from dramatiq.composition import group
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
Session,
Token,
User,
UserTypes,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.events.models import Event, EventAction
from authentik.flows.stage import StageView
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
from authentik.lib.utils.reflection import class_to_path
from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
PLAN_CONTEXT_LOCKDOWN_REASON = "lockdown_reason"
LOCKDOWN_EVENT_ACTION_ID = "account_lockdown"
TARGET_REQUIRED_MESSAGE = _("No target user specified for account lockdown")
PERMISSION_DENIED_MESSAGE = _("You do not have permission to lock down this account.")
ACCOUNT_LOCKDOWN_FAILED_MESSAGE = _("Account lockdown failed for this account.")
SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE = _(
"Self-service account lockdown requires a completion flow."
)
def get_lockdown_target_users() -> QuerySet[User]:
"""Return users that can be targeted by account lockdown."""
return User.objects.exclude_anonymous().exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
def _get_model_field(model: type[Model], field_name: str):
"""Get a model field by name, if present."""
try:
return model._meta.get_field(field_name)
except FieldDoesNotExist:
return None
def _has_user_field(model: type[Model]) -> bool:
"""Check if a model has a direct user foreign key."""
field = _get_model_field(model, "user")
return bool(field and getattr(field, "remote_field", None) and field.remote_field.model is User)
def _has_authenticated_session_field(model: type[Model]) -> bool:
"""Check if a model is linked to an authenticated session."""
field = _get_model_field(model, "session")
return bool(
field
and getattr(field, "remote_field", None)
and field.remote_field.model is AuthenticatedSession
)
def _has_provider_field(model: type[Model]) -> bool:
"""Check if a model is linked to a provider."""
return _get_model_field(model, "provider") is not None
def get_lockdown_token_models() -> tuple[type[Model], ...]:
"""Return token, grant, and provider session models removed by account lockdown."""
token_models: list[type[Model]] = []
for model in apps.get_models():
if model._meta.abstract or not issubclass(model, ExpiringModel):
continue
if model is Token:
token_models.append(model)
elif _has_user_field(model) and (
_has_provider_field(model) or _has_authenticated_session_field(model)
):
token_models.append(model)
elif _has_authenticated_session_field(model):
token_models.append(model)
return tuple(token_models)
def get_lockdown_token_queryset(model: type[Model], user: User) -> QuerySet:
"""Return account lockdown artifacts for a model and user."""
manager = model.objects.including_expired()
if _has_user_field(model):
return manager.filter(user=user)
return manager.filter(session__user=user)
def can_lock_user(actor, user: User) -> bool:
"""Check whether the actor may lock the target user."""
if not actor.is_authenticated:
return False
if user.pk == actor.pk:
return True
return actor.has_perm("authentik_core.change_user", user)
def get_outgoing_sync_tasks() -> tuple[tuple[type[OutgoingSyncProvider], Actor], ...]:
"""Return outgoing sync provider types and their direct sync tasks."""
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync_direct
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync_direct
from authentik.providers.scim.models import SCIMProvider
from authentik.providers.scim.tasks import scim_sync_direct
return (
(SCIMProvider, scim_sync_direct),
(GoogleWorkspaceProvider, google_workspace_sync_direct),
(MicrosoftEntraProvider, microsoft_entra_sync_direct),
)
class AccountLockdownStageView(StageView):
"""Execute account lockdown actions on the target user."""
def is_self_service(self, request: HttpRequest, user: User) -> bool:
"""Check whether the currently authenticated user is locking their own account."""
return request.user.is_authenticated and user.pk == request.user.pk
def get_reason(self) -> str:
"""Get the lockdown reason from the plan context.
Priority:
1. prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
2. PLAN_CONTEXT_LOCKDOWN_REASON (explicitly set)
3. Empty string as fallback
"""
prompt_data = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
if PLAN_CONTEXT_LOCKDOWN_REASON in prompt_data:
return prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
return self.executor.plan.context.get(PLAN_CONTEXT_LOCKDOWN_REASON, "")
def _apply_lockdown_actions(self, stage: AccountLockdownStage, user: User) -> None:
"""Apply the configured account changes to the target user."""
if stage.deactivate_user:
user.is_active = False
if stage.set_unusable_password:
user.set_unusable_password()
if stage.deactivate_user:
with sync_outgoing_inhibit_dispatch():
user.save()
return
user.save()
def _sync_deactivated_user_to_outgoing_providers(self, user: User) -> None:
"""Synchronize a deactivated user to outgoing sync providers."""
messages = []
wait_timeout = 0
model = class_to_path(User)
provider_filter = Q(backchannel_application__isnull=False) | Q(application__isnull=False)
for provider_model, task_sync_direct in get_outgoing_sync_tasks():
for provider in provider_model.objects.filter(provider_filter):
time_limit = int(
timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
)
messages.append(
task_sync_direct.message_with_options(
args=(model, user.pk, provider.pk),
rel_obj=provider,
time_limit=time_limit,
uid=f"{provider.name}:user:{user.pk}:direct",
)
)
wait_timeout += time_limit
if not messages:
return
try:
group(messages).run().wait(timeout=wait_timeout)
except ResultTimeout:
self.logger.warning(
"Timed out waiting for outgoing sync tasks; tasks remain queued",
user=user.username,
timeout=wait_timeout,
)
def _get_lockdown_artifact_querysets(
self, stage: AccountLockdownStage, user: User
) -> tuple[QuerySet, ...]:
"""Return the configured sessions and tokens targeted by lockdown."""
querysets: list[QuerySet] = []
if stage.delete_sessions:
querysets.append(Session.objects.filter(authenticatedsession__user=user))
if stage.revoke_tokens:
querysets.extend(
get_lockdown_token_queryset(model, user) for model in get_lockdown_token_models()
)
return tuple(querysets)
def _delete_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> None:
"""Delete sessions and tokens selected by the lockdown configuration."""
for queryset in self._get_lockdown_artifact_querysets(stage, user):
queryset.delete()
def _has_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> bool:
"""Check whether there are still sessions or tokens to remove."""
return any(
queryset.exists() for queryset in self._get_lockdown_artifact_querysets(stage, user)
)
def _emit_lockdown_event(self, request: HttpRequest, user: User, reason: str) -> None:
"""Emit the audit event for a completed lockdown."""
# Emit the audit event after the transaction commits. If event creation
# fails here, dispatch() would otherwise treat the whole lockdown as
# failed even though the account changes have already been committed.
try:
Event.new(
EventAction.USER_WRITE,
action_id=LOCKDOWN_EVENT_ACTION_ID,
reason=reason,
affected_user=user.username,
).from_http(request)
except Exception as exc: # noqa: BLE001
# Event emission should not make the lockdown itself fail.
self.logger.warning(
"Failed to emit account lockdown event",
user=user.username,
exc=exc,
)
def _lockdown_user(
self,
request: HttpRequest,
stage: AccountLockdownStage,
user: User,
reason: str,
) -> None:
"""Execute lockdown actions on a single user."""
with atomic():
user = User.objects.get(pk=user.pk)
self._apply_lockdown_actions(stage, user)
self._delete_lockdown_artifacts(stage, user)
# These additional checks/deletes are done to prevent a timing attack that creates tokens
# with a compromised token that is simultaneously being deleted.
while self._has_lockdown_artifacts(stage, user):
with atomic():
self._delete_lockdown_artifacts(stage, user)
if stage.deactivate_user:
try:
self._sync_deactivated_user_to_outgoing_providers(user)
except Exception as exc: # noqa: BLE001
# Local lockdown has already committed. Provider sync failures
# must not reopen access or mark the lockdown itself as failed.
self.logger.warning(
"Failed to sync account lockdown deactivation to outgoing providers",
user=user.username,
exc=exc,
)
self._emit_lockdown_event(request, user, reason)
def dispatch(self, request: HttpRequest) -> HttpResponse:
"""Execute account lockdown actions."""
self.request = request
stage: AccountLockdownStage = self.executor.current_stage
pending_user = self.get_pending_user()
if not pending_user.is_authenticated:
self.logger.warning("No target user found for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
user = get_lockdown_target_users().filter(pk=pending_user.pk).first()
if user is None:
self.logger.warning("Target user is not eligible for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
if not can_lock_user(request.user, user):
self.logger.warning(
"Permission denied for account lockdown",
actor=getattr(request.user, "username", None),
target=user.username,
)
return self.executor.stage_invalid(PERMISSION_DENIED_MESSAGE)
reason = self.get_reason()
self_service = self.is_self_service(request, user)
if self_service and stage.delete_sessions and not stage.self_service_completion_flow:
self.logger.warning("No completion flow configured for self-service account lockdown")
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
self.logger.info(
"Executing account lockdown",
user=user.username,
reason=reason,
self_service=self_service,
deactivate_user=stage.deactivate_user,
set_unusable_password=stage.set_unusable_password,
delete_sessions=stage.delete_sessions,
revoke_tokens=stage.revoke_tokens,
)
try:
self._lockdown_user(request, stage, user, reason)
self.logger.info("Account lockdown completed", user=user.username)
except Exception as exc: # noqa: BLE001
# Convert unexpected lockdown errors to a flow-stage failure instead
# of leaking an exception through the flow executor.
self.logger.warning("Account lockdown failed", user=user.username, exc=exc)
return self.executor.stage_invalid(ACCOUNT_LOCKDOWN_FAILED_MESSAGE)
if self_service:
if stage.delete_sessions:
return self._self_service_completion_response(request)
return self.executor.stage_ok()
return self.executor.stage_ok()
def _self_service_completion_response(self, request: HttpRequest) -> HttpResponse:
"""Redirect to completion flow after self-service lockdown.
Since all sessions are deleted, the user cannot continue in the flow.
Redirect them to an unauthenticated completion flow that shows the
lockdown message.
We use a direct HTTP redirect instead of a challenge because the
flow executor's challenge handling may try to access the session
which we just deleted.
"""
stage: AccountLockdownStage = self.executor.current_stage
completion_flow = stage.self_service_completion_flow
if completion_flow:
# Flush the current request's session to prevent Django's session
# middleware from trying to save a deleted session
if hasattr(request, "session"):
request.session.flush()
redirect_to = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": completion_flow.slug},
)
return HttpResponseRedirect(redirect_to)
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)

View File

@@ -0,0 +1,148 @@
"""Test Users Account Lockdown API"""
from json import loads
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import (
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
# Patch for enterprise license check
patch_license = patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
MagicMock(return_value=True),
)
@patch_license
class AccountLockdownAPITestCase(APITestCase):
"""Shared helpers for account lockdown API tests."""
def setUp(self) -> None:
self.lockdown_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.lockdown_stage = AccountLockdownStage.objects.create(name=generate_id())
FlowStageBinding.objects.create(
target=self.lockdown_flow,
stage=self.lockdown_stage,
order=0,
)
self.brand = create_test_brand()
self.brand.flow_lockdown = self.lockdown_flow
self.brand.save()
def create_user_with_email(self):
"""Create a regular user with a unique email address."""
user = create_test_user()
user.email = f"{generate_id()}@test.com"
user.save()
return user
def assert_redirect_targets(self, response, user):
"""Assert that a response contains a pre-planned lockdown flow link for a user."""
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertIn(self.lockdown_flow.slug, body["link"])
self.assertEqual(urlparse(body["link"]).query, "")
plan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER].pk, user.pk)
def assert_no_flow_configured(self, response):
"""Assert that the API reports a missing lockdown flow."""
self.assertEqual(response.status_code, 400)
body = loads(response.content)
self.assertIn("No lockdown flow configured", body["non_field_errors"][0])
@patch_license
class TestUsersAccountLockdownAPI(AccountLockdownAPITestCase):
"""Test Users Account Lockdown API"""
def setUp(self) -> None:
super().setUp()
self.actor = create_test_user()
self.user = self.create_user_with_email()
def test_account_lockdown_with_change_user_returns_redirect(self):
"""Test that account lockdown allows users with change_user permission."""
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_no_flow_configured(self):
"""Test account lockdown when no flow is configured"""
self.brand.flow_lockdown = None
self.brand.save()
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_no_flow_configured(response)
def test_account_lockdown_unauthenticated(self):
"""Test account lockdown requires authentication"""
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_without_change_user_denied(self):
"""Test account lockdown denies users without change_user permission."""
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_self_returns_redirect(self):
"""Test successful self-service account lockdown returns a direct redirect."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_self_target_without_change_user_returns_redirect(self):
"""Test self-service does not require change_user permission."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)

View File

@@ -0,0 +1,46 @@
"""Tests for the packaged account-lockdown blueprint."""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.tasks import blueprints_find, check_blueprint_v1_file
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
BLUEPRINT_PATH = "example/flow-default-account-lockdown.yaml"
class TestAccountLockdownBlueprint(TransactionTestCase):
"""Test the packaged account-lockdown blueprint behavior."""
def test_blueprint_is_not_auto_instantiated(self):
"""Test the packaged blueprint is opt-in and skipped by discovery."""
BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).delete()
blueprint = next(item for item in blueprints_find() if item.path == BLUEPRINT_PATH)
check_blueprint_v1_file(blueprint)
self.assertFalse(BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).exists())
def test_blueprint_requires_licensed_context(self):
"""Test manual import only creates flows when enterprise is licensed."""
content = BlueprintInstance(path=BLUEPRINT_PATH).retrieve()
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": False})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": True})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown-complete").exists())

View File

@@ -0,0 +1,627 @@
"""Account lockdown stage tests"""
import json
from dataclasses import asdict
from threading import Event as ThreadEvent
from threading import Thread
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.db import connection
from django.http import HttpResponse
from django.test import TransactionTestCase
from django.urls import reverse
from django.utils import timezone
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import AuthenticatedSession, Session, Token, TokenIntents
from authentik.core.tests.utils import (
RequestFactory,
create_test_admin_user,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
LOCKDOWN_EVENT_ACTION_ID,
PLAN_CONTEXT_LOCKDOWN_REASON,
AccountLockdownStageView,
can_lock_user,
)
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.utils.reflection import class_to_path
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.saml.models import SAMLProvider, SAMLSession
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
patch_enterprise_enabled = patch(
"authentik.enterprise.apps.AuthentikEnterpriseConfig.check_enabled",
return_value=True,
)
class AccountLockdownStageTestMixin:
"""Shared setup helpers for account lockdown stage tests."""
@classmethod
def setUpClass(cls):
cls.patch_enterprise_enabled = patch_enterprise_enabled.start()
cls.patch_event_dispatch = patch("authentik.events.tasks.event_trigger_dispatch.send")
cls.patch_event_dispatch.start()
super().setUpClass()
@classmethod
def tearDownClass(cls):
cls.patch_event_dispatch.stop()
patch_enterprise_enabled.stop()
super().tearDownClass()
def setUp(self):
super().setUp()
self.user = create_test_admin_user()
self.target_user = create_test_admin_user()
self.flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage = AccountLockdownStage.objects.create(
name="lockdown",
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.request_factory = RequestFactory()
def make_stage_view(self, plan: FlowPlan):
def _stage_ok():
return HttpResponse(status=204)
def _stage_invalid(_error_message=None):
return HttpResponse(status=400)
return AccountLockdownStageView(
SimpleNamespace(
plan=plan,
current_stage=self.stage,
current_binding=self.binding,
flow=self.flow,
stage_ok=_stage_ok,
stage_invalid=_stage_invalid,
)
)
def make_request(self, *, user=None, query=None):
return self.request_factory.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
query_params=query or {},
user=user,
)
def get_lockdown_event(self):
"""Return the account-lockdown user-write event."""
return Event.objects.filter(
action=EventAction.USER_WRITE,
context__action_id=LOCKDOWN_EVENT_ACTION_ID,
).first()
class TestAccountLockdownStage(AccountLockdownStageTestMixin, FlowTestCase):
"""Account lockdown stage tests"""
def test_lockdown_no_target(self):
"""Test lockdown stage with no pending user fails"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
response = view.dispatch(self.make_request())
self.assertEqual(response.status_code, 400)
def test_lockdown_with_pending_user(self):
"""Test lockdown stage with a pending target user."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Security incident"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertFalse(self.target_user.has_usable_password())
self.assertEqual(response.status_code, 204)
# Check event was created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["action_id"], LOCKDOWN_EVENT_ACTION_ID)
self.assertEqual(event.context["reason"], "Security incident")
self.assertEqual(event.context["affected_user"], self.target_user.username)
def test_lockdown_with_pending_user_reason(self):
"""Test lockdown stage with a pending target and explicit reason."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Compromised account"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_reason_from_prompt(self):
"""Test lockdown stage reads the reason from prompt data."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {
PLAN_CONTEXT_LOCKDOWN_REASON: "User requested lockdown",
}
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["reason"], "User requested lockdown")
def test_lockdown_event_failure_does_not_fail_self_service(self):
"""Test lockdown still succeeds when event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
def test_dispatch_records_success_when_event_emission_fails(self):
"""Test dispatch still completes if event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(
user=self.target_user,
)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_self_service_redirects_to_completion_flow(self):
"""Test self-service lockdown redirects to completion flow when sessions are deleted."""
completion_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage.self_service_completion_flow = completion_flow
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
response = view._self_service_completion_response(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": completion_flow.slug}),
)
def test_lockdown_self_service_requires_completion_flow(self):
"""Test self-service lockdown fails before deleting sessions without a completion flow."""
self.stage.self_service_completion_flow = None
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
self.target_user.refresh_from_db()
self.assertTrue(self.target_user.is_active)
def test_lockdown_denies_other_user_without_permission(self):
"""Test lockdown stage rejects non-self requests without change_user permission."""
actor = create_test_user()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=actor)
self.assertFalse(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
def test_lockdown_revokes_tokens(self):
"""Test lockdown stage revokes tokens"""
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_revokes_provider_tokens(self):
"""Test lockdown stage revokes provider tokens and sessions."""
oauth_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback")
],
signing_key=create_test_cert(),
)
saml_provider = SAMLProvider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
acs_url="https://sp.example.com/acs",
issuer_override="https://idp.example.com",
)
session = Session.objects.create(
session_key=generate_id(),
expires=timezone.now() + timezone.timedelta(hours=1),
last_ip="127.0.0.1",
)
auth_session = AuthenticatedSession.objects.create(
session=session,
user=self.target_user,
)
grant_kwargs = {
"provider": oauth_provider,
"user": self.target_user,
"auth_time": timezone.now(),
"_scope": "openid profile",
"expiring": False,
}
token_kwargs = grant_kwargs | {"_id_token": json.dumps(asdict(IDToken("foo", "bar")))}
AuthorizationCode.objects.create(
code=generate_id(),
session=auth_session,
**grant_kwargs,
)
AccessToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
RefreshToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
DeviceToken.objects.create(
provider=oauth_provider,
user=self.target_user,
session=auth_session,
_scope="openid profile",
expiring=False,
)
SAMLSession.objects.create(
provider=saml_provider,
user=self.target_user,
session=auth_session,
session_index=generate_id(),
name_id=self.target_user.email,
expires=timezone.now() + timezone.timedelta(hours=1),
expiring=True,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(AuthorizationCode.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(AccessToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(RefreshToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(DeviceToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(SAMLSession.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_selective_actions(self):
"""Test lockdown stage with selective actions"""
self.stage.deactivate_user = True
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should be deactivated
self.assertFalse(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Token should still exist
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
def test_lockdown_no_actions(self):
"""Test lockdown stage with all actions disabled"""
self.stage.deactivate_user = False
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should still be active
self.assertTrue(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Event should still be created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
def test_lockdown_deactivation_inhibits_signal_dispatch_until_after_commit(self):
"""Test lockdown queues explicit outgoing syncs after the deactivation transaction."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.sync_outgoing_inhibit_dispatch"
) as inhibit,
patch.object(view, "_sync_deactivated_user_to_outgoing_providers") as sync_outgoing,
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
inhibit.assert_called_once()
sync_outgoing.assert_called_once()
synced_user = sync_outgoing.call_args.args[0]
self.assertEqual(synced_user.pk, self.target_user.pk)
self.assertFalse(synced_user.is_active)
def test_lockdown_waits_for_direct_outgoing_provider_syncs(self):
"""Test direct outgoing sync tasks are enqueued and waited on."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
) as task_group_cls,
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_sync_direct.message_with_options.assert_called_once_with(
args=(class_to_path(type(self.target_user)), self.target_user.pk, provider.pk),
rel_obj=provider,
time_limit=5000,
uid=f"{provider.name}:user:{self.target_user.pk}:direct",
)
task_group_cls.assert_called_once_with(["direct-message"])
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_timeout_leaves_tasks_running(self):
"""Test timeout while waiting for direct outgoing syncs does not fail lockdown."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
task_group.run.return_value.wait.side_effect = ResultTimeout("timed out")
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
),
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_group.run.assert_called_once_with()
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_failure_does_not_fail_lockdown(self):
"""Test completed local lockdown still emits an event if outgoing sync fails."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with patch.object(
view,
"_sync_deactivated_user_to_outgoing_providers",
side_effect=ValueError("sync failed"),
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
event = self.get_lockdown_event()
self.assertIsNotNone(event)
class TestAccountLockdownStageConcurrency(AccountLockdownStageTestMixin, TransactionTestCase):
"""Account lockdown concurrency tests."""
def test_lockdown_retries_when_another_transaction_recreates_a_token(self):
"""Lockdown should remove a token recreated before the retry check runs."""
Token.objects.create(
user=self.target_user,
identifier=f"initial-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
original_has_artifacts = view._has_lockdown_artifacts
target_user = self.target_user
thread_ready = ThreadEvent()
start_create = ThreadEvent()
thread_done = ThreadEvent()
thread_errors = []
class TokenCreatorThread(Thread):
__test__ = False
def run(self):
try:
thread_ready.set()
if not start_create.wait(timeout=5):
thread_errors.append("timed out waiting to recreate token")
return
Token.objects.create(
user=target_user,
identifier=f"concurrent-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
except Exception as exc: # noqa: BLE001
thread_errors.append(exc)
finally:
thread_done.set()
connection.close()
def has_artifacts_after_concurrent_create(stage, user):
if not start_create.is_set():
start_create.set()
self.assertTrue(
thread_done.wait(timeout=30),
(
"Concurrent token creation did not complete "
f"before retry check: {thread_errors}"
),
)
return original_has_artifacts(stage, user)
creator = TokenCreatorThread()
with patch.object(
view, "_has_lockdown_artifacts", side_effect=has_artifacts_after_concurrent_create
):
creator.start()
self.assertTrue(
thread_ready.wait(timeout=5),
"Concurrent token creation thread did not start",
)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
creator.join()
self.assertEqual(thread_errors, [])
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)

View File

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

View File

@@ -8,7 +8,6 @@ from inspect import currentframe
from typing import Any
from uuid import uuid4
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.apps import apps
from django.db import models
@@ -410,7 +409,7 @@ class NotificationTransport(TasksModel, SerializerModel):
)
notification.save()
layer = get_channel_layer()
async_to_sync(layer.group_send)(
layer.group_send_blocking(
build_user_group(notification.user),
{
"type": "event.notification",

View File

@@ -29,6 +29,7 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
default = False
visibility = "public"
description = _("Refresh other tabs after successful authentication.")
deprecated = True
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):

View File

@@ -23,7 +23,7 @@
height: 100%;
}
body {
background-image: url("{{ flow_background_url }}");
background-image: url("{{ flow_background_url|iriencode|safe }}");
background-repeat: no-repeat;
background-size: cover;
}

View File

@@ -39,7 +39,7 @@
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style data-id="flow-css">
:root {
--ak-global--background-image: url("{{ flow_background_url }}");
--ak-global--background-image: url("{{ flow_background_url|iriencode|safe }}");
}
</style>
{% endblock %}
@@ -48,7 +48,7 @@
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container></ak-message-container>
<ak-drawer id="flow-drawer">
<ak-flow-executor
<ak-flow
slug="{{ flow.slug }}"
class="pf-c-login"
data-layout="{{ flow.layout|default:'stacked' }}"
@@ -57,7 +57,7 @@
{% include "base/placeholder.html" %}
<ak-brand-links name="flow-links" slot="footer"></ak-brand-links>
</ak-flow-executor>
</ak-flow>
<ak-flow-inspector
slot="panel"

View File

@@ -1,12 +1,14 @@
"""stage view tests"""
from collections.abc import Callable
from unittest.mock import patch
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.tests.utils import RequestFactory as AuthentikRequestFactory
from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import FlowStageBinding
from authentik.flows.models import Flow, FlowStageBinding
from authentik.flows.stage import StageView
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.utils.reflection import all_subclasses
@@ -42,6 +44,46 @@ class TestViews(TestCase):
"/static/dist/assets/images/flow_background.jpg",
)
def test_flow_interface_css_background_preserves_presigned_url_query(self):
"""Test flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertContains(
response,
f'--ak-global--background-image: url("{background_url}");',
html=False,
)
def test_flow_sfe_css_background_preserves_presigned_url_query(self):
"""Test SFE flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) + "?sfe"
)
self.assertContains(
response,
f'background-image: url("{background_url}");',
html=False,
)
def view_tester_factory(view_class: type[StageView]) -> Callable:
"""Test a form"""

View File

@@ -53,6 +53,16 @@ class TestEndSessionView(OAuthTestCase):
self.brand.flow_invalidation = self.invalidation_flow
self.brand.save()
def _id_token_hint(self, host: str) -> str:
"""Issue a valid id_token_hint for the test provider under the given host."""
return self.provider.encode(
{
"iss": f"http://{host}/application/o/{self.app.slug}/",
"aud": self.provider.client_id,
"sub": str(self.user.pk),
}
)
def test_post_logout_redirect_uri_strict_match(self):
"""Test strict URI matching redirects to flow"""
self.client.force_login(self.user)
@@ -61,7 +71,10 @@ class TestEndSessionView(OAuthTestCase):
"authentik_providers_oauth2:end-session",
kwargs={"application_slug": self.app.slug},
),
{"post_logout_redirect_uri": "http://testserver/logout"},
{
"post_logout_redirect_uri": "http://testserver/logout",
"id_token_hint": self._id_token_hint(self.brand.domain),
},
HTTP_HOST=self.brand.domain,
)
# Should redirect to the invalidation flow
@@ -69,7 +82,12 @@ class TestEndSessionView(OAuthTestCase):
self.assertIn(self.invalidation_flow.slug, response.url)
def test_post_logout_redirect_uri_strict_no_match(self):
"""Test strict URI not matching still proceeds with flow (no redirect URI in context)"""
"""Test strict URI not matching returns an error and does not start logout flow.
Required by OIDC RP-Initiated Logout 1.0: on an unregistered
post_logout_redirect_uri, the OP MUST NOT redirect and MUST NOT proceed with
logout that targets the RP.
"""
self.client.force_login(self.user)
invalid_uri = "http://testserver/other"
response = self.client.get(
@@ -77,12 +95,14 @@ class TestEndSessionView(OAuthTestCase):
"authentik_providers_oauth2:end-session",
kwargs={"application_slug": self.app.slug},
),
{"post_logout_redirect_uri": invalid_uri},
{
"post_logout_redirect_uri": invalid_uri,
"id_token_hint": self._id_token_hint(self.brand.domain),
},
HTTP_HOST=self.brand.domain,
)
# Should still redirect to flow, but invalid URI should not be in response
self.assertEqual(response.status_code, 302)
self.assertNotIn(invalid_uri, response.url)
self.assertEqual(response.status_code, 400)
self.assertNotIn(invalid_uri, response.content.decode())
def test_post_logout_redirect_uri_regex_match(self):
"""Test regex URI matching redirects to flow"""
@@ -92,7 +112,10 @@ class TestEndSessionView(OAuthTestCase):
"authentik_providers_oauth2:end-session",
kwargs={"application_slug": self.app.slug},
),
{"post_logout_redirect_uri": "https://app.example.com/logout"},
{
"post_logout_redirect_uri": "https://app.example.com/logout",
"id_token_hint": self._id_token_hint(self.brand.domain),
},
HTTP_HOST=self.brand.domain,
)
# Should redirect to the invalidation flow
@@ -100,7 +123,7 @@ class TestEndSessionView(OAuthTestCase):
self.assertIn(self.invalidation_flow.slug, response.url)
def test_post_logout_redirect_uri_regex_no_match(self):
"""Test regex URI not matching"""
"""Test regex URI not matching returns an error and does not start logout flow."""
self.client.force_login(self.user)
invalid_uri = "https://malicious.com/logout"
response = self.client.get(
@@ -108,12 +131,14 @@ class TestEndSessionView(OAuthTestCase):
"authentik_providers_oauth2:end-session",
kwargs={"application_slug": self.app.slug},
),
{"post_logout_redirect_uri": invalid_uri},
{
"post_logout_redirect_uri": invalid_uri,
"id_token_hint": self._id_token_hint(self.brand.domain),
},
HTTP_HOST=self.brand.domain,
)
# Should still proceed to flow, but invalid URI should not be in response
self.assertEqual(response.status_code, 302)
self.assertNotIn(invalid_uri, response.url)
self.assertEqual(response.status_code, 400)
self.assertNotIn(invalid_uri, response.content.decode())
def test_state_parameter_appended_to_uri(self):
"""Test state parameter is appended to validated redirect URI"""
@@ -123,6 +148,7 @@ class TestEndSessionView(OAuthTestCase):
{
"post_logout_redirect_uri": "http://testserver/logout",
"state": "test-state-123",
"id_token_hint": self._id_token_hint("testserver"),
},
)
request.user = self.user
@@ -132,6 +158,7 @@ class TestEndSessionView(OAuthTestCase):
view.request = request
view.kwargs = {"application_slug": self.app.slug}
view.resolve_provider_application()
view.validate()
self.assertIn("state=test-state-123", view.post_logout_redirect_uri)
@@ -146,6 +173,7 @@ class TestEndSessionView(OAuthTestCase):
{
"post_logout_redirect_uri": "http://testserver/logout",
"state": "xyz789",
"id_token_hint": self._id_token_hint(self.brand.domain),
},
HTTP_HOST=self.brand.domain,
)

View File

@@ -5,6 +5,8 @@ from urllib.parse import quote, urlparse
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from jwt import PyJWTError
from jwt import decode as jwt_decode
from authentik.common.oauth.constants import (
FORBIDDEN_URI_SCHEMES,
@@ -21,11 +23,14 @@ from authentik.flows.planner import (
from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.policies.views import PolicyAccessView
from authentik.providers.iframe_logout import IframeLogoutStageView
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
JWTAlgorithms,
OAuth2LogoutMethod,
OAuth2Provider,
RedirectURIMatchingMode,
)
from authentik.providers.oauth2.tasks import send_backchannel_logout_request
@@ -47,21 +52,45 @@ class EndSessionView(PolicyAccessView):
if not self.flow:
raise Http404
def validate(self):
# Parse end session parameters
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
state = query_dict.get("state")
request_redirect_uri = query_dict.get("post_logout_redirect_uri")
id_token_hint = query_dict.get("id_token_hint")
self.post_logout_redirect_uri = None
# OIDC Certification: Verify id_token_hint. If invalid or missing, throw an error
if id_token_hint:
# Load a fresh provider instance that's not part of the flow
# since it'll have the cryptography Certificate that can't be pickled
provider = OAuth2Provider.objects.get(pk=self.provider.pk)
key, alg = provider.jwt_key
if alg != JWTAlgorithms.HS256:
key = provider.signing_key.public_key
try:
jwt_decode(
id_token_hint,
key,
algorithms=[alg],
audience=provider.client_id,
issuer=provider.get_issuer(self.request),
# ID Tokens are short-lived; a logout request arriving
# after expiry is still legitimate and must succeed.
options={"verify_exp": False},
)
except PyJWTError:
raise TokenError("invalid_request").with_cause(
"id_token_hint_decode_failed"
) from None
# Validate post_logout_redirect_uri against registered URIs
if request_redirect_uri:
# OIDC Certification: id_token_hint required with post_logout_redirect_uri
if not id_token_hint:
raise TokenError("invalid_request").with_cause("id_token_hint_missing")
if urlparse(request_redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
raise RequestValidationError(
bad_request_message(
self.request,
"Forbidden URI scheme in post_logout_redirect_uri",
)
)
raise TokenError("invalid_request").with_cause("post_logout_redirect_uri")
for allowed in self.provider.post_logout_redirect_uris:
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
if request_redirect_uri == allowed.url:
@@ -71,6 +100,10 @@ class EndSessionView(PolicyAccessView):
if fullmatch(allowed.url, request_redirect_uri):
self.post_logout_redirect_uri = request_redirect_uri
break
# OIDC Certification: OP MUST NOT perform post-logout redirection
# if the supplied URI does not exactly match a registered one
if self.post_logout_redirect_uri is None:
raise TokenError("invalid_request").with_cause("invalid_post_logout_redirect_uri")
# Append state to the redirect URI if both are present
if self.post_logout_redirect_uri and state:
@@ -91,50 +124,43 @@ class EndSessionView(PolicyAccessView):
"<html><body>Logout successful</body></html>", content_type="text/html", status=200
)
# Otherwise, continue with normal policy checks
return super().dispatch(request, *args, **kwargs)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Dispatch the flow planner for the invalidation flow"""
try:
self.validate()
except TokenError as exc:
return bad_request_message(
self.request,
exc.description,
)
planner = FlowPlanner(self.flow)
planner.allow_empty_flows = True
# Build flow context with logout parameters
context = {
PLAN_CONTEXT_APPLICATION: self.application,
}
# Get session info for logout notifications and token invalidation
auth_session = AuthenticatedSession.from_request(request, request.user)
# Add validated redirect URI (with state appended) to context if available
if self.post_logout_redirect_uri:
context[PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI] = self.post_logout_redirect_uri
# Invalidate tokens for this provider/session (RP-initiated logout:
# user stays logged into authentik, only this provider's tokens are revoked)
if request.user.is_authenticated and auth_session:
AccessToken.objects.filter(
user=request.user,
provider=self.provider,
session=auth_session,
).delete()
session_key = (
auth_session.session.session_key if auth_session and auth_session.session else None
)
# Handle frontchannel logout
frontchannel_logout_url = None
if self.provider.logout_method == OAuth2LogoutMethod.FRONTCHANNEL:
frontchannel_logout_url = build_frontchannel_logout_url(
self.provider, request, session_key
)
# Handle backchannel logout
if (
self.provider.logout_method == OAuth2LogoutMethod.BACKCHANNEL
and self.provider.logout_uri
):
# Find access token to get iss and sub for the logout token
access_token = AccessToken.objects.filter(
user=request.user,
provider=self.provider,
@@ -163,9 +189,16 @@ class EndSessionView(PolicyAccessView):
}
]
access_tokens = AccessToken.objects.filter(
user=request.user,
provider=self.provider,
)
if auth_session:
access_tokens = access_tokens.filter(session=auth_session)
access_tokens.delete()
plan = planner.plan(request, context)
# Inject iframe logout stage if frontchannel logout is configured
if frontchannel_logout_url:
plan.insert_stage(in_memory_stage(IframeLogoutStageView))

View File

@@ -1,6 +1,5 @@
"""RAC Signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete
@@ -18,7 +17,7 @@ from authentik.providers.rac.models import ConnectionToken, Endpoint
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer()
async_to_sync(layer.group_send)(
layer.group_send_blocking(
build_rac_client_group_session(instance.session.session_key),
{"type": "event.disconnect", "reason": "session_logout"},
)
@@ -28,7 +27,7 @@ def user_session_deleted(sender, instance: AuthenticatedSession, **_):
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
"""Disconnect session when connection token is deleted"""
layer = get_channel_layer()
async_to_sync(layer.group_send)(
layer.group_send_blocking(
build_rac_client_group_token(instance.token),
{"type": "event.disconnect", "reason": "token_delete"},
)

View File

@@ -6,6 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0056_user_roles"), # must run before group field is removed
("authentik_rbac", "0009_remove_initialpermissions_mode"),
]

View File

@@ -172,6 +172,7 @@ SPECTACULAR_SETTINGS = {
},
"ENUM_NAME_OVERRIDES": {
"AppEnum": "authentik.lib.api.Apps",
"AuthenticationEnum": "authentik.flows.models.FlowAuthenticationRequirement",
"ConsentModeEnum": "authentik.stages.consent.models.ConsentMode",
"CountryCodeEnum": "django_countries.countries",
"DeviceClassesEnum": "authentik.stages.authenticator_validate.models.DeviceClasses",
@@ -186,6 +187,7 @@ SPECTACULAR_SETTINGS = {
"PolicyEngineMode": "authentik.policies.models.PolicyEngineMode",
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
"RedirectURITypeEnum": "authentik.providers.oauth2.models.RedirectURIType",
"SAMLBindingsEnum": "authentik.providers.saml.models.SAMLBindings",
"SAMLLogoutMethods": "authentik.providers.saml.models.SAMLLogoutMethods",
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
@@ -219,7 +221,7 @@ REST_FRAMEWORK = {
"authentik.api.search.ql.QLSearch",
"authentik.rbac.filters.ObjectFilter",
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"authentik.api.ordering.NullsAwareOrderingFilter",
],
"DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",),
"DEFAULT_AUTHENTICATION_CLASSES": (

View File

@@ -389,17 +389,19 @@ class ThrottlingMixin(models.Model):
"""Check if throttling is enabled"""
return self.get_throttle_factor() > 0
def get_throttle_factor(self): # pragma: no cover
def get_throttle_factor(self) -> float: # pragma: no cover
"""
This must be implemented to return the throttle factor.
Returns the throttling factor.
"""
return getattr(self, "_throttle_factor", 1.0)
def set_throttle_factor(self, throttle_factor: float) -> None:
"""
Sets the throttle factor to use. Call this to override the default value of 1.
The number of seconds required between verification attempts will be
:math:`c2^{n-1}` where `c` is this factor and `n` is the number of
previous failures. A factor of 1 translates to delays of 1, 2, 4, 8,
etc. seconds. A factor of 0 disables the throttling.
Normally this is just a wrapper for a plugin-specific setting like
:setting:`OTP_EMAIL_THROTTLE_FACTOR`.
"""
raise NotImplementedError()
self._throttle_factor = throttle_factor

View File

@@ -6,7 +6,6 @@ from threading import Thread
from django.contrib.auth.models import AnonymousUser
from django.db import connection
from django.test import TestCase, TransactionTestCase
from django.test.utils import override_settings
from django.utils import timezone
from freezegun import freeze_time
@@ -110,8 +109,24 @@ class ThrottlingTestMixin:
self.assertEqual(verify_is_allowed3, True)
self.assertEqual(data3, None)
def test_set_throttle_factor_is_reflected(self):
"""`set_throttle_factor` must drive `get_throttle_factor`."""
self.device.set_throttle_factor(5.5)
self.assertEqual(self.device.get_throttle_factor(), 5.5)
self.device.set_throttle_factor(0)
self.assertEqual(self.device.get_throttle_factor(), 0)
def test_throttling_disabled_by_factor_zero(self):
"""Setting the throttle factor to 0 must actually disable throttling.
A failed attempt followed by a successful one must succeed. The lockout
path must not kick in when the factor is 0.
"""
self.device.set_throttle_factor(0)
self.assertFalse(self.device.verify_token(self.invalid_token()))
self.assertTrue(self.device.verify_token(self.valid_token()))
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
class APITestCase(TestCase):
"""Test API"""
@@ -119,6 +134,7 @@ class APITestCase(TestCase):
self.alice = create_test_admin_user("alice")
self.bob = create_test_admin_user("bob")
device = self.alice.staticdevice_set.create()
device.set_throttle_factor(0)
self.valid = generate_id(length=16)
device.token_set.create(token=self.valid)
@@ -138,6 +154,8 @@ class APITestCase(TestCase):
verified = verify_token(self.alice, device.persistent_id, "bogus")
self.assertIsNone(verified)
self.alice.staticdevice_set.get().throttle_reset()
verified = verify_token(self.alice, device.persistent_id, self.valid)
self.assertIsNotNone(verified)
@@ -146,11 +164,12 @@ class APITestCase(TestCase):
verified = match_token(self.alice, "bogus")
self.assertIsNone(verified)
self.alice.staticdevice_set.get().throttle_reset()
verified = match_token(self.alice, self.valid)
self.assertEqual(verified, self.alice.staticdevice_set.first())
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
class ConcurrencyTestCase(TransactionTestCase):
"""Test concurrent verifications"""

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.12 on 2026-04-02 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_email",
"0002_alter_authenticatoremailstage_friendly_name",
),
]
operations = [
migrations.AddField(
model_name="emaildevice",
name="throttling_failure_count",
field=models.PositiveIntegerField(
default=0, help_text="Number of successive failed attempts."
),
),
migrations.AddField(
model_name="emaildevice",
name="throttling_failure_timestamp",
field=models.DateTimeField(
blank=True,
default=None,
help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.",
null=True,
),
),
]

View File

@@ -14,7 +14,7 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.stages.authenticator.models import SideChannelDevice
from authentik.stages.authenticator.models import SideChannelDevice, ThrottlingMixin
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
@@ -116,7 +116,7 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
verbose_name_plural = _("Email Authenticator Setup Stages")
class EmailDevice(SerializerModel, SideChannelDevice):
class EmailDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
"""Email Device"""
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
@@ -130,6 +130,20 @@ class EmailDevice(SerializerModel, SideChannelDevice):
return EmailDeviceSerializer
def verify_token(self, token: str) -> bool:
verify_allowed, _ = self.verify_is_allowed()
if verify_allowed:
verified = super().verify_token(token)
if verified:
self.throttle_reset()
else:
self.throttle_increment()
else:
verified = False
return verified
def _compose_email(self) -> TemplateEmailMessage:
try:
pending_user = self.user

View File

@@ -8,6 +8,7 @@ from django.core.mail.backends.locmem import EmailBackend
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
from django.db.utils import IntegrityError
from django.template.exceptions import TemplateDoesNotExist
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
@@ -16,6 +17,7 @@ from authentik.flows.models import FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.lib.config import CONFIG
from authentik.lib.utils.email import mask_email
from authentik.stages.authenticator.tests import ThrottlingTestMixin
from authentik.stages.authenticator_email.api import (
AuthenticatorEmailStageSerializer,
EmailDeviceSerializer,
@@ -79,6 +81,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
self.assertFalse(self.device.verify_token("000000"))
# Verify correct token (should clear token after verification)
self.device.throttle_reset(commit=False)
self.assertTrue(self.device.verify_token(token))
self.assertIsNone(self.device.token)
@@ -329,3 +332,27 @@ class TestAuthenticatorEmailStage(FlowTestCase):
# Test AuthenticatorEmailStage send method
self.stage.send(self.device)
self.assertEqual(len(mail.outbox), 1)
class TestEmailDeviceThrottling(ThrottlingTestMixin, TestCase):
def setUp(self):
super().setUp()
flow = create_test_flow()
user = create_test_user()
stage = AuthenticatorEmailStage.objects.create(
name="email-authenticator-throttle",
use_global_settings=True,
from_address="test@authentik.local",
configure_flow=flow,
token_expiry="minutes=30",
) # nosec
self.device = EmailDevice.objects.create(
user=user, stage=stage, email="throttle@authentik.local"
)
self.device.generate_token()
def valid_token(self):
return self.device.token
def invalid_token(self):
return "000000" if self.device.token != "000000" else "111111"

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.12 on 2026-04-16 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_authenticator_sms", "0008_alter_authenticatorsmsstage_friendly_name"),
]
operations = [
migrations.AddField(
model_name="smsdevice",
name="throttling_failure_count",
field=models.PositiveIntegerField(
default=0, help_text="Number of successive failed attempts."
),
),
migrations.AddField(
model_name="smsdevice",
name="throttling_failure_timestamp",
field=models.DateTimeField(
blank=True,
default=None,
help_text="A timestamp of the last failed verification attempt. Null if last attempt succeeded.",
null=True,
),
),
]

View File

@@ -20,7 +20,7 @@ from authentik.events.utils import sanitize_item
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.utils.http import get_http_session
from authentik.stages.authenticator.models import SideChannelDevice
from authentik.stages.authenticator.models import SideChannelDevice, ThrottlingMixin
LOGGER = get_logger()
@@ -197,7 +197,7 @@ def hash_phone_number(phone_number: str) -> str:
return "hash:" + sha256(phone_number.encode()).hexdigest()
class SMSDevice(SerializerModel, SideChannelDevice):
class SMSDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
"""SMS Device"""
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
@@ -224,11 +224,19 @@ class SMSDevice(SerializerModel, SideChannelDevice):
return SMSDeviceSerializer
def verify_token(self, token):
valid = super().verify_token(token)
if valid:
self.save()
return valid
def verify_token(self, token: str) -> bool:
verify_allowed, _ = self.verify_is_allowed()
if verify_allowed:
verified = super().verify_token(token)
if verified:
self.throttle_reset()
else:
self.throttle_increment()
else:
verified = False
return verified
def __str__(self):
return str(self.name) or str(self.user_id)

View File

@@ -3,6 +3,7 @@
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qsl
from django.test import TestCase
from django.urls import reverse
from requests_mock import Mocker
@@ -12,6 +13,7 @@ from authentik.flows.planner import FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.stages.authenticator.tests import ThrottlingTestMixin
from authentik.stages.authenticator_sms.models import (
AuthenticatorSMSStage,
SMSDevice,
@@ -357,3 +359,30 @@ class AuthenticatorSMSStageTests(FlowTestCase):
},
phone_number_required=False,
)
class TestSMSDeviceThrottling(ThrottlingTestMixin, TestCase):
"""Test ThrottlingMixin behaviour on SMSDevice.verify_token"""
def setUp(self):
super().setUp()
flow = create_test_flow()
user = create_test_admin_user()
stage = AuthenticatorSMSStage.objects.create(
flow=flow,
name="sms-throttle",
provider=SMSProviders.GENERIC,
from_number="1234",
)
self.device = SMSDevice.objects.create(
user=user,
stage=stage,
phone_number="+15551230001",
)
self.device.generate_token()
def valid_token(self):
return self.device.token
def invalid_token(self):
return "000000" if self.device.token != "000000" else "111111"

View File

@@ -3,7 +3,6 @@
from base64 import b32encode
from os import urandom
from django.conf import settings
from django.core.validators import MaxValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -78,9 +77,6 @@ class StaticDevice(SerializerModel, ThrottlingMixin, Device):
return StaticDeviceSerializer
def get_throttle_factor(self):
return getattr(settings, "OTP_STATIC_THROTTLE_FACTOR", 1)
def verify_token(self, token):
verify_allowed, _ = self.verify_is_allowed()
if verify_allowed:

View File

@@ -1,6 +1,5 @@
"""Test Static API"""
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -44,9 +43,6 @@ class DeviceTest(TestCase):
str(device)
@override_settings(
OTP_STATIC_THROTTLE_FACTOR=1,
)
class ThrottlingTestCase(ThrottlingTestMixin, TestCase):
"""Test static device throttling"""

View File

@@ -194,9 +194,6 @@ class TOTPDevice(SerializerModel, ThrottlingMixin, Device):
return verified
def get_throttle_factor(self):
return getattr(settings, "OTP_TOTP_THROTTLE_FACTOR", 1)
@property
def config_url(self):
"""

View File

@@ -63,11 +63,14 @@ class TOTPDeviceMixin:
@override_settings(
OTP_TOTP_SYNC=False,
OTP_TOTP_THROTTLE_FACTOR=0,
)
class TOTPTest(TOTPDeviceMixin, TestCase):
"""TOTP tests"""
def setUp(self):
super().setUp()
self.device.set_throttle_factor(0)
def test_default_key(self):
"""Ensure default_key is valid"""
device = self.alice.totpdevice_set.create()
@@ -190,9 +193,6 @@ class TOTPTest(TOTPDeviceMixin, TestCase):
self.assertEqual(params["image"][0], image_url)
@override_settings(
OTP_TOTP_THROTTLE_FACTOR=1,
)
class ThrottlingTestCase(TOTPDeviceMixin, ThrottlingTestMixin, TestCase):
"""Test TOTP Throttling"""

View File

@@ -39,6 +39,10 @@ class AuthenticatorValidateStageSerializer(StageSerializer):
"webauthn_hints",
"webauthn_allowed_device_types",
"webauthn_allowed_device_types_obj",
"email_otp_throttling_factor",
"sms_otp_throttling_factor",
"totp_otp_throttling_factor",
"static_otp_throttling_factor",
]

View File

@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.db import transaction
from django.http import HttpRequest
from django.http.response import Http404
from django.shortcuts import get_object_or_404
@@ -29,8 +30,8 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.email import mask_email
from authentik.lib.utils.time import timedelta_from_string
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.authenticator import match_token
from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator import devices_for_user
from authentik.stages.authenticator.models import Device, ThrottlingMixin
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_email.models import EmailDevice
from authentik.stages.authenticator_sms.models import SMSDevice
@@ -143,7 +144,20 @@ def select_challenge_email(request: HttpRequest, device: EmailDevice):
def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device:
"""Validate code-based challenges. We test against every device, on purpose, as
the user mustn't choose between totp and static devices."""
device = match_token(user, code)
with transaction.atomic():
for device in devices_for_user(user, for_verify=True):
if isinstance(device, ThrottlingMixin):
throttling_factor = stage_view.executor.current_stage.get_throttling_factor(
DeviceClasses.from_model_label(device.model_label())
)
if throttling_factor is not None:
device.set_throttle_factor(throttling_factor)
if device.verify_token(code):
break
else:
device = None
if not device:
login_failed.send(
sender=__name__,

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.12 on 2026-04-16 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_validate",
"0015_authenticatorvalidatestage_webauthn_hints",
),
]
operations = [
migrations.AddField(
model_name="authenticatorvalidatestage",
name="email_otp_throttling_factor",
field=models.FloatField(default=1),
),
migrations.AddField(
model_name="authenticatorvalidatestage",
name="sms_otp_throttling_factor",
field=models.FloatField(default=1),
),
migrations.AddField(
model_name="authenticatorvalidatestage",
name="static_otp_throttling_factor",
field=models.FloatField(default=1),
),
migrations.AddField(
model_name="authenticatorvalidatestage",
name="totp_otp_throttling_factor",
field=models.FloatField(default=1),
),
]

View File

@@ -22,6 +22,12 @@ class DeviceClasses(models.TextChoices):
SMS = "sms", _("SMS")
EMAIL = "email", _("Email")
@staticmethod
def from_model_label(model_label: str) -> DeviceClasses:
return getattr(
DeviceClasses, model_label.rsplit(".", maxsplit=1)[-1][: -len("device")].upper()
)
def default_device_classes() -> list:
"""By default, accept all device classes"""
@@ -82,6 +88,11 @@ class AuthenticatorValidateStage(Stage):
"authentik_stages_authenticator_webauthn.WebAuthnDeviceType", blank=True
)
email_otp_throttling_factor = models.FloatField(default=1)
sms_otp_throttling_factor = models.FloatField(default=1)
totp_otp_throttling_factor = models.FloatField(default=1)
static_otp_throttling_factor = models.FloatField(default=1)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
@@ -98,6 +109,17 @@ class AuthenticatorValidateStage(Stage):
def component(self) -> str:
return "ak-stage-authenticator-validate-form"
def get_throttling_factor(self, device_class: DeviceClasses) -> float | None:
if device_class == DeviceClasses.EMAIL:
return self.email_otp_throttling_factor
elif device_class == DeviceClasses.SMS:
return self.sms_otp_throttling_factor
elif device_class == DeviceClasses.TOTP:
return self.totp_otp_throttling_factor
elif device_class == DeviceClasses.STATIC:
return self.static_otp_throttling_factor
return None
class Meta:
verbose_name = _("Authenticator Validation Stage")
verbose_name_plural = _("Authenticator Validation Stages")

View File

@@ -0,0 +1,247 @@
from django.test import TestCase
from django.test.client import RequestFactory
from django.urls.base import reverse
from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowStageBinding
from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.generators import generate_id
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
from authentik.stages.authenticator_sms.models import (
AuthenticatorSMSStage,
SMSDevice,
SMSProviders,
)
from authentik.stages.authenticator_validate.challenge import validate_challenge_code
from authentik.stages.authenticator_validate.models import (
AuthenticatorValidateStage,
DeviceClasses,
)
from authentik.stages.identification.models import IdentificationStage, UserFields
class DeviceClassesHelperTests(TestCase):
"""Tests for the DeviceClasses.from_model_label helper."""
def test_from_model_label_all_classes(self):
cases = {
"authentik_stages_authenticator_email.emaildevice": DeviceClasses.EMAIL,
"authentik_stages_authenticator_sms.smsdevice": DeviceClasses.SMS,
"authentik_stages_authenticator_totp.totpdevice": DeviceClasses.TOTP,
"authentik_stages_authenticator_static.staticdevice": DeviceClasses.STATIC,
"authentik_stages_authenticator_duo.duodevice": DeviceClasses.DUO,
"authentik_stages_authenticator_webauthn.webauthndevice": DeviceClasses.WEBAUTHN,
}
for label, expected in cases.items():
with self.subTest(label=label):
self.assertEqual(DeviceClasses.from_model_label(label), expected)
class AuthenticatorValidateStageFactorTests(TestCase):
"""Tests for AuthenticatorValidateStage.get_throttling_factor."""
def test_per_class_factors_returned(self):
stage = AuthenticatorValidateStage.objects.create(
name=generate_id(),
email_otp_throttling_factor=5,
sms_otp_throttling_factor=6,
totp_otp_throttling_factor=7,
static_otp_throttling_factor=8,
)
self.assertEqual(stage.get_throttling_factor(DeviceClasses.EMAIL), 5)
self.assertEqual(stage.get_throttling_factor(DeviceClasses.SMS), 6)
self.assertEqual(stage.get_throttling_factor(DeviceClasses.TOTP), 7)
self.assertEqual(stage.get_throttling_factor(DeviceClasses.STATIC), 8)
def test_no_factor_for_webauthn_or_duo(self):
stage = AuthenticatorValidateStage.objects.create(name=generate_id())
self.assertIsNone(stage.get_throttling_factor(DeviceClasses.WEBAUTHN))
self.assertIsNone(stage.get_throttling_factor(DeviceClasses.DUO))
class ValidateChallengeCodeThrottlingTests(FlowTestCase):
"""Tests for validate_challenge_code throttling behavior."""
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.request_factory = RequestFactory()
self.email_stage = AuthenticatorEmailStage.objects.create(
name="email-stage-validate-throttle",
use_global_settings=True,
from_address="test@authentik.local",
token_expiry="minutes=30",
) # nosec
self.sms_stage = AuthenticatorSMSStage.objects.create(
name="sms-stage-validate-throttle",
provider=SMSProviders.GENERIC,
from_number="1234",
)
def _validate_stage(self, **factors) -> AuthenticatorValidateStage:
return AuthenticatorValidateStage.objects.create(
name=generate_id(),
device_classes=[
DeviceClasses.EMAIL,
DeviceClasses.SMS,
DeviceClasses.TOTP,
DeviceClasses.STATIC,
],
**factors,
)
def _stage_view(self, validate_stage: AuthenticatorValidateStage) -> StageView:
request = self.request_factory.get("/")
return StageView(FlowExecutorView(current_stage=validate_stage), request=request)
def _email_device(self, email: str = "throttle@authentik.local") -> EmailDevice:
return EmailDevice.objects.create(
user=self.user,
stage=self.email_stage,
confirmed=True,
email=email,
)
def _sms_device(self, phone_number: str = "+15551230101") -> SMSDevice:
return SMSDevice.objects.create(
user=self.user,
stage=self.sms_stage,
confirmed=True,
phone_number=phone_number,
)
def test_stage_factor_applied_to_email_device(self):
"""The stage's email_otp_throttling_factor is pushed onto the device before verify."""
stage = self._validate_stage(email_otp_throttling_factor=3)
device = self._email_device()
device.generate_token()
with self.assertRaises(ValidationError):
validate_challenge_code("000000", self._stage_view(stage), self.user)
device.refresh_from_db()
self.assertEqual(device.throttling_failure_count, 1)
# verify_is_allowed must compute the delay using factor=3 (3 * 2^0 = 3s).
device.set_throttle_factor(3)
allowed, data = device.verify_is_allowed()
self.assertFalse(allowed)
required = data["locked_until"] - device.throttling_failure_timestamp
self.assertAlmostEqual(required.total_seconds(), 3, places=3)
def test_factor_zero_disables_throttling_end_to_end(self):
"""With email_otp_throttling_factor=0, repeated failures do not lock the device."""
stage = self._validate_stage(email_otp_throttling_factor=0)
device = self._email_device()
device.generate_token()
token = device.token
for _ in range(10):
with self.assertRaises(ValidationError):
validate_challenge_code("000000", self._stage_view(stage), self.user)
matched = validate_challenge_code(token, self._stage_view(stage), self.user)
self.assertEqual(matched.pk, device.pk)
def test_lockout_persists_across_calls(self):
"""
A correct token on the second call is still blocked and does not increment the counter.
"""
stage = self._validate_stage(email_otp_throttling_factor=1)
device = self._email_device()
device.generate_token()
token = device.token
invalid_token = "000000" if token != "000000" else "111111" # nosec
with self.assertRaises(ValidationError):
validate_challenge_code(invalid_token, self._stage_view(stage), self.user)
# Immediately try with the correct token: lockout still active, attempt must be rejected.
with self.assertRaises(ValidationError):
validate_challenge_code(token, self._stage_view(stage), self.user)
device.refresh_from_db()
# Token wasn't consumed (verification never ran), and counter didn't get incremented.
self.assertEqual(device.token, token)
self.assertEqual(device.throttling_failure_count, 1)
class ValidateStageThrottlingFlowTests(FlowTestCase):
"""End-to-end lockout behavior through the flow executor HTTP API."""
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.email_stage = AuthenticatorEmailStage.objects.create(
name="email-stage-flow-throttle",
use_global_settings=True,
from_address="test@authentik.local",
token_expiry="minutes=30",
) # nosec
self.ident_stage = IdentificationStage.objects.create(
name=generate_id(),
user_fields=[UserFields.USERNAME],
)
self.validate_stage = AuthenticatorValidateStage.objects.create(
name=generate_id(),
device_classes=[DeviceClasses.EMAIL],
email_otp_throttling_factor=1,
)
self.flow = create_test_flow()
FlowStageBinding.objects.create(target=self.flow, stage=self.ident_stage, order=0)
FlowStageBinding.objects.create(target=self.flow, stage=self.validate_stage, order=1)
def _identify(self):
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{"uid_field": self.user.username},
follow=True,
)
self.assertEqual(response.status_code, 200)
def _select_email(self, device: EmailDevice):
self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{
"component": "ak-stage-authenticator-validate",
"selected_challenge": {
"device_class": "email",
"device_uid": str(device.pk),
"challenge": {},
"last_used": None,
},
},
)
def test_bad_code_then_correct_code_is_still_blocked(self):
"""After a bad code over HTTP, a subsequent correct code is still rejected
because the lockout persists in the database."""
device = EmailDevice.objects.create(
user=self.user,
confirmed=True,
stage=self.email_stage,
email="throttle-flow@authentik.local",
)
self._identify()
self._select_email(device)
# Server generated and stored the token - grab it from DB.
device.refresh_from_db()
token = device.token
# First attempt: bad code - must increment the DB counter.
self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{"component": "ak-stage-authenticator-validate", "code": "000000"},
)
device.refresh_from_db()
self.assertEqual(device.throttling_failure_count, 1)
self.assertEqual(device.token, token)
# Second attempt with the correct token - still blocked.
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{"component": "ak-stage-authenticator-validate", "code": token},
)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-authenticator-validate",
)
device.refresh_from_db()
# Counter wasn't incremented on a blocked attempt
self.assertEqual(device.throttling_failure_count, 1)
# Token wasn't consumed.
self.assertEqual(device.token, token)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_prompt",
"0011_prompt_initial_value_prompt_initial_value_expression_and_more",
),
]
operations = [
migrations.AlterField(
model_name="prompt",
name="type",
field=models.CharField(
choices=[
("text", "Text: Simple Text input"),
("text_area", "Text area: Multiline Text Input."),
(
"text_read_only",
"Text (read-only): Simple Text input, but cannot be edited.",
),
(
"text_area_read_only",
"Text area (read-only): Multiline Text input, but cannot be edited.",
),
(
"username",
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
),
("email", "Email: Text field with Email type."),
(
"password",
"Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
),
("number", "Number"),
("checkbox", "Checkbox"),
(
"radio-button-group",
"Fixed choice field rendered as a group of radio buttons.",
),
("dropdown", "Fixed choice field rendered as a dropdown."),
("date", "Date"),
("date-time", "Date Time"),
(
"file",
"File: File upload for arbitrary files. File content will be available in flow context as data-URI",
),
("separator", "Separator: Static Separator Line"),
("hidden", "Hidden: Hidden field, can be used to insert data into form."),
("static", "Static: Static value, displayed as-is."),
("alert_info", "Alert (Info): Static alert box with info styling"),
("alert_warning", "Alert (Warning): Static alert box with warning styling"),
("alert_danger", "Alert (Danger): Static alert box with danger styling"),
("ak-locale", "authentik: Selection of locales authentik supports"),
],
max_length=100,
),
),
]

View File

@@ -87,6 +87,11 @@ class FieldTypes(models.TextChoices):
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
STATIC = "static", _("Static: Static value, displayed as-is.")
# Alert box types for displaying styled messages
ALERT_INFO = "alert_info", _("Alert (Info): Static alert box with info styling")
ALERT_WARNING = "alert_warning", _("Alert (Warning): Static alert box with warning styling")
ALERT_DANGER = "alert_danger", _("Alert (Danger): Static alert box with danger styling")
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
@@ -299,7 +304,12 @@ class Prompt(SerializerModel):
field_class = HiddenField
kwargs["required"] = False
kwargs["default"] = self.placeholder
case FieldTypes.STATIC:
case (
FieldTypes.STATIC
| FieldTypes.ALERT_INFO
| FieldTypes.ALERT_WARNING
| FieldTypes.ALERT_DANGER
):
kwargs["default"] = self.placeholder
kwargs["required"] = False
kwargs["label"] = ""

View File

@@ -124,6 +124,9 @@ class PromptChallengeResponse(ChallengeResponse):
type__in=[
FieldTypes.HIDDEN,
FieldTypes.STATIC,
FieldTypes.ALERT_INFO,
FieldTypes.ALERT_WARNING,
FieldTypes.ALERT_DANGER,
FieldTypes.TEXT_READ_ONLY,
FieldTypes.TEXT_AREA_READ_ONLY,
]

View File

@@ -330,10 +330,20 @@ class TestPromptStage(FlowTestCase):
def test_static_hidden_overwrite(self):
"""Test that static and hidden fields ignore any value sent to them"""
alert_prompt = Prompt.objects.create(
name=generate_id(),
field_key="alert_prompt",
type=FieldTypes.ALERT_INFO,
required=True,
placeholder="alert fallback",
initial_value="alert content",
)
self.stage.fields.add(alert_prompt)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {"hidden_prompt": "hidden"}
self.prompt_data["hidden_prompt"] = "foo"
self.prompt_data["static_prompt"] = "foo"
self.prompt_data["alert_prompt"] = "foo"
challenge_response = PromptChallengeResponse(
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
)
@@ -341,6 +351,7 @@ class TestPromptStage(FlowTestCase):
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["alert_prompt"], "alert content")
def test_prompt_placeholder(self):
"""Test placeholder and expression"""

View File

@@ -16,7 +16,7 @@ class RedirectMode(models.TextChoices):
class RedirectStage(Stage):
"""Redirect the user to another flow, potentially with all gathered context."""
"""Redirect the user to a static URL or another flow, optionally with all gathered context."""
keep_context = models.BooleanField(default=True)
mode = models.TextField(choices=RedirectMode.choices)

View File

@@ -7,7 +7,7 @@ from dramatiq.broker import Broker, MessageProxy, get_broker
from dramatiq.middleware.middleware import Middleware
from dramatiq.middleware.retries import Retries
from dramatiq.results.middleware import Results
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
from dramatiq.worker import ConsumerThread, Worker, WorkerThread
from authentik.tasks.broker import PostgresBroker
@@ -20,7 +20,7 @@ class TestWorker(Worker):
self.worker_id = 1000
self.work_queue = PriorityQueue()
self.consumers = {
TESTING_QUEUE: _ConsumerThread(
TESTING_QUEUE: ConsumerThread(
broker=self.broker,
queue_name=TESTING_QUEUE,
prefetch=2,
@@ -33,7 +33,7 @@ class TestWorker(Worker):
prefetch=2,
timeout=1,
)
self._worker = _WorkerThread(
self._worker = WorkerThread(
broker=self.broker,
consumers=self.consumers,
work_queue=self.work_queue,
@@ -78,17 +78,18 @@ def use_test_broker():
actor.broker = broker
actor.broker.declare_actor(actor)
for middleware_class, middleware_kwargs in Conf().middlewares:
middleware: Middleware = import_string(middleware_class)(
for middleware_class_path, middleware_kwargs in Conf().middlewares:
middleware_class = import_string(middleware_class_path)
if issubclass(middleware_class, Results):
middleware_kwargs["backend"] = import_string(Conf().result_backend)(
*Conf().result_backend_args,
**Conf().result_backend_kwargs,
)
middleware: Middleware = middleware_class(
**middleware_kwargs,
)
if isinstance(middleware, Retries):
middleware.max_retries = 0
if isinstance(middleware, Results):
middleware.backend = import_string(Conf().result_backend)(
*Conf().result_backend_args,
**Conf().result_backend_kwargs,
)
broker.add_middleware(middleware)
broker.start()

View File

@@ -59,6 +59,8 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
if _flag.description:
props[_flag.key]["description"] = _flag.description
if _flag.deprecated:
props[_flag.key]["deprecated"] = _flag.deprecated
return build_object_type(props, required=props.keys())

View File

@@ -18,6 +18,7 @@ class Flag[T]:
Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"]
) = "none"
description: str | None = None
deprecated = False
def __init_subclass__(cls, key: str, **kwargs):
cls.__key = key

View File

@@ -0,0 +1,306 @@
version: 1
metadata:
name: Example - Account lockdown flow
labels:
blueprints.goauthentik.io/instantiate: "false"
entries:
flows:
# Main lockdown flow - requires authentication
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown
title: Lock Account
authentication: require_authenticated
identifiers:
slug: default-account-lockdown
model: authentik_flows.flow
id: flow
# Self-service completion flow - no authentication required
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown Complete
title: Account Locked
authentication: none
identifiers:
slug: default-account-lockdown-complete
model: authentik_flows.flow
id: completion-flow
prompt_fields:
# Warning field - danger alert box (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 50
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
pending_user = None
if target_uuid and not is_self_service:
from authentik.core.models import User
pending_user = User.objects.filter(pk=target_uuid).first()
if is_self_service:
return (
"<p><strong>You are about to lock down your own account.</strong></p>"
"<p>This is an emergency action for cutting off access to your account right away.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li><strong>Invalidate your password</strong> - Your password will be set to a random value "
"and cannot be recovered</li>"
"<li><strong>Deactivate your account</strong> - Your account will be disabled</li>"
"<li><strong>Terminate all your sessions</strong> - You will be logged out everywhere</li>"
"<li><strong>Revoke all your tokens</strong> - All your API, app password, recovery, "
"verification, and OAuth2 tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
from django.utils.html import escape
if pending_user:
email = escape(pending_user.email or pending_user.name or "No email")
user_html = f"<p><code>{escape(pending_user.username)}</code> ({email})</p>"
else:
user_html = "<p>the account selected when this one-time lockdown link was created</p>"
return (
"<p><strong>You are about to lock down the following account:</strong></p>"
f"{user_html}"
"<p>This is an emergency action for cutting off access to the account right away. "
"It does not lock the administrator who opened this page.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li>Invalidate the user's password</li>"
"<li>Deactivate the user</li>"
"<li>Terminate all sessions - All active sessions will be ended</li>"
"<li>Revoke all tokens - All API, app password, recovery, verification, and OAuth2 "
"tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
initial_value_expression: true
required: false
type: alert_danger
field_key: lockdown_warning
label: Warning
sub_text: ""
identifiers:
name: default-account-lockdown-field-warning
id: prompt-field-warning
model: authentik_stages_prompt.prompt
# Info field - when to use lockdown (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
info = (
"Use this if you no longer trust your current password or sessions. "
"After lockdown, you will need help from your administrator or security team to regain access."
)
else:
info = (
"Use this for incident response on the listed account, for example after a compromise report "
"or suspicious activity. The reason you enter below will be recorded in the audit log."
)
return (
f"<p>{info}</p>"
'<p><a href="https://docs.goauthentik.io/docs/security/'
'account-lockdown?utm_source=authentik" '
'target="_blank" rel="noopener noreferrer">Learn more about account lockdown</a></p>'
)
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_info
label: Information
sub_text: ""
identifiers:
name: default-account-lockdown-field-info
id: prompt-field-info
model: authentik_stages_prompt.prompt
# Reason field - text area for lockdown reason
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 200
placeholder: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
return "Describe why you are locking your account..."
return "Describe why this account is being locked down..."
placeholder_expression: true
required: true
type: text_area
field_key: lockdown_reason
label: Reason
sub_text: This explanation will be recorded in the audit log.
identifiers:
name: default-account-lockdown-field-reason
id: prompt-field-reason
model: authentik_stages_prompt.prompt
prompt_stages:
# Prompt stage for warnings and reason input
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-warning
- !KeyOf prompt-field-info
- !KeyOf prompt-field-reason
identifiers:
name: default-account-lockdown-prompt
id: default-account-lockdown-prompt
model: authentik_stages_prompt.promptstage
lockdown_stage:
# Account lockdown stage - performs the actual lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
name: default-account-lockdown-stage
id: default-account-lockdown-stage
model: authentik_stages_account_lockdown.accountlockdownstage
attrs:
deactivate_user: true
set_unusable_password: true
delete_sessions: true
revoke_tokens: true
self_service_completion_flow: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
completion_prompt:
# Completion message field - confirmation shown after an admin-triggered lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 300
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
from django.utils.html import escape
from authentik.core.models import User
if target_uuid:
target = User.objects.filter(pk=target_uuid).first()
if target:
return f"<p><code>{escape(target.username)}</code> has been locked down.</p>"
return "<p>The selected account has been locked down.</p>"
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_complete
label: Result
sub_text: ""
identifiers:
name: default-account-lockdown-field-complete
id: prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for admin completion message
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-complete
identifiers:
name: default-account-lockdown-complete-prompt
id: default-account-lockdown-complete-prompt
model: authentik_stages_prompt.promptstage
policies:
# Expression policy to check if this is NOT a self-service lockdown (admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
name: default-account-lockdown-admin-policy
expression: |
target_uuid = (request.http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(request.user, "pk", "") or getattr(request.http_request.user, "pk", ""))
return bool(target_uuid) and target_uuid != current_user_uuid
identifiers:
name: default-account-lockdown-admin-policy
id: admin-policy
model: authentik_policies_expression.expressionpolicy
bindings:
# Stage bindings
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-prompt
target: !KeyOf flow
model: authentik_flows.flowstagebinding
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 10
stage: !KeyOf default-account-lockdown-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# Admin completion stage binding - shown for admin lockdown only
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 20
stage: !KeyOf default-account-lockdown-complete-prompt
target: !KeyOf flow
id: admin-completion-binding
model: authentik_flows.flowstagebinding
# Bind the admin policy to the admin completion stage
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
enabled: true
negate: false
order: 0
identifiers:
policy: !KeyOf admin-policy
target: !KeyOf admin-completion-binding
model: authentik_policies.policybinding
self_service_completion:
# Self-service completion message field (for the unauthenticated completion flow)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
return (
"<h1>Your account has been locked</h1>"
"<p>You have been logged out of all sessions and your password has been invalidated.</p>"
"<p>To regain access to your account, please contact your IT administrator or security team.</p>"
)
initial_value_expression: true
required: false
type: alert_warning
field_key: self_lockdown_complete
label: Account locked
sub_text: ""
identifiers:
name: default-account-lockdown-self-field-complete
id: self-prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for self-service completion (unauthenticated)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf self-prompt-field-complete
identifiers:
name: default-account-lockdown-self-complete-prompt
id: default-account-lockdown-self-complete-prompt
model: authentik_stages_prompt.promptstage
# Bind self-service completion stage to the completion flow
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-self-complete-prompt
target: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
model: authentik_flows.flowstagebinding

View File

@@ -0,0 +1,211 @@
# Minimal Invitation-based Enrollment Blueprint
#
# Companion to flows-invitation-enrollment.yaml, intended for the "New Invitation"
# wizard in the admin UI. Creates a single enrollment flow with an invitation stage
# bound to it, plus the supporting prompt/user-write/user-login stages.
#
# All user-facing fields are parameterized via !Context with fallback defaults, so
# this blueprint can be imported directly (without context) or through the wizard
# with custom values.
#
# Context keys (all optional):
# flow_name Display name of the enrollment flow.
# flow_slug URL slug of the flow and suffix for sub-entity
# identifiers (so repeated imports with different
# slugs don't overwrite each other).
# stage_name Name of the invitation stage.
# continue_flow_without_invitation Whether the flow continues when no invitation
# is supplied (default: false).
# user_type "external" or "internal" (default: "external").
# Drives the user-write stage's user_type and
# user_path_template.
version: 1
metadata:
labels:
blueprints.goauthentik.io/instantiate: "false"
name: Invitation-based Enrollment (minimal)
entries:
- identifiers:
slug: !Context [flow_slug, invitation-enrollment-flow]
model: authentik_flows.flow
id: flow
attrs:
name: !Context [flow_name, Invitation Enrollment Flow]
title: !Context [flow_name, Invitation Enrollment Flow]
designation: enrollment
authentication: require_unauthenticated
- identifiers:
name: !Context [stage_name, invitation-stage]
id: invitation-stage
model: authentik_stages_invitation.invitationstage
attrs:
continue_flow_without_invitation: !Context [continue_flow_without_invitation, false]
- identifiers:
name:
!Format [
"invitation-enrollment-field-username-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-username
model: authentik_stages_prompt.prompt
attrs:
field_key: username
label: Username
type: username
required: true
placeholder: Username
placeholder_expression: false
order: 0
- identifiers:
name:
!Format [
"invitation-enrollment-field-password-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-password
model: authentik_stages_prompt.prompt
attrs:
field_key: password
label: Password
type: password
required: true
placeholder: Password
placeholder_expression: false
order: 1
- identifiers:
name:
!Format [
"invitation-enrollment-field-password-repeat-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-password-repeat
model: authentik_stages_prompt.prompt
attrs:
field_key: password_repeat
label: Password (repeat)
type: password
required: true
placeholder: Password (repeat)
placeholder_expression: false
order: 2
- identifiers:
name:
!Format [
"invitation-enrollment-field-name-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-name
model: authentik_stages_prompt.prompt
attrs:
field_key: name
label: Name
type: text
required: true
placeholder: Name
placeholder_expression: false
order: 0
- identifiers:
name:
!Format [
"invitation-enrollment-field-email-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-field-email
model: authentik_stages_prompt.prompt
attrs:
field_key: email
label: Email
type: email
required: true
placeholder: Email
placeholder_expression: false
order: 1
- identifiers:
name:
!Format [
"invitation-enrollment-prompt-credentials-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-stage-credentials
model: authentik_stages_prompt.promptstage
attrs:
fields:
- !KeyOf prompt-field-username
- !KeyOf prompt-field-password
- !KeyOf prompt-field-password-repeat
- identifiers:
name:
!Format [
"invitation-enrollment-prompt-details-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: prompt-stage-details
model: authentik_stages_prompt.promptstage
attrs:
fields:
- !KeyOf prompt-field-name
- !KeyOf prompt-field-email
- identifiers:
name:
!Format [
"invitation-enrollment-user-write-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: user-write-stage
model: authentik_stages_user_write.userwritestage
attrs:
user_creation_mode: always_create
user_type: !Context [user_type, external]
user_path_template:
!Format ["users/%s", !Context [user_type, external]]
- identifiers:
name:
!Format [
"invitation-enrollment-user-login-%s",
!Context [flow_slug, invitation-enrollment-flow],
]
id: user-login-stage
model: authentik_stages_user_login.userloginstage
- identifiers:
target: !KeyOf flow
stage: !KeyOf invitation-stage
order: 5
model: authentik_flows.flowstagebinding
attrs:
evaluate_on_plan: true
re_evaluate_policies: true
- identifiers:
target: !KeyOf flow
stage: !KeyOf prompt-stage-credentials
order: 10
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf prompt-stage-details
order: 15
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf user-write-stage
order: 20
model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf user-login-stage
order: 100
model: authentik_flows.flowstagebinding

View File

@@ -1216,6 +1216,46 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_account_lockdown.accountlockdownstage"
},
"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_stages_account_lockdown.accountlockdownstage_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
}
}
},
{
"type": "object",
"required": [
@@ -5100,6 +5140,11 @@
"format": "uuid",
"title": "Flow device code"
},
"flow_lockdown": {
"type": "string",
"format": "uuid",
"title": "Flow lockdown"
},
"default_application": {
"type": "string",
"format": "uuid",
@@ -6094,6 +6139,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -7757,6 +7806,69 @@
}
}
},
"model_authentik_stages_account_lockdown.accountlockdownstage": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"deactivate_user": {
"type": "boolean",
"title": "Deactivate user",
"description": "Deactivate the user account (set is_active to False)"
},
"set_unusable_password": {
"type": "boolean",
"title": "Set unusable password",
"description": "Set an unusable password for the user"
},
"delete_sessions": {
"type": "boolean",
"title": "Delete sessions",
"description": "Delete all active sessions for the user"
},
"revoke_tokens": {
"type": "boolean",
"title": "Revoke tokens",
"description": "Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
},
"self_service_completion_flow": {
"type": "string",
"format": "uuid",
"title": "Self service completion flow",
"description": "Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted."
}
},
"required": []
},
"model_authentik_stages_account_lockdown.accountlockdownstage_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_accountlockdownstage",
"change_accountlockdownstage",
"delete_accountlockdownstage",
"view_accountlockdownstage"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": {
"type": "object",
"properties": {
@@ -8952,6 +9064,7 @@
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source"
@@ -9084,6 +9197,7 @@
"authentik_providers_ssf.ssfprovider",
"authentik_providers_ws_federation.wsfederationprovider",
"authentik_reports.dataexport",
"authentik_stages_account_lockdown.accountlockdownstage",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
"authentik_stages_source.sourcestage"
@@ -11791,6 +11905,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -14818,6 +14936,22 @@
"format": "uuid"
},
"title": "Webauthn allowed device types"
},
"email_otp_throttling_factor": {
"type": "number",
"title": "Email otp throttling factor"
},
"sms_otp_throttling_factor": {
"type": "number",
"title": "Sms otp throttling factor"
},
"totp_otp_throttling_factor": {
"type": "number",
"title": "Totp otp throttling factor"
},
"static_otp_throttling_factor": {
"type": "number",
"title": "Static otp throttling factor"
}
},
"required": []
@@ -15657,6 +15791,9 @@
"separator",
"hidden",
"static",
"alert_info",
"alert_warning",
"alert_danger",
"ak-locale"
],
"title": "Type"

View File

@@ -73,8 +73,16 @@ entries:
redirect_uris:
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/callback
redirect_uri_type: authorization
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
redirect_uri_type: authorization
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/post_logout_redirect
redirect_uri_type: logout
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/post_logout_redirect
redirect_uri_type: logout
grant_types:
- authorization_code
- implicit
@@ -108,8 +116,16 @@ entries:
redirect_uris:
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/callback
redirect_uri_type: authorization
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
redirect_uri_type: authorization
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/post_logout_redirect
redirect_uri_type: logout
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/post_logout_redirect
redirect_uri_type: logout
grant_types:
- authorization_code
- implicit

View File

@@ -26,6 +26,8 @@ var healthcheckCmd = &cobra.Command{
exitCode := 1
log.WithField("mode", mode).Debug("checking health")
switch strings.ToLower(mode) {
case "allinone":
fallthrough
case "server":
exitCode = check(fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path))
case "worker":

2
go.mod
View File

@@ -7,7 +7,7 @@ require (
beryju.io/radius-eap v0.1.0
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/getsentry/sentry-go v0.46.0
github.com/getsentry/sentry-go v0.46.1
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-openapi/runtime v0.29.4

4
go.sum
View File

@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/getsentry/sentry-go v0.46.1 h1:mZyQFaQYkPxAdDG4HR8gDg6j4CnKYVWt4TF92N7i3XY=
github.com/getsentry/sentry-go v0.46.1/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -31,7 +31,7 @@ function run_authentik {
echo go run ./cmd/server "$@"
fi
;;
worker)
allinone | worker)
if [[ -x "$(command -v authentik)" ]]; then
echo authentik "$@"
else
@@ -79,7 +79,7 @@ function prepare_debug {
apt-get update
apt-get install -y --no-install-recommends krb5-kdc krb5-user krb5-admin-server libkrb5-dev gcc
source "${VENV_PATH}/bin/activate"
uv sync --active --frozen
uv sync --active --locked
touch /unittest.xml
chown authentik:authentik /unittest.xml
}
@@ -105,7 +105,7 @@ elif [[ "$1" == "test-all" ]]; then
prepare_debug
chmod 777 /root
check_if_root_and_run manage test authentik
elif [[ "$1" == "server" ]] || [[ "$1" == "worker" ]]; then
elif [[ "$1" == "allinone" ]] || [[ "$1" == "server" ]] || [[ "$1" == "worker" ]]; then
wait_for_db
check_if_root_and_run "$@"
elif [[ "$1" == "healthcheck" ]]; then

View File

@@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1118.4",
"aws-cdk": "^2.1120.0",
"cross-env": "^10.1.0"
},
"engines": {
@@ -25,9 +25,9 @@
"license": "MIT"
},
"node_modules/aws-cdk": {
"version": "2.1118.4",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1118.4.tgz",
"integrity": "sha512-wJfRQdvb+FJ2cni059mYdmjhfwhMskP+PAB59BL9jhon+jYtjy8X3pbj3uzHgAOJwNhh6jGkP8xq36Cffccbbw==",
"version": "2.1120.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1120.0.tgz",
"integrity": "sha512-vDVa0IX0FhizARdY/GLSParFglKbdHCIhM8IDmynrAv9w8uLLljzWMeLUOhC1XpMErDZ/npYEihAOjfKxTaMIw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -7,7 +7,7 @@
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
},
"devDependencies": {
"aws-cdk": "^2.1118.4",
"aws-cdk": "^2.1120.0",
"cross-env": "^10.1.0"
},
"engines": {

View File

@@ -200,7 +200,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=bind,target=packages/django-postgres-cache,src=packages/django-postgres-cache \
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
uv sync --locked --no-install-project --no-dev
# Stage: Run
FROM python-base AS final-image

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