Compare commits

..

93 Commits

Author SHA1 Message Date
authentik-automation[bot]
f83d3a19d0 release: 2025.12.0-rc3 2026-01-06 19:51:06 +00:00
authentik-automation[bot]
ef59ff1856 web: Fix user library colors, modal z-indexes, table progress bars (cherry-pick #19152 to version-2025.12) (#19174)
Cherry-pick #19152 to version-2025.12 (with conflicts)
This cherry-pick has conflicts that need manual resolution.

Original PR: #19152
Original commit: 3838150

Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2026-01-06 19:15:39 +00:00
authentik-automation[bot]
4966225282 outpost/proxyv2: reduce max number of postgres connections (cherry-pick #19211 to version-2025.12) (#19214)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-01-06 19:20:39 +01:00
authentik-automation[bot]
2b8765d0aa web: fix promoted source button hover losing blue color (cherry-pick #19048 to version-2025.12) (#19100)
web: fix promoted source button hover losing blue color (#19048)

Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-06 18:10:50 +00:00
authentik-automation[bot]
d60d06f958 core: handle deserialization errors from FileField migration (cherry-pick #19067 to version-2025.12) (#19168)
Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-06 18:48:35 +01:00
authentik-automation[bot]
1a3f268476 admin/files: support %(theme)s variable in media file paths (cherry-pick #19108 to version-2025.12) (#19213)
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-01-06 14:32:12 +01:00
authentik-automation[bot]
515a855c40 web/admin: adjust sync threshold, add tooltip (cherry-pick #19131 to version-2025.12) (#19175)
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-06 13:48:37 +01:00
authentik-automation[bot]
16d65b8d12 rbac: Add show all to roles tab, add role tab to groups (cherry-pick #19097 to version-2025.12) (#19199)
rbac: Add show all to roles tab, add role tab to groups (#19097)

* improve sort order and inherit visual

* Update web/src/admin/groups/GroupViewPage.ts




* Update web/src/admin/users/UserViewPage.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* setup include inherited roles and fix returning nothing

* update api calls

* fix rendering error

* do not use set

* change from exception handling

* go off query param

* fix wording

* fix linting error for new group api structure

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2026-01-06 04:01:06 +00:00
authentik-automation[bot]
bfe928df18 web: Defer table refresh, visibility checks. (cherry-pick #19194 to version-2025.12) (#19198)
* web: Fix user library colors, modal z-indexes, table progress bars (#19152)

* Fix progress bar fade out, positioning, labels.

* Export parts. Fix z-index, colors.

* Fix clickable area.

* Ignore clickable icons.

* web: Defer table refresh, visibility checks. (#19194)

Fix types, args.

---------

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-05 23:38:47 +00:00
authentik-automation[bot]
c447bbe6c8 web: Merge branch -- Stale notifications, synchronized context objects, rendering fixes (cherry-pick #19141 to version-2025.12) (#19197)
Cherry-pick #19141 to version-2025.12 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #19141
Original commit: 2c813cbe03

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-05 23:13:08 +00:00
authentik-automation[bot]
1c0a3f95df core: add prettier failure on duplicate group names (cherry-pick #18941 to version-2025.12) (#19193)
core: add prettier failure on duplicate group names (#18941)

* core: add prettier failure on duplicate group names

* add db_alias




* lint

* migrate to system migration



* fix error on empty database



* returning a count of 0 still takes 1 row :P

---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-05 18:44:03 +01:00
authentik-automation[bot]
8a6116ab79 lifecycle: fix migration conn_options for psycopg connection (cherry-pick #19134 to version-2025.12) (#19186)
Co-authored-by: Duncan Tasker <72562945+D-Tasker207@users.noreply.github.com>
fix migration conn_options for psycopg connection (#19134)
2026-01-05 15:21:49 +01:00
authentik-automation[bot]
430010fbea website/docs: remove duplicates in slo docs (cherry-pick #19170 to version-2025.12) (#19177)
website/docs: remove duplicates in slo docs (#19170)

remove duplicated points in the iframe mode points in slo docs

Co-authored-by: Adithya S Narasinghe <adithyasnarasinghe@gmail.com>
2026-01-04 21:41:54 +00:00
authentik-automation[bot]
079b575a45 web: fix slug auto-updating when editing existing applications (cherry-pick #19169 to version-2025.12) (#19173)
web: fix slug auto-updating when editing existing applications (#19169)

Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-04 04:23:21 +00:00
authentik-automation[bot]
b2ca887d59 website/docs: endpoint agent release notes (cherry-pick #19042 to version-2025.12) (#19146)
website/docs: endpoint agent release notes (#19042)

* website/docs: endpoint agent release notes



* Apply suggestion from @dominic-r



* rename, update



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-03 21:35:39 +01:00
authentik-automation[bot]
d7b30ad0d7 web: Token Form Fixes (cherry-pick #19121 to version-2025.12) (#19153)
web: Token Form Fixes (#19121)

* Fix autofocus attribute.

* web: Fix label alignment, focus handlers, edit states.

* Tidy date functions.

* Use Dates over strings.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-03 02:20:12 +00:00
authentik-automation[bot]
b084ace1dd web/user: fix consent delete form missing details (cherry-pick #19147 to version-2025.12) (#19156)
web/user: fix consent delete form missing details (#19147)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-02 12:27:56 +01:00
authentik-automation[bot]
b3e45cdf1a website/docs: fix build (cherry-pick #19148 to version-2025.12) (#19151)
website/docs: fix build (#19148)

* ensure we never throw errors in the browser



* cleaner



* rework



* fix misleading variable



* Tidy behavior.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2026-01-01 18:53:33 +00:00
authentik-automation[bot]
8132e1f7d9 web: Capitalize language display names, code owner fix (cherry-pick #19119 to version-2025.12) (#19122)
web: Capitalize language display names, code owner fix (#19119)

* web: Capitalize locale display names.

* Fix broad code owner.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-30 20:50:42 -05:00
authentik-automation[bot]
149dccf244 web: fix file search input not resetting results properly (cherry-pick #19034 to version-2025.12) (#19075)
web: fix file search input not resetting results properly (#19034)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-30 20:18:23 +00:00
authentik-automation[bot]
b5e4797761 web: Fix stale flow background (cherry-pick #19015 to version-2025.12) (#19101)
web: Fix stale flow background (#19015)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-30 19:31:58 +00:00
authentik-automation[bot]
be670d6253 web: Fix Impersonation, Lit Reactive Controller Contexts (cherry-pick #19114 to version-2025.12) (#19117)
web: Fix Impersonation, Lit Reactive Controller Contexts (#19114)

* web: Fix issue where impersonation does not trigger updates.

* web: Fix issues surrounding abort controller types, lifecycle.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-30 17:06:25 +00:00
authentik-automation[bot]
71060ea4e7 website/docs: release notes: Add more integrations (cherry-pick #19109 to version-2025.12) (#19115)
website/docs: release notes: Add more integrations (#19109)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-30 08:50:05 +00:00
authentik-automation[bot]
f60f38280c website/docs: endpoints: mention connector key required for stage to work (cherry-pick #19084 to version-2025.12) (#19095)
website/docs: endpoints: mention connector key required for stage to work (#19084)

keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
  if not keypair:
      return self.executor.stage_ok()  # < --- skips the stage

took me a bit of time to find this and yea

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 20:59:38 +01:00
authentik-automation[bot]
418deeb332 website/docs: endpoint devices: add path to macos setup (cherry-pick #19093 to version-2025.12) (#19099)
website/docs: endpoint devices: add path to macos setup (#19093)

* Add path

* Update macos.md



---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-29 20:59:18 +01:00
authentik-automation[bot]
619c77c27e web/admin: use consistent icon for inactive user status (cherry-pick #19032 to version-2025.12) (#19035)
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 08:26:45 -08:00
authentik-automation[bot]
ddfddb49da website/docs: endpoint devices: update features table (cherry-pick #19094 to version-2025.12) (#19098)
website/docs: endpoint devices: update features table (#19094)

* Update table

* Remove wording

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-29 13:35:29 +00:00
authentik-automation[bot]
dbbb1870b7 website/docs: rel notes .12: add wallos (cherry-pick #19063 to version-2025.12) (#19096)
website/docs: rel notes .12: add wallos (#19063)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 12:43:03 +00:00
authentik-automation[bot]
5b43301206 docs/release notes: update 2025.12 release notes (cherry-pick #19043 to version-2025.12) (#19046)
docs/release notes: update 2025.12 release notes (#19043)

* Add links and tags

* Update website/docs/releases/2025/v2025.12.md




---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 05:09:34 +00:00
authentik-automation[bot]
d915d1a94a web/admin: fix button alignment on user view page (cherry-pick #19079 to version-2025.12) (#19081)
web/admin: fix button alignment on user view page (#19079)

* web/admin: fix button alignment on user view page



* fix width



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-28 19:58:29 +01:00
authentik-automation[bot]
786497790a internal: update TLS Suite (cherry-pick #19076 to version-2025.12) (#19078)
internal: update TLS Suite (#19076)

* internal: update TLS Suite



* disable chacha20 due to fips



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-28 15:19:46 +01:00
authentik-automation[bot]
56c899cf21 blueprints: set enrollment token key (cherry-pick #19061 to version-2025.12) (#19062)
blueprints: set enrollment token key (#19061)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 23:42:27 +01:00
authentik-automation[bot]
943f22e5a9 blueprints: fix deadlock and task context error in MetaApplyBlueprint (cherry-pick #19033 to version-2025.12) (#19068)
blueprints: fix deadlock and task context error in MetaApplyBlueprint (#19033)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-12-26 23:41:22 +01:00
authentik-automation[bot]
11b45689f4 blueprints: fix flaky tests (cherry-pick #19002 to version-2025.12) (#19059)
blueprints: fix flaky tests (#19002)

* blueprints: attempt to fix tests



* fix postgres debug logging



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 16:11:21 +01:00
authentik-automation[bot]
87f443532f endpoints/devices: cleanup (cherry-pick #19047 to version-2025.12) (#19057)
* endpoints/devices: cleanup  (#19047)

* endpoints: make device token internally managed

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

* fix text and defaults for agent

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

* re-org some code

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

---------

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

* fix

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 15:53:23 +01:00
authentik-automation[bot]
0c672a0c37 lib/sync: fix sync_dispatch (cherry-pick #19053 to version-2025.12) (#19056)
lib/sync: fix sync_dispatch (#19053)

* fix: add missing call to all on self.schedules

Fixes #19051

* fix: change the name of syncOutgoingTriggerMode ak-radio-input

Fixes #19052

Co-authored-by: Amélie Lilith Krejčí <krejcar25@blep.cz>
2025-12-26 14:20:34 +01:00
authentik-automation[bot]
dfd11ceb57 events: notifications live update (cherry-pick #18980 to version-2025.12) (#18990)
events: notifications live update (#18980)

* this has been broken for a while but no one noticed...? cc @rissson



* send WS broadcast for new notifications



* add tests



* better layout



* fix e2e tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-24 14:29:14 +01:00
authentik-automation[bot]
d865b7fd87 core: use chunked_queryset for expired message deletion (cherry-pick #19028 to version-2025.12) (#19031)
core: use chunked_queryset for expired message deletion (#19028)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-24 01:56:23 +01:00
authentik-automation[bot]
aa8a6b9c43 web: Locale selector UI fixes (cherry-pick #18972 to version-2025.12) (#19027)
web: Locale selector UI fixes (#18972)

* Fix alignment, focus.

* Clean up.

* Tidy click area.

* Fix compatibility mode.

* Fix alignment.

* Fix issues surrounding labels, alignment, consistency.

* Update web/src/common/ui/locale/format.ts



* Tidy hover states.

* Tidy.

* Clean up parsing.

* Tidy comments, usage.

* Always use script naming over region.

* Remove unused.

* Spacing.

---------

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-23 16:22:32 -05:00
Jens L.
fe5313f42e ci: ensure disk space is available (#19025)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-23 17:57:03 +01:00
authentik-automation[bot]
499f739e2b website/docs: Prioritize "Release Candidate" over "Current Release" (cherry-pick #18975 to version-2025.12) (#19022)
website/docs: Prioritize "Release Candidate" over "Current Release" (#18975)

Normalize labels.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-23 16:28:23 +00:00
authentik-automation[bot]
4e0e738823 web/admin: prevent file upload attempt when backend not managed (cherry-pick #18646 to version-2025.12) (#19021)
web/admin: prevent file upload attempt when backend not managed (#18646)

* web/admin: prevent file upload attempt when backend not managed



* wip

* fixup

* rework



* format



* add check for reports



* fix delete table for data exports missing details



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-23 14:47:24 +01:00
authentik-automation[bot]
24360bf306 web: fix Open button selecting row instead of navigating (cherry-pick #18992 to version-2025.12) (#19003)
web: fix Open button selecting row instead of navigating (#18992)

the `isEventTargetingListener()` function only checked the click target and the immediate parent for interactive elements (like links, buttons and more). when clicking the icon inside the Open button, the DOM structure is:

<a href=...>  <--- 2 levels up, never checked
<pf-tooltip>  <--- immediate parent, not interactive
<i> <---- click target, not interactive

Because <i> and <pf-tooltip> did not match the interactive elements query, the function returned false which caused the table rowClickListener to continue with row selection isntead of allowing the click.

The fix is to update the function to to traverse (up) the entire dom tree from the click target to the listener element (the table cell) and check for each ancestor for the interactive elements.

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-22 23:48:07 +01:00
authentik-automation[bot]
6fad3c2bbd enterprise/search: add static autocomplete structure (cherry-pick #19008 to version-2025.12) (#19011)
enterprise/search: add static autocomplete structure (#19008)

* enterprise/search: add static autocomplete structure



* add recursive structured for context



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-22 23:47:44 +01:00
authentik-automation[bot]
2cf20de7ec website/docs: improve endpoint devices docs (cherry-pick #19007 to version-2025.12) (#19012)
website/docs: improve endpoint devices docs (#19007)

* Remove sudo auth sections

* Add firefox extension link

* Add chrome extension

* Update release notes

* Remove link

* Fix link

* Fix release note wording

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-22 20:50:05 +00:00
authentik-automation[bot]
3d8d3bb8ce enterprise/reports: improve export list, confirmation (cherry-pick #18981 to version-2025.12) (#19010)
enterprise/reports: improve export list, confirmation (#18981)

* enterprise/reports: use verbose name for model label



* add confirmation for export



* update docs



* remove duplicated api



* fix duplicate



* fix search query not updated



* exclude page & page size



* improve query display



* fix user display



* exclude unset params



* Apply suggestions from code review




* more code style



* format



* fix types



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-22 20:53:51 +01:00
authentik-automation[bot]
80bcbe4885 web/admin: Fix haveibeenpwned link in PasswordPolicyForm (cherry-pick #18984 to version-2025.12) (#18989)
web/admin: Fix haveibeenpwned link in PasswordPolicyForm (#18984)

web: Fix haveibeenpwned link in PasswordPolicyForm

Co-authored-by: Henry Skrtich <1214484+hskrtich@users.noreply.github.com>
2025-12-21 15:46:52 +01:00
authentik-automation[bot]
32e4782ed8 web/admin: fix dark theme on map (cherry-pick #18985 to version-2025.12) (#18987)
web/admin: fix dark theme on map (#18985)

web/admin: fix dark theme on map broken

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-21 15:44:39 +01:00
authentik-automation[bot]
613a51bdbb web/admin: fix endpoints user binding (cherry-pick #18935 to version-2025.12) (#18952)
web/admin: fix endpoints user binding (#18935)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-19 17:53:22 +01:00
Teffen Ellis
1c6de43701 website/docs: Backport version picker updates. (#18964)
Fix import path.

Show unlisted entries if release.

Fix sidebar rendering.

Fix positioning of pre-release note. Tidy phrasing.

Clarify pre-release vs draft.

website/docs: Fix version parsing.
2025-12-19 17:07:56 +01:00
authentik-automation[bot]
6771530025 web/admin: add UI copy to RBAC modal (cherry-pick #18917 to version-2025.12) (#18962)
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-19 16:49:32 +01:00
authentik-automation[bot]
5876f367bc website/docs: add note to active directory source doc (cherry-pick #18787 to version-2025.12) (#18966)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-19 16:48:20 +01:00
authentik-automation[bot]
e263af2dd9 web/elements: progress-bar and table loading header (cherry-pick #18934 to version-2025.12) (#18939)
web/elements: progress-bar and table loading header (#18934)

* add ak-progress-bar



* make intermediate smaller



* add table



* hide table overflow



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-19 00:06:02 +01:00
authentik-automation[bot]
3a59911a2b website/docs: release notes: add endpoint device links to 2025.12 notes (cherry-pick #18940 to version-2025.12) (#18947)
website/docs: release notes: add endpoint device links to 2025.12 notes (#18940)

Add links to release notes

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-18 21:59:53 +00:00
authentik-automation[bot]
bbf31e99c3 website/docs: endpoint devices (cherry-pick #18634 to version-2025.12) (#18946)
website/docs: endpoint devices (#18634)

* Initial

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Apply suggestions from code review




* Apply suggestions from code review




* Apply suggestions

* Apply suggestions

* Apply suggestions from code review




* Apply suggestions from code review




* WIP

* Apply suggestions from code review




* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* fixes



* WIP

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Fix anchor

* Update website/docs/endpoint-devices/index.mdx




* WIP

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-12-18 19:40:56 +00:00
authentik-automation[bot]
9d5bd42f3e stages/identification: replace sleep with make_password (cherry-pick #18883 to version-2025.12) (#18943)
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-18 19:10:01 +01:00
authentik-automation[bot]
e721dae6da web/flow: Fix spurious double submit on ak-stage-autosubmit (cherry-pick #18727 to version-2025.12) (#18933)
web/flow: Fix spurious double submit  on ak-stage-autosubmit (#18727)

* Fix double submission on ak-stage-autosubmit

* use updated correctly



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Victor Nawothnig <dminuoso@icloud.com>
Co-authored-by: Victor Nawothnig <Victor.Nawothnig+git@icloud.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-12-18 16:25:19 +01:00
authentik-automation[bot]
af3106b144 packages/ak-guardian: cast safely (cherry-pick #18929 to version-2025.12) (#18931)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-18 16:24:59 +01:00
authentik-automation[bot]
5b55103575 tests/e2e: handle StaleElementReferenceException in parse_json_content (cherry-pick #18842 to version-2025.12) (#18919)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-18 14:08:49 +01:00
authentik-automation[bot]
ee4ecf929f release: 2025.12.0-rc2 2025-12-17 22:03:04 +00:00
authentik-automation[bot]
8336556a6f root: fix docker-compose data mount (cherry-pick #18903 to version-2025.12) (#18918)
root: fix docker-compose data mount (#18903)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 19:53:44 +00:00
authentik-automation[bot]
709aad1d3b core/groups: optimize prefetch queries to fetch only required fields (cherry-pick #18448 to version-2025.12) (#18914)
core/groups: optimize prefetch queries to fetch only required fields (#18448)

Co-authored-by: João C. Fernandes <joaocfernandes@gmail.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:50:44 +00:00
authentik-automation[bot]
fb7ab4937c web/admin: reword some things on the device view page (cherry-pick #18785 to version-2025.12) (#18913)
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-17 17:55:14 +01:00
authentik-automation[bot]
5df1726d80 website/docs: 2025.12: remove superfluous changes (cherry-pick #18910 to version-2025.12) (#18912)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:25:13 +01:00
authentik-automation[bot]
9fdb568843 ci/release-tag: checkout correct branch for make test-docker (cherry-pick #18880 to version-2025.12) (#18911)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 16:24:56 +01:00
authentik-automation[bot]
8e76f56f89 api: fix latest version for public schema (cherry-pick #18902 to version-2025.12) (#18909)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix latest version for public schema (#18902)
2025-12-17 16:14:16 +01:00
authentik-automation[bot]
05d3791577 website/docs: added list of Int Guide contributors (also edited frontmatter) (cherry-pick #18888 to version-2025.12) (#18907)
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 16:10:50 +01:00
authentik-automation[bot]
d00dd7eb90 api: fix page_size with invalid query param (cherry-pick #18879 to version-2025.12) (#18908)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix page_size with invalid query param (#18879)
2025-12-17 16:10:07 +01:00
authentik-automation[bot]
8d2e404017 stages/authenticator_*: fix code input field not string (cherry-pick #18875 to version-2025.12) (#18906)
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix code input field not string (#18875)
2025-12-17 16:04:32 +01:00
authentik-automation[bot]
95eb2af25e tasks/middleware: close connections on worker status update database error (cherry-pick #18881 to version-2025.12) (#18905)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
close connections on worker status update database error (#18881)
2025-12-17 15:46:53 +01:00
authentik-automation[bot]
cbc00a501b web: fix file upload form (cherry-pick #18808 to version-2025.12) (#18884)
Co-authored-by: Dominic R <dominic@sdko.org>
fix file upload form (#18808)
2025-12-17 14:02:31 +01:00
authentik-automation[bot]
480645d897 website/docs: add icon info to style guide (cherry-pick #18832 to version-2025.12) (#18837)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 14:02:12 +01:00
authentik-automation[bot]
997c767c95 web/admin: endpoint: change wording and add helper text (cherry-pick #18871 to version-2025.12) (#18890)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <teffen@sister.software>
2025-12-17 14:00:02 +01:00
authentik-automation[bot]
5a54e1dc9a web: fix notification counter (cherry-pick #18781 to version-2025.12) (#18882)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
fix notification counter (#18781)
2025-12-16 18:44:22 +01:00
authentik-automation[bot]
49b1952566 website/docs: Add docs for passkey autofill (WebauthN Conditional UI) (cherry-pick #18805 to version-2025.12) (#18870)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-16 18:11:02 +01:00
authentik-automation[bot]
e73edc2fce web/admin: fix read-only provider selection for application form (cherry-pick #18768 to version-2025.12) (#18803)
Co-authored-by: Dominic R <dominic@sdko.org>
fix read-only provider selection for application form (#18768)
2025-12-16 18:10:49 +01:00
authentik-automation[bot]
409652e874 web: add custom message with links for empty data export list (cherry-pick #18830 to version-2025.12) (#18876)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
2025-12-16 18:09:52 +01:00
authentik-automation[bot]
1d3fb6431f website/docs: 2025.10.3 release notes (cherry-pick #18868 to version-2025.12) (#18873)
website/docs: 2025.10.3 release notes (#18868)

* website/docs: 2025.10.3 release notes



* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-16 17:06:16 +01:00
authentik-automation[bot]
76cfada60f website/docs: adjust RBAC-related details in 2025.12 release notes (cherry-pick #18863 to version-2025.12) (#18869)
website/docs: adjust RBAC-related details in 2025.12 release notes (#18863)

* website/docs: adjust RBAC-related details in 2025.12 release notes

* adjust wording




---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-16 10:39:50 -05:00
authentik-automation[bot]
ac45f80551 outposts: fix permission errors for related certificates (cherry-pick #18861 to version-2025.12) (#18866)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix permission errors for related certificates (#18861)
2025-12-16 15:23:49 +01:00
authentik-automation[bot]
5ea85f086a web/admin/rbac: misc object permission fixes (cherry-pick #18859 to version-2025.12) (#18865)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
fixes (#18859)
2025-12-16 14:54:31 +01:00
authentik-automation[bot]
e3f657746c rbac: alter migrated direct permission roles (cherry-pick #18860 to version-2025.12) (#18864)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2025-12-16 13:56:35 +01:00
authentik-automation[bot]
001b56e2cc release: 2025.12.0-rc1 2025-12-16 04:59:24 +00:00
Marcelo Elizeche Landó
ecbfd2f0de add skip s3_test_server_available to TestResolveFileUrlS3Backend 2025-12-16 01:29:40 -03:00
authentik-automation[bot]
45753397e1 admin/files: fix get_objects_for_user queryset argument in FileUsedByView (cherry-pick #18845 to version-2025.12) (#18847)
admin/files: fix get_objects_for_user queryset argument in FileUsedByView (#18845)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-16 01:15:30 +00:00
Marcelo Elizeche Landó
dc6fe1dafe core: skip s3 tests if endpoint isn't available (#18841)
skip s3 tests if endpoint isn't available
2025-12-15 20:38:01 -03:00
authentik-automation[bot]
d5e8f2f416 admin/files: revert add check for /media existence (#18636) (cherry-pick #18829 to version-2025.12) (#18838)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-15 16:45:43 +01:00
authentik-automation[bot]
d73af5a2b4 packages/django-dramatiq-postgres: broker: close django connections on consumer close (cherry-pick #18833 to version-2025.12) (#18836)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Norman Ziebal <norman.ziebal@mail.schwarz>
close django connections on consumer close (#18833)
2025-12-15 15:01:08 +01:00
authentik-automation[bot]
7042f2bba8 core: list applications fix (cherry-pick #18798 to version-2025.12) (#18828)
Co-authored-by: Ryan Pesek <44002516+ryanpesek@users.noreply.github.com>
fix (#18798)
2025-12-15 12:47:20 +00:00
authentik-automation[bot]
efeb260fa8 tests/e2e: retry detached shadow roots (cherry-pick #18796 to version-2025.12) (#18799)
tests/e2e: retry detached shadow roots (#18796)

tests(e2e): retry detached shadow roots

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-14 00:50:23 +01:00
authentik-automation[bot]
29e90092ea website/release notes: Update v2025.12 release notes (cherry-pick #18797 to version-2025.12) (#18800)
website/release notes: Update v2025.12 release notes (#18797)

* website/release notes: Update v2025.12 release notes



* fix linting

---------

Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-13 00:03:26 +00:00
Marcelo Elizeche Landó
0abe865023 Revert "Update docker compose command to start postgresql with s3"
This reverts commit 220c65a41a.
2025-12-12 20:59:44 -03:00
Marcelo Elizeche Landó
220c65a41a Update docker compose command to start postgresql with s3
Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-12 19:47:24 -03:00
1124 changed files with 132340 additions and 33195 deletions

View File

@@ -18,16 +18,16 @@ runs:
run: |
sudo apt-get remove --purge man-db
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v5
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
with:
enable-cache: true
- name: Setup python
if: ${{ contains(inputs.dependencies, 'python') }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
with:
python-version-file: "pyproject.toml"
- name: Install Python deps
@@ -36,7 +36,7 @@ runs:
run: uv sync --all-extras --dev --frozen
- name: Setup node
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
with:
node-version-file: web/package.json
cache: "npm"
@@ -44,20 +44,20 @@ runs:
registry-url: 'https://registry.npmjs.org'
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
with:
go-version-file: "go.mod"
- name: Setup docker cache
if: ${{ contains(inputs.dependencies, 'runtime') }}
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
with:
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- name: Setup dependencies
if: ${{ contains(inputs.dependencies, 'runtime') }}
shell: bash
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
docker compose -f .github/actions/setup/docker-compose.yml up -d
cd web && npm i
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}

View File

@@ -11,6 +11,11 @@ services:
ports:
- 5432:5432
restart: always
redis:
image: docker.io/library/redis:7
ports:
- 6379:6379
restart: always
s3:
container_name: s3
image: docker.io/zenko/cloudserver
@@ -22,7 +27,7 @@ services:
- 8020:8000
volumes:
- s3-data:/usr/src/app/localData
- s3-metadata:/usr/src/app/localMetadata
- s3-metadata:/usr/scr/app/localMetadata
restart: always
volumes:

View File

@@ -12,11 +12,11 @@ runs:
with:
flags: ${{ inputs.flags }}
use_oidc: true
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
with:
flags: ${{ inputs.flags }}
file: unittest.xml
use_oidc: true
report_type: test_results
- name: PostgreSQL Logs
shell: bash
run: |

View File

@@ -42,9 +42,9 @@ jobs:
# Needed for checkout
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -56,13 +56,13 @@ jobs:
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -73,7 +73,7 @@ jobs:
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Setup node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -85,7 +85,6 @@ jobs:
id: push
with:
context: .
file: lifecycle/container/Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
@@ -96,7 +95,7 @@ jobs:
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -49,7 +49,7 @@ jobs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -69,7 +69,7 @@ jobs:
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -79,25 +79,25 @@ jobs:
image-name: ${{ inputs.image_name }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@a39573caa37b6a8a03302d43b57c3f48635096e2 # v2
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # v2
id: build
with:
tags: ${{ matrix.tag }}
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}

View File

@@ -22,10 +22,10 @@ jobs:
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"
@@ -46,7 +46,7 @@ jobs:
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install Dependencies
working-directory: website/
run: npm ci
@@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -41,7 +41,7 @@ jobs:
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
with:
path: |
${{ github.workspace }}/website/api/.docusaurus
@@ -55,7 +55,7 @@ jobs:
env:
NODE_ENV: production
run: npm run build -w api
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
with:
name: api-docs
path: website/api/build
@@ -66,12 +66,12 @@ jobs:
- lint
- build
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

@@ -21,10 +21,10 @@ jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: lifecycle/aws/package.json
cache: "npm"

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate docs

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install dependencies
working-directory: website/
run: npm ci
@@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -48,8 +48,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -69,13 +69,13 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -85,7 +85,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -101,7 +101,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -18,11 +18,11 @@ jobs:
- version-2025-4
- version-2025-2
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"
mkdir -p $dir
cd $dir
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
${current}/scripts/test_docker.sh

View File

@@ -37,7 +37,7 @@ jobs:
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
@@ -45,7 +45,7 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
@@ -71,7 +71,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 0
- name: checkout stable
@@ -84,7 +84,7 @@ jobs:
# Current version family based on
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
if [[ -n $current_version_family ]]; then
prev_stable="version/${current_version_family}"
prev_stable=$current_version_family
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
@@ -136,7 +136,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
@@ -156,7 +156,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
@@ -187,25 +187,21 @@ jobs:
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
- name: ldap
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: ws-fed
glob: tests/e2e/test_provider_ws_fed*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
- name: endpoints
glob: tests/e2e/test_endpoints_*
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
@@ -225,54 +221,6 @@ jobs:
if: ${{ always() }}
with:
flags: e2e
test-openid-conformance:
name: test-openid-conformance (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
- uses: ./.github/actions/test-results
if: ${{ always() }}
with:
flags: conformance
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/
ci-core-mark:
if: always()
needs:
@@ -312,7 +260,7 @@ jobs:
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables

View File

@@ -21,8 +21,8 @@ jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- name: Prepare and generate API
@@ -42,8 +42,8 @@ jobs:
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- name: Setup authentik env
@@ -86,13 +86,13 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -102,7 +102,7 @@ jobs:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -114,7 +114,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
@@ -122,7 +122,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
@@ -145,13 +145,13 @@ jobs:
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -31,8 +31,8 @@ jobs:
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
@@ -48,8 +48,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -76,8 +76,8 @@ jobs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -33,7 +33,7 @@ jobs:
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
@@ -42,7 +42,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@@ -20,13 +20,13 @@ jobs:
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: uv run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -17,7 +17,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
env:
GH_APP_ID: ${{ secrets.GH_APP_ID }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ steps.app-token.outcome != 'skipped' }}
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Cleanup
run: |

View File

@@ -31,10 +31,10 @@ jobs:
- packages/docusaurus-config
- packages/esbuild-plugin-live-reload
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"

View File

@@ -24,7 +24,7 @@ jobs:
language: ["go", "javascript", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Initialize CodeQL

View File

@@ -26,5 +26,5 @@ jobs:
image: semgrep/semgrep
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: semgrep ci

View File

@@ -34,7 +34,7 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: "${{ steps.app-token.outputs.token }}"
@@ -62,7 +62,7 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: ${{ steps.generate_token.outputs.token }}
@@ -73,7 +73,7 @@ jobs:
- name: Bump version
run: "make bump version=${{ inputs.next_version }}.0-rc1"
- name: Create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: release-bump-${{ inputs.next_version }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
environment: internal-production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
- run: |

View File

@@ -31,11 +31,11 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -44,7 +44,7 @@ jobs:
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -58,7 +58,7 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: true
with:
@@ -83,14 +83,14 @@ jobs:
- radius
- rac
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -103,12 +103,12 @@ jobs:
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Docker Login Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -121,10 +121,10 @@ jobs:
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
@@ -146,11 +146,11 @@ jobs:
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -186,7 +186,7 @@ jobs:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
@@ -202,7 +202,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
@@ -218,7 +218,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -232,7 +232,7 @@ jobs:
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -52,7 +52,7 @@ jobs:
needs:
- check-inputs
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
- run: make test-docker
@@ -74,7 +74,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
token: "${{ steps.app-token.outputs.token }}"
@@ -122,7 +122,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/helm"
token: "${{ steps.app-token.outputs.token }}"
@@ -134,7 +134,7 @@ jobs:
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
./scripts/helm-docs.sh
- name: Create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -164,7 +164,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/version"
token: "${{ steps.app-token.outputs.token }}"
@@ -189,7 +189,7 @@ jobs:
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
mv version.new.json version.json
- name: Create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}

View File

@@ -25,11 +25,11 @@ jobs:
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
@@ -44,7 +44,7 @@ jobs:
make web-check-compile
- name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

3
.gitignore vendored
View File

@@ -211,5 +211,4 @@ source_docs/
/vendor/
### Docker ###
tests/openid_conformance/exports/*.zip
compose.override.yml
docker-compose.override.yml

View File

@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
lifecycle/container/ @goauthentik/infrastructure
Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure

View File

@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -76,9 +76,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 AS uv
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.14.2-slim-trixie-fips@sha256:46c0658052e43ad303da39e461ad106c499a03fabd3512d05ff586e506507242 AS python-base
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
@@ -114,7 +114,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
# postgresql
libpq-dev \
# python-kadmin-rs
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
clang libkrb5-dev sccache \
# xmlsec
libltdl-dev && \
curl https://sh.rustup.rs -sSf | sh -s -- -y
@@ -156,11 +156,7 @@ WORKDIR /
RUN apt-get update && \
apt-get upgrade -y && \
# Required for runtime
apt-get install -y --no-install-recommends \
libpq5 libmaxminddb0 ca-certificates \
krb5-multidev libkrb5-3 libkdb5-10 libkadm5clnt-mit12 \
heimdal-multidev libkadm5clnt7t64-heimdal \
libltdl7 libxslt1.1 && \
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
pip3 install --no-cache-dir --upgrade pip && \

143
Makefile
View File

@@ -9,45 +9,28 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
PY_SOURCES = authentik packages tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED_INPLACE = sed -i ''
else
SED_INPLACE = sed -i
endif
GEN_API_TS = gen-ts-api
GEN_API_PY = gen-py-api
GEN_API_GO = gen-go-api
BREW_LDFLAGS :=
BREW_CPPFLAGS :=
BREW_PKG_CONFIG_PATH :=
UV := uv
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
# For macOS users, add the libxml2 installed from brew libxmlsec1 to the build path
# to prevent SAML-related tests from failing and ensure correct pip dependency compilation
ifeq ($(UNAME_S),Darwin)
# Only add for brew users who installed libxmlsec1
BREW_EXISTS := $(shell command -v brew 2> /dev/null)
ifdef BREW_EXISTS
LIBXML2_EXISTS := $(shell brew list libxml2 2> /dev/null)
ifdef LIBXML2_EXISTS
_xml_pref := $(shell brew --prefix libxml2)
BREW_LDFLAGS += -L${_xml_pref}/lib
BREW_CPPFLAGS += -I${_xml_pref}/include
BREW_PKG_CONFIG_PATH = ${_xml_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
KRB5_EXISTS := $(shell brew list krb5 2> /dev/null)
ifdef KRB5_EXISTS
_krb5_pref := $(shell brew --prefix krb5)
BREW_LDFLAGS += -L${_krb5_pref}/lib
BREW_CPPFLAGS += -I${_krb5_pref}/include
BREW_PKG_CONFIG_PATH = ${_krb5_pref}/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
UV := LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv
endif
# These functions are only evaluated when called in specific targets
LIBXML2_EXISTS = $(shell brew list libxml2 2> /dev/null)
KRB5_EXISTS = $(shell brew list krb5 2> /dev/null)
LIBXML2_LDFLAGS = -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
LIBXML2_CPPFLAGS = -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
LIBXML2_PKG_CONFIG = $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
KRB_PATH =
ifneq ($(KRB5_EXISTS),)
KRB_PATH = PATH="$(shell brew --prefix krb5)/sbin:$(shell brew --prefix krb5)/bin:$$PATH"
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
@@ -66,46 +49,47 @@ go-test:
go test -timeout 0 -v -race -cover ./...
test: ## Run the server tests and produce a coverage report (locally)
$(UV) run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
$(UV) run coverage html
$(UV) run coverage report
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
uv run coverage html
uv run coverage report
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
$(UV) run black $(PY_SOURCES)
$(UV) run ruff check --fix $(PY_SOURCES)
uv run black $(PY_SOURCES)
uv run ruff check --fix $(PY_SOURCES)
lint-codespell: ## Reports spelling errors.
$(UV) run codespell -w
uv run codespell -w
lint: ci-bandit ## Lint the python and golang sources
lint: ## Lint the python and golang sources
uv run bandit -c pyproject.toml -r $(PY_SOURCES)
golangci-lint run -v
core-install:
ifdef ($(BREW_EXISTS))
ifneq ($(LIBXML2_EXISTS),)
# Clear cache to ensure fresh compilation
$(UV) cache clean
uv cache clean
# Force compilation from source for lxml and xmlsec with correct environment
$(UV) sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
LDFLAGS="$(LIBXML2_LDFLAGS)" CPPFLAGS="$(LIBXML2_CPPFLAGS)" PKG_CONFIG_PATH="$(LIBXML2_PKG_CONFIG)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
else
$(UV) sync --frozen
uv sync --frozen
endif
migrate: ## Run the Authentik Django server's migrations
$(UV) run python -m lifecycle.migrate
uv run python -m lifecycle.migrate
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn:
cd lifecycle/aws && npm i && $(UV) run npm run 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
uv run ak server
run-worker: ## Run the main authentik worker process
$(UV) run ak worker
uv run ak worker
core-i18n-extract:
$(UV) run ak makemessages \
uv run ak makemessages \
--add-location file \
--no-obsolete \
--ignore web \
@@ -118,17 +102,11 @@ core-i18n-extract:
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
dev-drop-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
$(eval pg_host := $(shell $(UV) run python -m authentik.lib.config postgresql.host 2>/dev/null))
$(eval pg_name := $(shell $(UV) run python -m authentik.lib.config postgresql.name 2>/dev/null))
dropdb -U ${pg_user} -h ${pg_host} ${pg_name} || true
# Also remove the test-db if it exists
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
dev-create-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
$(eval pg_host := $(shell $(UV) run python -m authentik.lib.config postgresql.host 2>/dev/null))
$(eval pg_name := $(shell $(UV) run python -m authentik.lib.config postgresql.name 2>/dev/null))
createdb -U ${pg_user} -h ${pg_host} ${pg_name}
dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state.
@@ -141,8 +119,8 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
ifndef version
$(error Usage: make bump version=20xx.xx.xx )
endif
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' pyproject.toml
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
$(MAKE) gen-build gen-compose aws-cfn
npm version --no-git-tag-version --allow-same-version $(version)
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
@@ -156,10 +134,14 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
$(UV) run ak build_schema
uv run ak make_blueprint_schema --file blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak spectacular --file schema.yml
gen-compose:
$(UV) run scripts/generate_compose.py
uv run scripts/generate_docker_compose.py
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
@@ -167,14 +149,14 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
/local/schema-old.yml \
/local/schema.yml
rm schema-old.yml
$(SED_INPLACE) 's/{/&#123;/g' diff.md
$(SED_INPLACE) 's/}/&#125;/g' diff.md
sed -i 's/{/&#123;/g' diff.md
sed -i 's/}/&#125;/g' diff.md
npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for TypeScript
@@ -190,7 +172,7 @@ gen-clean-go: ## Remove generated API client for Go
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" gen \
generate \
-i /local/schema.yml \
-g typescript-fetch \
@@ -206,19 +188,28 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
mkdir -p ${PWD}/${GEN_API_PY}
ifeq ($(wildcard ${PWD}/${GEN_API_PY}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
else
cd ${PWD}/${GEN_API_PY} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
gen-client-go: ## Build and install the authentik API for Golang
mkdir -p ${PWD}/${GEN_API_GO}
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
else
cd ${PWD}/${GEN_API_GO} && git reset --hard
cd ${PWD}/${GEN_API_GO} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
make -C ${PWD}/${GEN_API_GO} build
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
gen-dev-config: ## Generate a local development config file
$(UV) run scripts/generate_config.py
uv run scripts/generate_config.py
gen: gen-build gen-client-ts
@@ -302,7 +293,7 @@ docs-api-clean: ## Clean generated API documentation
docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ${PWD}/scripts/test_docker.sh
@@ -314,28 +305,28 @@ test-docker:
# which makes the YAML File a lot smaller
ci--meta-debug:
$(UV) run python -V
python -V
node --version
ci-mypy: ci--meta-debug
$(UV) run mypy --strict $(PY_SOURCES)
uv run mypy --strict $(PY_SOURCES)
ci-black: ci--meta-debug
$(UV) run black --check $(PY_SOURCES)
uv run black --check $(PY_SOURCES)
ci-ruff: ci--meta-debug
$(UV) run ruff check $(PY_SOURCES)
uv run ruff check $(PY_SOURCES)
ci-codespell: ci--meta-debug
$(UV) run codespell -s
uv run codespell -s
ci-bandit: ci--meta-debug
$(UV) run bandit -c pyproject.toml -r $(PY_SOURCES) -iii
uv run bandit -r $(PY_SOURCES)
ci-pending-migrations: ci--meta-debug
$(UV) run ak makemigrations --check
uv run ak makemigrations --check
ci-test: ci--meta-debug
$(UV) run coverage run manage.py test --keepdb authentik
$(UV) run coverage report
$(UV) run coverage xml
uv run coverage run manage.py test --keepdb authentik
uv run coverage report
uv run coverage xml

View File

@@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| ---------- | ---------- |
| 2025.8.x | ✅ |
| 2025.10.x | ✅ |
| 2025.12.x | ✅ |
## Reporting a Vulnerability

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2026.2.0-rc1"
VERSION = "2025.12.0-rc3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -1,3 +1,5 @@
import mimetypes
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema
@@ -10,14 +12,13 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.backends.base import get_content_type
from authentik.admin.files.fields import FileField as AkFileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileApiUsage
from authentik.admin.files.validation import validate_upload_file_name
from authentik.api.validation import validate
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction
from authentik.lib.utils.reflection import get_apps
from authentik.rbac.permissions import HasPermission
@@ -25,6 +26,11 @@ from authentik.rbac.permissions import HasPermission
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
def get_mime_from_filename(filename: str) -> str:
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
class FileView(APIView):
pagination_class = None
parser_classes = [MultiPartParser]
@@ -47,7 +53,6 @@ class FileView(APIView):
name = CharField()
mime_type = CharField()
url = CharField()
themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
@extend_schema(
parameters=[FileListParameters],
@@ -75,9 +80,8 @@ class FileView(APIView):
FileView.FileListSerializer(
data={
"name": file,
"url": manager.file_url(file, request),
"mime_type": get_content_type(file),
"themed_urls": manager.themed_urls(file, request),
"url": manager.file_url(file),
"mime_type": get_mime_from_filename(file),
}
)
for file in files
@@ -146,7 +150,7 @@ class FileView(APIView):
"pk": name,
"name": name,
"usage": usage.value,
"mime_type": get_content_type(name),
"mime_type": get_mime_from_filename(name),
},
).from_http(request)

View File

@@ -1,4 +1,3 @@
import mimetypes
from collections.abc import Callable, Generator, Iterator
from typing import cast
@@ -11,32 +10,6 @@ from authentik.admin.files.usage import FileUsage
CACHE_PREFIX = "goauthentik.io/admin/files"
LOGGER = get_logger()
# Theme variable placeholder for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def get_content_type(name: str) -> str:
"""Get MIME type for a file based on its extension."""
content_type, _ = mimetypes.guess_type(name)
return content_type or "application/octet-stream"
def get_valid_themes() -> list[str]:
"""Get valid themes that can be substituted for %(theme)s."""
from authentik.brands.api import Themes
return [t.value for t in Themes if t != Themes.AUTOMATIC]
def has_theme_variable(name: str) -> bool:
"""Check if filename contains %(theme)s variable."""
return THEME_VARIABLE in name
def substitute_theme(name: str, theme: str) -> str:
"""Replace %(theme)s with the given theme."""
return name.replace(THEME_VARIABLE, theme)
class Backend:
"""
@@ -102,29 +75,6 @@ class Backend:
"""
raise NotImplementedError
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Args:
name: File path potentially containing %(theme)s
request: Optional Django HttpRequest for URL building
Returns:
Dict mapping theme to URL if %(theme)s present, None otherwise
"""
if not has_theme_variable(name):
return None
return {
theme: self.file_url(substitute_theme(name, theme), request, use_cache=True)
for theme in get_valid_themes()
}
class ManageableBackend(Backend):
"""

View File

@@ -45,13 +45,8 @@ class FileBackend(ManageableBackend):
@property
def manageable(self) -> bool:
# Check _base_dir (the mount point, e.g. /data) rather than base_path
# (which includes usage/schema subdirs, e.g. /data/media/public).
# The subdirectories are created on first file write via mkdir(parents=True)
# in save_file(), so requiring them to exist beforehand would prevent
# file creation on fresh installs.
return (
self._base_dir.exists()
self.base_path.exists()
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
or (settings.DEBUG or settings.TEST)
)

View File

@@ -46,25 +46,3 @@ class PassthroughBackend(Backend):
) -> str:
"""Return the URL as-is for passthrough files."""
return name
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""Support themed URLs for external URLs with %(theme)s placeholder.
If the external URL contains %(theme)s, substitute it for each theme.
We can't verify that themed variants exist at the external location,
but we trust the user to provide valid URLs.
"""
from authentik.admin.files.backends.base import (
get_valid_themes,
has_theme_variable,
substitute_theme,
)
if not has_theme_variable(name):
return None
return {theme: substitute_theme(name, theme) for theme in get_valid_themes()}

View File

@@ -9,7 +9,7 @@ from botocore.exceptions import ClientError
from django.db import connection
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
@@ -173,22 +173,7 @@ class S3Backend(ManageableBackend):
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
path = parsed.path
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom_domain must
# include the bucket name (per docs), strip it from the path to avoid
# duplication. See: https://github.com/goauthentik/authentik/issues/19521
# Check with trailing slash to ensure exact bucket name match
if path.startswith(f"/{self.bucket_name}/"):
path = path.removeprefix(f"/{self.bucket_name}")
# Normalize to avoid double slashes
custom_domain = custom_domain.rstrip("/")
if not path.startswith("/"):
path = f"/{path}"
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
return url
@@ -204,7 +189,6 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
ContentType=get_content_type(name),
)
@contextmanager
@@ -220,7 +204,6 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
"ContentType": get_content_type(name),
},
)

View File

@@ -165,31 +165,3 @@ class TestFileBackend(FileTestFileBackendMixin, TestCase):
def test_file_exists_false(self):
"""Test file_exists returns False for nonexistent file"""
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
file_name = "logo.png"
result = self.backend.themed_urls(file_name)
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
file_name = "logo-%(theme)s.png"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs contain the substituted theme
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
file_name = "%(theme)s/logo-%(theme)s.svg"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])

View File

@@ -110,106 +110,3 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3Backend with REPORTS usage"""
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")
@CONFIG.patch("storage.s3.secure_urls", True)
@CONFIG.patch("storage.s3.addressing_style", "path")
def test_file_url_custom_domain_with_bucket_no_duplicate(self):
"""Test file_url doesn't duplicate bucket name when custom_domain includes bucket.
Regression test for https://github.com/goauthentik/authentik/issues/19521
When using:
- Path-style addressing (bucket name goes in URL path, not subdomain)
- Custom domain that includes the bucket name (e.g., s3.example.com/bucket-name)
The bucket name should NOT appear twice in the final URL.
Example of the bug:
- custom_domain = "s3.example.com/authentik-media"
- boto3 presigned URL = "http://s3.example.com/authentik-media/media/public/file.png?..."
- Buggy result = "https://s3.example.com/authentik-media/authentik-media/media/public/file.png?..."
"""
bucket_name = self.media_s3_bucket_name
# Custom domain includes the bucket name
custom_domain = f"localhost:8020/{bucket_name}"
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
url = self.media_s3_backend.file_url("application-icons/test.svg", use_cache=False)
# The bucket name should appear exactly once in the URL path, not twice
bucket_occurrences = url.count(bucket_name)
self.assertEqual(
bucket_occurrences,
1,
f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
f"URL: {url}",
)
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
result = self.media_s3_backend.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of presigned URLs for each theme"""
result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs are valid presigned URLs with correct file paths
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
self.assertIn("X-Amz-Signature=", result["light"])
self.assertIn("X-Amz-Signature=", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])
def test_save_file_sets_content_type_svg(self):
"""Test save_file sets correct ContentType for SVG files"""
self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.svg",
)
self.assertEqual(response["ContentType"], "image/svg+xml")
def test_save_file_sets_content_type_png(self):
"""Test save_file sets correct ContentType for PNG files"""
self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
)
self.assertEqual(response["ContentType"], "image/png")
def test_save_file_stream_sets_content_type(self):
"""Test save_file_stream sets correct ContentType"""
with self.media_s3_backend.save_file_stream("test.css") as f:
f.write(b"body { color: red; }")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.css",
)
self.assertEqual(response["ContentType"], "text/css")
def test_save_file_unknown_extension_octet_stream(self):
"""Test save_file sets octet-stream for unknown extensions"""
self.media_s3_backend.save_file("test.unknownext123", b"data")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.unknownext123",
)
self.assertEqual(response["ContentType"], "application/octet-stream")

View File

@@ -88,28 +88,6 @@ class FileManager:
LOGGER.warning(f"Could not find file backend for file: {name}")
return ""
def themed_urls(
self,
name: str | None,
request: HttpRequest | Request | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Returns dict mapping theme to URL if %(theme)s present, None otherwise.
"""
if not name:
return None
if isinstance(request, Request):
request = request._request
for backend in self.backends:
if backend.supports_file(name):
return backend.themed_urls(name, request)
return None
def _check_manageable(self) -> None:
if not self.manageable:
raise ImproperlyConfigured("No file management backend configured.")

View File

@@ -5,6 +5,7 @@ from io import BytesIO
from django.test import TestCase
from django.urls import reverse
from authentik.admin.files.api import get_mime_from_filename
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
@@ -93,9 +94,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -129,9 +129,8 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -201,64 +200,30 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("field is required", str(response.data))
def test_list_files_includes_themed_urls_none(self):
"""Test listing files includes themed_urls as None for non-themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-no-theme.png"
manager.save_file(file_name, b"test content")
response = self.client.get(
reverse("authentik_api:files", query={"search": file_name, "manageableOnly": "true"})
)
class TestGetMimeFromFilename(TestCase):
"""Test get_mime_from_filename function"""
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsNone(file_entry["themed_urls"])
def test_image_png(self):
"""Test PNG image MIME type"""
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
manager.delete_file(file_name)
def test_image_jpeg(self):
"""Test JPEG image MIME type"""
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
def test_list_files_includes_themed_urls_dict(self):
"""Test listing files includes themed_urls as dict for themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "logo-%(theme)s.svg"
manager.save_file("logo-light.svg", b"<svg>light</svg>")
manager.save_file("logo-dark.svg", b"<svg>dark</svg>")
manager.save_file(file_name, b"<svg>placeholder</svg>")
def test_image_svg(self):
"""Test SVG image MIME type"""
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
response = self.client.get(
reverse("authentik_api:files", query={"search": "%(theme)s", "manageableOnly": "true"})
)
def test_text_plain(self):
"""Test text file MIME type"""
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsInstance(file_entry["themed_urls"], dict)
self.assertIn("light", file_entry["themed_urls"])
self.assertIn("dark", file_entry["themed_urls"])
def test_unknown_extension(self):
"""Test unknown extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
manager.delete_file(file_name)
manager.delete_file("logo-light.svg")
manager.delete_file("logo-dark.svg")
def test_upload_file_with_theme_variable(self):
"""Test uploading file with %(theme)s in name"""
manager = FileManager(FileUsage.MEDIA)
file_name = "brand-logo-%(theme)s.svg"
file_content = b"<svg></svg>"
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(manager.file_exists(file_name))
manager.delete_file(file_name)
def test_no_extension(self):
"""Test no extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")

View File

@@ -1,7 +1,6 @@
"""Test file service layer"""
from unittest import skipUnless
from urllib.parse import urlparse
from django.http import HttpRequest
from django.test import TestCase
@@ -105,71 +104,3 @@ class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
# S3 URLs should be returned as-is (already absolute)
self.assertTrue(result.startswith("http://s3.test:8080/test"))
class TestThemedUrls(FileTestFileBackendMixin, TestCase):
"""Test FileManager.themed_urls method"""
def test_themed_urls_none_path(self):
"""Test themed_urls returns None for None path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls(None)
self.assertIsNone(result)
def test_themed_urls_empty_path(self):
"""Test themed_urls returns None for empty path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("")
self.assertIsNone(result)
def test_themed_urls_no_theme_variable(self):
"""Test themed_urls returns None when no %(theme)s in path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_with_request(self):
"""Test themed_urls builds absolute URLs with request"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.svg", mock_request)
self.assertIsInstance(result, dict)
light_url = urlparse(result["light"])
dark_url = urlparse(result["dark"])
self.assertEqual(light_url.scheme, "http")
self.assertEqual(light_url.netloc, "example.com")
self.assertEqual(dark_url.scheme, "http")
self.assertEqual(dark_url.netloc, "example.com")
def test_themed_urls_passthrough_with_theme_variable(self):
"""Test themed_urls returns dict for passthrough URLs with %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs with %(theme)s should return themed URLs
result = manager.themed_urls("https://example.com/logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertEqual(result["light"], "https://example.com/logo-light.png")
self.assertEqual(result["dark"], "https://example.com/logo-dark.png")
def test_themed_urls_passthrough_without_theme_variable(self):
"""Test themed_urls returns None for passthrough URLs without %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs without %(theme)s should return None
result = manager.themed_urls("https://example.com/logo.png")
self.assertIsNone(result)

View File

@@ -4,7 +4,6 @@ from pathlib import PurePosixPath
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from authentik.admin.files.backends.base import THEME_VARIABLE
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
@@ -13,6 +12,10 @@ from authentik.admin.files.usage import FileUsage
MAX_FILE_NAME_LENGTH = 1024
MAX_PATH_COMPONENT_LENGTH = 255
# Theme variable placeholder that can be used in file paths
# This allows for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def validate_file_name(name: str) -> None:
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
@@ -41,16 +44,16 @@ def validate_upload_file_name(
raise ValidationError(_("File name cannot be empty"))
# Allow %(theme)s placeholder for theme-specific files
# Replace with placeholder for validation, then check the result
# We temporarily replace it for validation, then check the result
name_for_validation = name.replace(THEME_VARIABLE, "theme")
# Same regex is used in the frontend as well (with %(theme)s handling)
# Same regex is used in the frontend as well (without %(theme)s handling there)
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
raise ValidationError(
_(
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
"and the placeholder %(theme)s for theme-specific files"
"and the special placeholder %(theme)s for theme-specific files"
)
)

View File

@@ -1,45 +0,0 @@
from json import dumps
from django.core.management.base import BaseCommand, no_translations
from drf_spectacular.drainage import GENERATOR_STATS
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.renderers import OpenApiYamlRenderer
from drf_spectacular.validation import validate_schema
from structlog.stdlib import get_logger
from authentik.blueprints.v1.schema import SchemaBuilder
class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = get_logger()
def add_arguments(self, parser):
parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json")
parser.add_argument("--api-file", type=str, default="schema.yml")
@no_translations
def handle(self, *args, blueprint_file: str, api_file: str, **options):
self.build_blueprint(blueprint_file)
self.build_api(api_file)
def build_blueprint(self, file: str):
self.logger.debug("Building blueprint schema...", file=file)
blueprint_builder = SchemaBuilder()
blueprint_builder.build()
with open(file, "w") as _schema:
_schema.write(
dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default)
)
def build_api(self, file: str):
self.logger.debug("Building API schema...", file=file)
generator = SchemaGenerator()
schema = generator.get_schema(request=None, public=True)
GENERATOR_STATS.emit_summary()
validate_schema(schema)
output = OpenApiYamlRenderer().render(schema, renderer_context={})
with open(file, "wb") as f:
f.write(output)

View File

@@ -1,14 +1,9 @@
"""Schema generation tests"""
from pathlib import Path
from django.core.management import call_command
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load
from authentik.lib.config import CONFIG
class TestSchemaGeneration(APITestCase):
"""Generic admin tests"""
@@ -26,18 +21,3 @@ class TestSchemaGeneration(APITestCase):
reverse("authentik_api:schema-browser"),
)
self.assertEqual(response.status_code, 200)
def test_build_schema(self):
"""Test schema build command"""
blueprint_file = Path("blueprints/schema.json")
api_file = Path("schema.yml")
blueprint_file.unlink()
api_file.unlink()
with (
CONFIG.patch("debug", True),
CONFIG.patch("tenants.enabled", True),
CONFIG.patch("outposts.disable_embedded_outpost", True),
):
call_command("build_schema")
self.assertTrue(blueprint_file.exists())
self.assertTrue(api_file.exists())

View File

@@ -1,7 +1,9 @@
"""Generate JSON Schema for blueprints"""
from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from django.db.models.fields.related import OneToOneField
from drf_jsonschema_serializer.convert import converter, field_to_converter
@@ -38,12 +40,13 @@ class PrimaryKeyRelatedFieldConverter:
return {"type": "integer"}
class SchemaBuilder:
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
schema: dict
def __init__(self):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
@@ -90,6 +93,16 @@ class SchemaBuilder:
"$defs": {"blueprint_entry": {"oneOf": []}},
}
def add_arguments(self, parser):
parser.add_argument("--file", type=str)
@no_translations
def handle(self, *args, file: str, **options):
"""Generate JSON Schema for blueprints"""
self.build()
with open(file, "w") as _schema:
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
@staticmethod
def json_default(value: Any) -> Any:
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
@@ -111,7 +124,7 @@ class SchemaBuilder:
try:
serializer_class = model_instance.serializer
except NotImplementedError as exc:
raise ValueError(f"SerializerModel not implemented by {model}") from exc
raise NotImplementedError(model_instance) from exc
serializer = serializer_class(
context={
SERIALIZER_CONTEXT_BLUEPRINT: False,

View File

@@ -18,7 +18,7 @@ entries:
name: foo
title: foo
permissions:
- permission: authentik_flows.view_flow
- permission: view_flow
user: !KeyOf user
- permission: authentik_flows.view_flow
- permission: view_flow
role: !KeyOf role

View File

@@ -9,7 +9,7 @@ from functools import reduce
from json import JSONDecodeError, loads
from operator import ixor
from os import getenv
from typing import Any, Literal
from typing import Any, Literal, Union
from uuid import UUID
from deepmerge import always_merger
@@ -70,17 +70,19 @@ class BlueprintEntryDesiredState(Enum):
class BlueprintEntryPermission:
"""Describe object-level permissions"""
permission: str | YAMLTag
user: int | YAMLTag | None = field(default=None)
role: str | YAMLTag | None = field(default=None)
permission: Union[str, "YAMLTag"]
user: Union[int, "YAMLTag", None] = field(default=None)
role: Union[str, "YAMLTag", None] = field(default=None)
@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
model: str | YAMLTag
state: BlueprintEntryDesiredState | YAMLTag = field(default=BlueprintEntryDesiredState.PRESENT)
model: Union[str, "YAMLTag"]
state: Union[BlueprintEntryDesiredState, "YAMLTag"] = field(
default=BlueprintEntryDesiredState.PRESENT
)
conditions: list[Any] = field(default_factory=list)
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: dict[str, Any] | None = field(default_factory=dict)
@@ -94,7 +96,7 @@ class BlueprintEntry:
self.__tag_contexts: list[YAMLTagContext] = []
@staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> BlueprintEntry:
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
"""Convert a SerializerModel instance to a blueprint Entry"""
identifiers = {
"pk": model.pk,
@@ -112,8 +114,8 @@ class BlueprintEntry:
def get_tag_context(
self,
depth: int = 0,
context_tag_type: type[YAMLTagContext] | tuple[YAMLTagContext, ...] | None = None,
) -> YAMLTagContext:
context_tag_type: type["YAMLTagContext"] | tuple["YAMLTagContext", ...] | None = None,
) -> "YAMLTagContext":
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
if depth < 0:
raise ValueError("depth must be a positive number or zero")
@@ -128,7 +130,7 @@ class BlueprintEntry:
except IndexError as exc:
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}") from exc
def tag_resolver(self, value: Any, blueprint: Blueprint) -> Any:
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
"""Check if we have any special tags that need handling"""
val = copy(value)
@@ -150,23 +152,23 @@ class BlueprintEntry:
return val
def get_attrs(self, blueprint: Blueprint) -> dict[str, Any]:
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.attrs, blueprint)
def get_identifiers(self, blueprint: Blueprint) -> dict[str, Any]:
def get_identifiers(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.identifiers, blueprint)
def get_state(self, blueprint: Blueprint) -> BlueprintEntryDesiredState:
def get_state(self, blueprint: "Blueprint") -> BlueprintEntryDesiredState:
"""Get the blueprint state, with yaml tags resolved if present"""
return BlueprintEntryDesiredState(self.tag_resolver(self.state, blueprint))
def get_model(self, blueprint: Blueprint) -> str:
def get_model(self, blueprint: "Blueprint") -> str:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))
def get_permissions(self, blueprint: Blueprint) -> Generator[BlueprintEntryPermission]:
def get_permissions(self, blueprint: "Blueprint") -> Generator[BlueprintEntryPermission]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(
@@ -175,7 +177,7 @@ class BlueprintEntry:
role=self.tag_resolver(perm.role, blueprint),
)
def check_all_conditions_match(self, blueprint: Blueprint) -> bool:
def check_all_conditions_match(self, blueprint: "Blueprint") -> bool:
"""Check all conditions of this entry match (evaluate to True)"""
return all(self.tag_resolver(self.conditions, blueprint))
@@ -230,7 +232,7 @@ class KeyOf(YAMLTag):
id_from: str
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.id_from = node.value
@@ -256,7 +258,7 @@ class Env(YAMLTag):
key: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -275,7 +277,7 @@ class File(YAMLTag):
path: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -303,7 +305,7 @@ class Context(YAMLTag):
key: str
default: Any | None
def __init__(self, loader: BlueprintLoader, node: ScalarNode | SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
@@ -326,7 +328,7 @@ class ParseJSON(YAMLTag):
raw: str
def __init__(self, loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.raw = node.value
@@ -343,7 +345,7 @@ class Format(YAMLTag):
format_string: str
args: list[Any]
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.format_string = loader.construct_object(node.value[0])
self.args = []
@@ -370,7 +372,7 @@ class Find(YAMLTag):
model_name: str | YAMLTag
conditions: list[list]
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.model_name = loader.construct_object(node.value[0])
self.conditions = []
@@ -442,7 +444,7 @@ class Condition(YAMLTag):
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
}
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.mode = loader.construct_object(node.value[0])
self.args = []
@@ -476,7 +478,7 @@ class If(YAMLTag):
when_true: Any
when_false: Any
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.condition = loader.construct_object(node.value[0])
if len(node.value) == 1:
@@ -516,7 +518,7 @@ class Enumerate(YAMLTag, YAMLTagContext):
),
}
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.iterable = loader.construct_object(node.value[0])
self.output_body = loader.construct_object(node.value[1])
@@ -582,7 +584,7 @@ class EnumeratedItem(YAMLTag):
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
def __init__(self, _loader: BlueprintLoader, node: ScalarNode) -> None:
def __init__(self, _loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.depth = int(node.value)
@@ -638,7 +640,7 @@ class AtIndex(YAMLTag):
attribute: int | str | YAMLTag
default: Any | UNSET
def __init__(self, loader: BlueprintLoader, node: SequenceNode) -> None:
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.obj = loader.construct_object(node.value[0])
self.attribute = loader.construct_object(node.value[1])
@@ -755,7 +757,7 @@ class EntryInvalidError(SentryIgnoredException):
@staticmethod
def from_entry(
msg_or_exc: str | Exception, entry: BlueprintEntry, *args, **kwargs
) -> EntryInvalidError:
) -> "EntryInvalidError":
"""Create EntryInvalidError with the context of an entry"""
error = EntryInvalidError(msg_or_exc, *args, **kwargs)
if isinstance(msg_or_exc, ValidationError):

View File

@@ -15,7 +15,8 @@ from django.db.models import Model
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from guardian.models import RoleObjectPermission
from django_channels_postgres.models import GroupChannel, Message
from guardian.models import RoleObjectPermission, UserObjectPermission
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@@ -40,17 +41,55 @@ from authentik.core.models import (
User,
UserSourceConnection,
)
from authentik.endpoints.models import Connector
from authentik.endpoints.connectors.agent.models import (
AgentDeviceConnection,
AppleNonce,
DeviceAuthenticationToken,
)
from authentik.endpoints.connectors.agent.models import (
DeviceToken as EndpointDeviceToken,
)
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.ssf.models import StreamEvent
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.utils import cleanse_dict
from authentik.flows.models import Stage
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.proxy.models import ProxySession
from authentik.providers.rac.models import ConnectionToken
from authentik.providers.saml.models import SAMLSession
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.stages.consent.models import UserConsent
from authentik.tasks.models import Task, TaskLog
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used
@@ -71,6 +110,7 @@ def excluded_models() -> list[type[Model]]:
ContentType,
Permission,
RoleObjectPermission,
UserObjectPermission,
# Base classes
Provider,
Source,
@@ -85,16 +125,49 @@ def excluded_models() -> list[type[Model]]:
# Classes that have other dependencies
Session,
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMProviderGroup,
SCIMProviderUser,
Tenant,
Task,
TaskLog,
ConnectionToken,
AuthorizationCode,
AccessToken,
RefreshToken,
ProxySession,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
EndpointDeviceToken,
Device,
DeviceConnection,
DeviceAuthenticationToken,
AppleNonce,
AgentDeviceConnection,
DeviceFactSnapshot,
DeviceToken,
StreamEvent,
UserConsent,
SAMLSession,
Message,
GroupChannel,
)
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return (
model not in excluded_models()
and issubclass(model, SerializerModel | BaseMetaModel)
and not issubclass(model, InternallyManagedMixin)
)
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
class DoRollback(SentryIgnoredException):
@@ -146,7 +219,7 @@ class Importer:
}
@staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> Importer:
def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
"""Parse YAML string and create blueprint importer from it"""
import_dict = load(yaml_input, BlueprintLoader)
try:

View File

@@ -23,7 +23,7 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
# We cannot override `instance` as that will confuse rest_framework
# and make it attempt to update the instance
blueprint_instance: BlueprintInstance
blueprint_instance: "BlueprintInstance"
def validate(self, attrs):
from authentik.blueprints.models import BlueprintInstance

View File

@@ -6,12 +6,7 @@ from django.db import models
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
CharField,
ChoiceField,
ListField,
SerializerMethodField,
)
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@@ -21,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.rbac.filters import SecretKeyFilter
from authentik.tenants.api.settings import FlagJSONField
from authentik.tenants.flags import Flag
@@ -95,9 +90,7 @@ class CurrentBrandSerializer(PassiveSerializer):
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField(source="branding_logo_url")
branding_logo_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_favicon = CharField(source="branding_favicon_url")
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_custom_css = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),
@@ -124,8 +117,10 @@ class CurrentBrandSerializer(PassiveSerializer):
@extend_schema_field(field=FlagJSONField)
def get_flags(self, _):
values = {}
for flag in Flag.available(visibility="public"):
values[flag().key] = flag.get()
for flag in Flag.available():
_flag = flag()
if _flag.visibility == "public":
values[_flag.key] = _flag.get()
return values

View File

@@ -89,26 +89,14 @@ class Brand(SerializerModel):
"""Get branding_logo URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
def branding_logo_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_logo if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_logo)
def branding_favicon_url(self) -> str:
"""Get branding_favicon URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
def branding_favicon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_favicon if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_favicon)
def branding_default_flow_background_url(self) -> str:
"""Get branding_default_flow_background URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
def branding_default_flow_background_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_default_flow_background if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_default_flow_background)
@property
def serializer(self) -> type[Serializer]:
from authentik.brands.api import BrandSerializer

View File

@@ -6,6 +6,7 @@ from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
@@ -21,8 +22,10 @@ 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()
for flag in Flag.available():
_flag = flag()
if _flag.visibility == "public":
self.default_flags[_flag.key] = _flag.get()
Brand.objects.all().delete()
def test_current_brand(self):
@@ -32,14 +35,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -54,14 +55,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -73,14 +72,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "fallback",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -97,14 +94,12 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -122,14 +117,12 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -140,14 +133,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -163,14 +154,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-strong",
"branding_custom_css": "",
"matched_domain": "foo.bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -186,14 +175,12 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-weak",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
@@ -269,14 +256,12 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "https://goauthentik.io/img/icon.png",
"branding_logo_themed_urls": None,
"branding_favicon": "https://goauthentik.io/img/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": "automatic",
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},

View File

@@ -24,7 +24,7 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.policies.api.exec import PolicyTestResultSerializer
@@ -53,9 +53,6 @@ class ApplicationSerializer(ModelSerializer):
)
meta_icon_url = ReadOnlyField(source="get_meta_icon")
meta_icon_themed_urls = ThemedUrlsSerializer(
source="get_meta_icon_themed_urls", read_only=True, allow_null=True
)
def get_launch_url(self, app: Application) -> str | None:
"""Allow formatting of launch URL"""
@@ -66,7 +63,7 @@ class ApplicationSerializer(ModelSerializer):
user = self.context["request"].user
# Cache serialized user data to avoid N+1 when formatting launch URLs
# for multiple applications. UserSerializer accesses user.groups which
# for multiple applications. UserSerializer accesses user.ak_groups which
# would otherwise trigger a query for each application.
if user is not None:
if "_cached_user_data" not in self.context:
@@ -105,7 +102,6 @@ class ApplicationSerializer(ModelSerializer):
"meta_launch_url",
"meta_icon",
"meta_icon_url",
"meta_icon_themed_urls",
"meta_description",
"meta_publisher",
"policy_engine_mode",

View File

@@ -2,31 +2,18 @@
from typing import TypedDict
from drf_spectacular.utils import (
extend_schema,
inline_serializer,
)
from rest_framework import mixins, serializers
from rest_framework.decorators import action
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
CharField,
DateTimeField,
IPAddressField,
ListField,
)
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
from authentik.rbac.decorators import permission_required
class UserAgentDeviceDict(TypedDict):
@@ -65,14 +52,6 @@ class UserAgentDict(TypedDict):
string: str
class BulkDeleteSessionSerializer(PassiveSerializer):
"""Serializer for bulk deleting authenticated sessions by user"""
user_pks = ListField(
child=serializers.IntegerField(), help_text="List of user IDs to revoke all sessions for"
)
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
@@ -136,22 +115,3 @@ class AuthenticatedSessionViewSet(
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
ordering = ["user__username"]
owner_field = "user"
@permission_required("authentik_core.delete_authenticatedsession")
@extend_schema(
parameters=[BulkDeleteSessionSerializer],
responses={
200: inline_serializer(
"BulkDeleteSessionResponse",
{"deleted": serializers.IntegerField()},
),
},
)
@validate(BulkDeleteSessionSerializer, location="query")
@action(detail=False, methods=["DELETE"], pagination_class=None, filter_backends=[])
def bulk_delete(self, request: Request, *, query: BulkDeleteSessionSerializer) -> Response:
"""Bulk revoke all sessions for multiple users"""
user_pks = query.validated_data.get("user_pks", [])
deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_pks).delete()
return Response({"deleted": deleted_count}, status=200)

View File

@@ -18,14 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
assigned_backchannel_application_slug = ReadOnlyField(
source="backchannel_application.slug", allow_null=True
)
assigned_backchannel_application_name = ReadOnlyField(
source="backchannel_application.name", allow_null=True
)
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
component = SerializerMethodField()

View File

@@ -14,7 +14,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer, ThemedUrlsSerializer
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.policies.engine import PolicyEngine
@@ -28,7 +28,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
managed = ReadOnlyField()
component = SerializerMethodField()
icon_url = ReadOnlyField()
icon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
def get_component(self, obj: Source) -> str:
"""Get object component so that we know how to edit the object"""
@@ -58,7 +57,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"user_path_template",
"icon",
"icon_url",
"icon_themed_urls",
]

View File

@@ -4,6 +4,7 @@ from typing import Any
from django.utils.timezone import now
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
@@ -144,6 +145,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
owner_field = "user"
rbac_allow_create_without_perm = True
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)
def perform_create(self, serializer: TokenSerializer):
if not self.request.user.is_superuser:
instance = serializer.save(

View File

@@ -129,6 +129,7 @@ class UserSerializer(ModelSerializer):
groups = PrimaryKeyRelatedField(
allow_empty=True,
many=True,
source="ak_groups",
queryset=Group.objects.all().order_by("name"),
default=list,
)
@@ -164,7 +165,7 @@ class UserSerializer(ModelSerializer):
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
if not self._should_include_groups:
return None
return PartialGroupSerializer(instance.groups, many=True).data
return PartialGroupSerializer(instance.ak_groups, many=True).data
@extend_schema_field(RoleSerializer(many=True))
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
@@ -415,12 +416,7 @@ class UsersFilter(FilterSet):
last_updated = IsoDateTimeFilter(field_name="last_updated")
last_updated__gt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="gt")
last_login__lt = IsoDateTimeFilter(field_name="last_login", lookup_expr="lt")
last_login = IsoDateTimeFilter(field_name="last_login")
last_login__gt = IsoDateTimeFilter(field_name="last_login", lookup_expr="gt")
last_login__isnull = BooleanFilter(field_name="last_login", lookup_expr="isnull")
is_superuser = BooleanFilter(field_name="groups", method="filter_is_superuser")
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
@@ -429,12 +425,12 @@ class UsersFilter(FilterSet):
type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type")
groups_by_name = ModelMultipleChoiceFilter(
field_name="groups__name",
field_name="ak_groups__name",
to_field_name="name",
queryset=Group.objects.all().order_by("name"),
)
groups_by_pk = ModelMultipleChoiceFilter(
field_name="groups",
field_name="ak_groups",
queryset=Group.objects.all().order_by("name"),
)
@@ -450,8 +446,8 @@ class UsersFilter(FilterSet):
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(groups__is_superuser=True).distinct()
return queryset.exclude(groups__is_superuser=True).distinct()
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
@@ -477,7 +473,6 @@ class UsersFilter(FilterSet):
"email",
"date_joined",
"last_updated",
"last_login",
"name",
"is_active",
"is_superuser",
@@ -498,7 +493,7 @@ class UserViewSet(
"""User Viewset"""
queryset = User.objects.none()
ordering = ["username", "date_joined", "last_updated", "last_login"]
ordering = ["username", "date_joined", "last_updated"]
serializer_class = UserSerializer
filterset_class = UsersFilter
search_fields = ["email", "name", "uuid", "username"]
@@ -529,7 +524,7 @@ class UserViewSet(
def get_queryset(self):
base_qs = User.objects.all().exclude_anonymous()
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("groups")
base_qs = base_qs.prefetch_related("ak_groups")
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
return base_qs

View File

@@ -127,10 +127,3 @@ class LinkSerializer(PassiveSerializer):
"""Returns a single link"""
link = CharField()
class ThemedUrlsSerializer(PassiveSerializer):
"""Themed URLs - maps theme names to URLs for light and dark themes"""
light = CharField(required=False, allow_null=True)
dark = CharField(required=False, allow_null=True)

View File

@@ -8,7 +8,7 @@ from uuid import uuid4
from django.contrib.auth import logout
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override
@@ -47,7 +47,7 @@ async def aget_user(request):
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest) -> HttpResponseBadRequest | None:
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
@@ -62,8 +62,7 @@ class AuthenticationMiddleware(MiddlewareMixin):
user = request.user
if user and user.is_authenticated and not user.is_active:
logout(request)
return HttpResponseBadRequest()
return None
raise AssertionError()
class ImpersonateMiddleware:

View File

@@ -16,7 +16,7 @@ def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
for obj in model.objects.using(db_alias).only("is_backchannel"):
obj.is_backchannel = True
obj.save()
except DatabaseError, InternalError, ProgrammingError:
except (DatabaseError, InternalError, ProgrammingError):
# The model might not have been migrated yet/doesn't exist yet
# so we don't need to worry about backporting the data
pass

View File

@@ -1,9 +1,101 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
try:
return pickle.loads(data) # nosec
except Exception:
return {}
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
uuid=old_auth_session.uuid,
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
@@ -113,4 +205,8 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -1,47 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-19 21:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0056_user_roles"),
("authentik_rbac", "0010_remove_role_group_alter_role_name"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="user_permissions",
),
migrations.AlterField(
model_name="group",
name="roles",
field=models.ManyToManyField(
blank=True, related_name="groups", to="authentik_rbac.role"
),
),
migrations.RemoveField(
model_name="user",
name="groups",
),
migrations.RenameField(
model_name="user",
old_name="ak_groups",
new_name="groups",
),
migrations.AlterModelOptions(
name="user",
options={
"permissions": [
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
("preview_user", "Can preview user data sent to providers"),
("view_user_applications", "View applications the user has access to"),
],
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
]

View File

@@ -1,9 +1,9 @@
"""authentik core models"""
from datetime import datetime, timedelta
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from typing import Any, Self
from typing import Any, Optional, Self
from uuid import uuid4
import pgtrigger
@@ -183,7 +183,7 @@ class Group(SerializerModel, AttributesMixin):
default=False, help_text=_("Users added to this group will be superusers.")
)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="groups", blank=True)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
parents = models.ManyToManyField(
"Group",
@@ -225,14 +225,14 @@ class Group(SerializerModel, AttributesMixin):
# in the LDAP Outpost we use the last 5 chars so match here
return int(str(self.pk.int)[:5])
def is_member(self, user: User) -> bool:
def is_member(self, user: "User") -> bool:
"""Recursively check if `user` is member of us, or any parent."""
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this group and all of its ancestors."""
return Role.objects.filter(
groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
).distinct()
def get_managed_role(self, create=False):
@@ -240,7 +240,7 @@ class Group(SerializerModel, AttributesMixin):
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.groups.add(self)
role.ak_groups.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
@@ -355,17 +355,13 @@ class UserManager(DjangoUserManager):
class User(SerializerModel, AttributesMixin, AbstractUser):
"""authentik User model, based on django's contrib auth user model."""
# Overwriting PermissionsMixin: permissions are handled by roles.
# (This knowingly violates the Liskov substitution principle. It is better to fail loudly.)
user_permissions = None
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
name = models.TextField(help_text=_("User's display name."))
path = models.TextField(default="users")
type = models.TextField(choices=UserTypes.choices, default=UserTypes.INTERNAL)
sources = models.ManyToManyField("Source", through="UserSourceConnection")
groups = models.ManyToManyField("Group", related_name="users")
ak_groups = models.ManyToManyField("Group", related_name="users")
roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True)
password_change_date = models.DateTimeField(auto_now_add=True)
@@ -379,6 +375,8 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
permissions = [
("reset_user_password", _("Reset Password")),
("impersonate", _("Can impersonate other users")),
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
@@ -402,11 +400,11 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of."""
return self.groups.all().with_ancestors()
return self.ak_groups.all().with_ancestors()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this user and all of its groups (recursively)."""
return Role.objects.filter(Q(users=self) | Q(groups__in=self.all_groups())).distinct()
return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct()
def get_managed_role(self, create=False):
if create:
@@ -468,7 +466,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
always_merger.merge(final_attributes, self.attributes)
return final_attributes
def app_entitlements(self, app: Application | None) -> QuerySet[ApplicationEntitlement]:
def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]:
"""Get all entitlements this user has for `app`."""
if not app:
return []
@@ -487,7 +485,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
).order_by("name")
return qs
def app_entitlements_attributes(self, app: Application | None) -> dict:
def app_entitlements_attributes(self, app: "Application | None") -> dict:
"""Get a dictionary containing all merged attributes from app entitlements for `app`."""
final_attributes = {}
for attrs in self.app_entitlements(app).values_list("attributes", flat=True):
@@ -510,42 +508,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser # type: ignore
# TODO: remove this after 2026.
@property
def ak_groups(self):
"""This is a proxy for a renamed, deprecated field."""
from authentik.events.models import Event, EventAction
deprecation = "authentik.core.models.User.ak_groups"
replacement = "authentik.core.models.User.groups"
message_logger = (
f"{deprecation} is deprecated and will be removed in a future version of "
f"authentik. Please use {replacement} instead."
)
message_event = (
f"{message_logger} This event will not be repeated until it expires (by "
"default: in 30 days). See authentik logs for every will invocation of this "
"deprecation."
)
LOGGER.warning(
"deprecation used",
message=message_logger,
deprecation=deprecation,
replacement=replacement,
)
if not Event.filter_not_expired(
action=EventAction.CONFIGURATION_WARNING, context__deprecation=deprecation
).exists():
event = Event.new(
EventAction.CONFIGURATION_WARNING,
deprecation=deprecation,
replacement=replacement,
message=message_event,
)
event.expires = datetime.now() + timedelta(days=30)
event.save()
return self.groups
def set_password(self, raw_password, signal=True, sender=None, request=None):
if self.pk and signal:
from authentik.core.signals import password_changed
@@ -692,7 +654,7 @@ class BackchannelProvider(Provider):
class ApplicationQuerySet(QuerySet):
def with_provider(self) -> QuerySet[Application]:
def with_provider(self) -> "QuerySet[Application]":
qs = self.select_related("provider")
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
qs = qs.select_related(f"provider__{subclass}")
@@ -751,15 +713,9 @@ class Application(SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
@property
def get_meta_icon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for meta_icon if it contains %(theme)s"""
if not self.meta_icon:
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.meta_icon)
def get_launch_url(self, user: User | None = None, user_data: dict | None = None) -> str | None:
def get_launch_url(
self, user: Optional["User"] = None, user_data: dict | None = None
) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
Args:
@@ -973,14 +929,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
@property
def icon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for icon if it contains %(theme)s"""
if not self.icon:
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.icon)
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""
try:
@@ -1000,7 +948,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
raise NotImplementedError
@property
def property_mapping_type(self) -> type[PropertyMapping]:
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
@@ -1121,7 +1069,7 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet[Self]:
def filter_not_expired(cls, **kwargs) -> QuerySet["Self"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
@@ -1317,7 +1265,7 @@ class AuthenticatedSession(SerializerModel):
return f"Authenticated Session {str(self.pk)[:10]}"
@staticmethod
def from_request(request: HttpRequest, user: User) -> AuthenticatedSession | None:
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists(
request.session.session_key

View File

@@ -66,7 +66,7 @@ class SessionStore(SessionBase):
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError, AttributeError, TypeError:
except (pickle.PickleError, AttributeError, TypeError):
# PickleError, ValueError - unpickling exceptions
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
# and their descriptors fail to initialize (e.g., missing storage)

View File

@@ -51,7 +51,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
if session:
session.save()
if not RefreshOtherFlowsAfterAuthentication.get():
if not RefreshOtherFlowsAfterAuthentication().get():
return
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
@@ -63,7 +63,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
@receiver(post_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: AuthenticatedSession, **_):
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted"""
Session.objects.filter(session_key=instance.pk).delete()

View File

@@ -392,10 +392,10 @@ class GroupUpdateStage(StageView):
groups.append(group)
with transaction.atomic():
self.user.groups.remove(
*self.user.groups.filter(groupsourceconnection__source=self.source)
self.user.ak_groups.remove(
*self.user.ak_groups.filter(groupsourceconnection__source=self.source)
)
self.user.groups.add(*groups)
self.user.ak_groups.add(*groups)
return True

View File

@@ -49,7 +49,7 @@ class SourceMapper:
def build_object_properties(
self,
object_type: type[User | Group],
manager: PropertyMappingManager | None = None,
manager: "PropertyMappingManager | None" = None,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,

View File

@@ -10,23 +10,15 @@
{% elif ui_theme == "light" %}
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#ffffff">
{% else %}
{% else %}
<script data-id="theme-script">
"use strict";
(function () {
try {
/* Ignore older theme names */
let locallyStoredTheme = window.localStorage?.getItem("theme") || null;
if (typeof locallyStoredTheme === "string") {
locallyStoredTheme = locallyStoredTheme.trim();
}
if (!(["auto", "light", "dark"].includes(locallyStoredTheme))) {
locallyStoredTheme = null;
}
const initialThemeChoice =
new URLSearchParams(window.location.search).get("theme") || locallyStoredTheme;
new URLSearchParams(window.location.search).get("theme") ||
window.localStorage?.getItem("theme");
const themeChoice =
initialThemeChoice || document.documentElement.dataset.themeChoice || "auto";

View File

@@ -38,7 +38,7 @@ class TestApplicationEntitlements(APITestCase):
def test_group(self):
"""Test direct group"""
group = Group.objects.create(name=generate_id())
self.user.groups.add(group)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=group, order=0)
ents = self.user.app_entitlements(self.app)
@@ -50,7 +50,7 @@ class TestApplicationEntitlements(APITestCase):
parent = Group.objects.create(name=generate_id())
group = Group.objects.create(name=generate_id())
group.parents.add(parent)
self.user.groups.add(group)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=parent, order=0)
ents = self.user.app_entitlements(self.app)

View File

@@ -107,8 +107,6 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": None,
"assigned_backchannel_application_slug": None,
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
@@ -127,7 +125,6 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -165,8 +162,6 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": None,
"assigned_backchannel_application_slug": None,
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
@@ -185,7 +180,6 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -195,7 +189,6 @@ class TestApplicationsAPI(APITestCase):
"meta_description": "",
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_launch_url": "",
"open_in_new_tab": False,
"meta_publisher": "",

View File

@@ -122,8 +122,8 @@ class TestGroupsAPI(APITestCase):
def test_superuser_update_no_perm(self):
"""Test updating a superuser group without permission"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -139,8 +139,8 @@ class TestGroupsAPI(APITestCase):
"""Test updating a superuser group without permission
and without changing the superuser status"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),

View File

@@ -54,7 +54,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -88,7 +88,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -123,7 +123,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
@@ -155,7 +155,7 @@ class TestSourceFlowManager(FlowTestCase):
)
self.assertTrue(stage.handle_groups())
self.assertTrue(Group.objects.filter(name="group 1").exists())
self.assertTrue(self.user.groups.filter(name="group 1").exists())
self.assertTrue(self.user.ak_groups.filter(name="group 1").exists())
self.assertTrue(
GroupOAuthSourceConnection.objects.filter(
group=Group.objects.get(name="group 1"), source=self.source
@@ -189,7 +189,7 @@ class TestSourceFlowManager(FlowTestCase):
request=request,
)
self.assertFalse(stage.handle_groups())
self.assertFalse(self.user.groups.filter(name="group 1").exists())
self.assertFalse(self.user.ak_groups.filter(name="group 1").exists())
self.assertFalse(
GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists()
)
@@ -201,7 +201,7 @@ class TestSourceFlowManager(FlowTestCase):
other_group = Group.objects.create(name="other group")
old_group = Group.objects.create(name="old group")
new_group = Group.objects.create(name="new group")
self.user.groups.set([other_group, old_group])
self.user.ak_groups.set([other_group, old_group])
GroupOAuthSourceConnection.objects.create(
group=old_group, source=self.source, identifier=old_group.name
)
@@ -231,7 +231,7 @@ class TestSourceFlowManager(FlowTestCase):
request=request,
)
self.assertTrue(stage.handle_groups())
self.assertFalse(self.user.groups.filter(name="old group").exists())
self.assertTrue(self.user.groups.filter(name="other group").exists())
self.assertTrue(self.user.groups.filter(name="new group").exists())
self.assertEqual(self.user.groups.count(), 2)
self.assertFalse(self.user.ak_groups.filter(name="old group").exists())
self.assertTrue(self.user.ak_groups.filter(name="other group").exists())
self.assertTrue(self.user.ak_groups.filter(name="new group").exists())
self.assertEqual(self.user.ak_groups.count(), 2)

View File

@@ -5,10 +5,9 @@ from django.test import TestCase
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.core.sources.mapper import SourceMapper
from authentik.lib.generators import generate_id
from authentik.lib.models import InternallyManagedMixin
class ProxySource(InternallyManagedMixin, Source):
class ProxySource(Source):
@property
def property_mapping_type(self):
return PropertyMapping

View File

@@ -183,16 +183,16 @@ class TestTokenAPI(APITestCase):
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
def test_list_with_permission(self):
"""Test Token List (Test with `view_token` permission)"""
def test_list_admin(self):
"""Test Token List (Test with admin auth)"""
Token.objects.all().delete()
self.client.force_login(self.admin)
token_should: Token = Token.objects.create(
identifier="test", expiring=False, user=self.user
)
token_should_not: Token = Token.objects.create(
identifier="test-2", expiring=False, user=get_anonymous_user()
)
self.user.assign_perms_to_managed_role("authentik_core.view_token")
response = self.client.get(reverse("authentik_api:token-list"))
body = loads(response.content)
self.assertEqual(len(body["results"]), 2)

View File

@@ -3,7 +3,6 @@
from django.test.testcases import TestCase
from authentik.core.models import User
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -19,17 +18,3 @@ class TestUsers(TestCase):
self.assertTrue(user.has_perm(perm))
user.remove_perms_from_managed_role(perm)
self.assertFalse(user.has_perm(perm))
def test_user_ak_groups(self):
"""Test user.ak_groups is a proxy for user.groups"""
user = User.objects.create(username=generate_id())
self.assertEqual(user.ak_groups, user.groups)
def test_user_ak_groups_event(self):
"""Test user.ak_groups creates exactly one event"""
user = User.objects.create(username=generate_id())
self.assertEqual(Event.objects.count(), 0)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)

View File

@@ -740,90 +740,3 @@ class TestUsersAPI(APITestCase):
response.content,
{"name": ["This field must be unique."]},
)
def test_filter_last_login(self):
"""Test API filtering by last_login"""
from datetime import timedelta
from django.utils import timezone
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
# Create users with different last_login values
user_recent = create_test_user()
user_recent.last_login = timezone.now()
user_recent.save()
user_old = create_test_user()
user_old.last_login = timezone.now() - timedelta(days=400) # Over 1 year ago
user_old.save()
user_never = create_test_user()
user_never.last_login = None # Never logged in
user_never.save()
# Filter users who logged in before 1 year ago
one_year_ago = (timezone.now() - timedelta(days=365)).isoformat()
response = self.client.get(
reverse("authentik_api:user-list"),
data={"last_login__lt": one_year_ago},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["pk"], user_old.pk)
# Filter users who have never logged in
response = self.client.get(
reverse("authentik_api:user-list"),
data={"last_login__isnull": True},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# Should include user_never and admin (who hasn't logged in via the app)
pks = [r["pk"] for r in body["results"]]
self.assertIn(user_never.pk, pks)
def test_sort_by_last_login(self):
"""Test API sorting by last_login"""
from datetime import timedelta
from django.utils import timezone
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
user1 = create_test_user()
user1.last_login = timezone.now() - timedelta(days=10)
user1.save()
user2 = create_test_user()
user2.last_login = timezone.now() - timedelta(days=5)
user2.save()
# Ascending order (oldest first)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"ordering": "last_login"},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# Users with null last_login come first, then user1 (older), then user2 (newer)
self.assertEqual(len(body["results"]), 3)
# Descending order (newest first)
response = self.client.get(
reverse("authentik_api:user-list"),
data={"ordering": "-last_login"},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
# user2 should come before user1 (more recent login)
pks = [r["pk"] for r in body["results"]]
self.assertIn(user1.pk, pks)
self.assertIn(user2.pk, pks)
# Verify user2 comes before user1 in descending order
self.assertLess(pks.index(user2.pk), pks.index(user1.pk))

View File

@@ -1,5 +1,7 @@
"""Crypto API Views"""
from datetime import datetime
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
@@ -13,12 +15,14 @@ from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_field,
)
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import (
CharField,
ChoiceField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
@@ -47,15 +51,59 @@ LOGGER = get_logger()
class CertificateKeyPairSerializer(ModelSerializer):
"""CertificateKeyPair Serializer"""
fingerprint_sha256 = SerializerMethodField()
fingerprint_sha1 = SerializerMethodField()
cert_expiry = SerializerMethodField()
cert_subject = SerializerMethodField()
private_key_available = SerializerMethodField()
key_type = SerializerMethodField()
certificate_download_url = SerializerMethodField()
private_key_download_url = SerializerMethodField()
@property
def _should_include_details(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_details", "true")).lower() == "true"
def get_fingerprint_sha256(self, instance: CertificateKeyPair) -> str | None:
"Get certificate Hash (SHA256)"
if not self._should_include_details:
return None
return instance.fingerprint_sha256
def get_fingerprint_sha1(self, instance: CertificateKeyPair) -> str | None:
"Get certificate Hash (SHA1)"
if not self._should_include_details:
return None
return instance.fingerprint_sha1
def get_cert_expiry(self, instance: CertificateKeyPair) -> datetime | None:
"Get certificate expiry"
if not self._should_include_details:
return None
return DateTimeField().to_representation(instance.certificate.not_valid_after_utc)
def get_cert_subject(self, instance: CertificateKeyPair) -> str | None:
"""Get certificate subject as full rfc4514"""
if not self._should_include_details:
return None
return instance.certificate.subject.rfc4514_string()
def get_private_key_available(self, instance: CertificateKeyPair) -> bool:
"""Show if this keypair has a private key configured or not"""
return instance.key_data != "" and instance.key_data is not None
@extend_schema_field(ChoiceField(choices=KeyType.choices, allow_null=True))
def get_key_type(self, instance: CertificateKeyPair) -> str | None:
"""Get the key algorithm type from the certificate's public key"""
if not self._should_include_details:
return None
return instance.key_type
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
"""Get URL to download certificate"""
return (
@@ -127,11 +175,6 @@ class CertificateKeyPairSerializer(ModelSerializer):
"managed": {"read_only": True},
"key_data": {"write_only": True},
"certificate_data": {"write_only": True},
"fingerprint_sha256": {"read_only": True},
"fingerprint_sha1": {"read_only": True},
"cert_expiry": {"read_only": True},
"cert_subject": {"read_only": True},
"key_type": {"read_only": True},
}
@@ -173,12 +216,17 @@ class CertificateKeyPairFilter(FilterSet):
return queryset.exclude(key_data__exact="")
def filter_key_type(self, queryset, name, value): # pragma: no cover
"""Filter certificates by key type using the stored database field"""
"""Filter certificates by key type using the public key from the certificate"""
if not value:
return queryset
# value is a list of KeyType enum values from MultipleChoiceFilter
return queryset.filter(key_type__in=value)
filtered_pks = []
for cert in queryset:
if cert.key_type in value:
filtered_pks.append(cert.pk)
return queryset.filter(pk__in=filtered_pks)
class Meta:
model = CertificateKeyPair
@@ -215,6 +263,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
"Can be specified multiple times (e.g. '?key_type=rsa&key_type=ec')"
),
),
OpenApiParameter("include_details", bool, default=True),
]
)
def list(self, request, *args, **kwargs):

View File

@@ -1,120 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-09 06:22
from hashlib import md5
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import load_pem_x509_certificate
from django.db import migrations, models
from authentik.crypto.signals import extract_certificate_metadata
from authentik.lib.migrations import progress_bar
def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
"""Backfill certificate metadata and kid for existing records."""
db_alias = schema_editor.connection.alias
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
print("\nStoring extra data about certificates, this might take a couple of minutes...")
for cert in progress_bar(CertificateKeyPair.objects.using(db_alias).all()):
updated_fields = []
if cert.certificate_data:
try:
certificate = load_pem_x509_certificate(
cert.certificate_data.encode("utf-8"), default_backend()
)
metadata = extract_certificate_metadata(certificate)
cert.key_type = metadata["key_type"]
cert.cert_expiry = metadata["cert_expiry"]
cert.cert_subject = metadata["cert_subject"]
cert.fingerprint_sha256 = metadata["fingerprint_sha256"]
cert.fingerprint_sha1 = metadata["fingerprint_sha1"]
updated_fields.extend(
[
"key_type",
"cert_expiry",
"cert_subject",
"fingerprint_sha256",
"fingerprint_sha1",
]
)
except ValueError, TypeError, AttributeError:
pass
# Backfill kid with MD5 for backwards compatibility
if cert.key_data:
cert.kid = md5(cert.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
updated_fields.append("kid")
if updated_fields:
cert.save(update_fields=updated_fields, using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0005_alter_certificatekeypair_options"),
]
operations = [
migrations.AddField(
model_name="certificatekeypair",
name="cert_expiry",
field=models.DateTimeField(blank=True, help_text="Certificate expiry date", null=True),
),
migrations.AddField(
model_name="certificatekeypair",
name="cert_subject",
field=models.TextField(
blank=True, help_text="Certificate subject as RFC4514 string", null=True
),
),
migrations.AddField(
model_name="certificatekeypair",
name="fingerprint_sha1",
field=models.CharField(
blank=True,
help_text="SHA1 fingerprint of the certificate",
max_length=59,
null=True,
),
),
migrations.AddField(
model_name="certificatekeypair",
name="fingerprint_sha256",
field=models.CharField(
blank=True,
help_text="SHA256 fingerprint of the certificate",
max_length=95,
null=True,
),
),
migrations.AddField(
model_name="certificatekeypair",
name="key_type",
field=models.CharField(
blank=True,
choices=[
("rsa", "RSA"),
("ec", "Elliptic Curve"),
("dsa", "DSA"),
("ed25519", "Ed25519"),
("ed448", "Ed448"),
],
help_text="Key algorithm type detected from the certificate's public key",
max_length=16,
null=True,
),
),
migrations.AddField(
model_name="certificatekeypair",
name="kid",
field=models.CharField(
blank=True, help_text="Key ID generated from private key", max_length=128, null=True
),
),
migrations.RunPython(backfill_certificate_metadata, migrations.RunPython.noop),
]

View File

@@ -1,8 +1,7 @@
"""authentik crypto models"""
from base64 import urlsafe_b64encode
from binascii import hexlify
from hashlib import md5, sha512
from hashlib import md5
from ssl import PEM_FOOTER, PEM_HEADER
from textwrap import wrap
from uuid import uuid4
@@ -48,39 +47,6 @@ def fingerprint_sha256(cert: Certificate) -> str:
return hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8")
def detect_key_type(certificate: Certificate) -> str | None:
"""Detect the key algorithm type by parsing the certificate's public key"""
try:
public_key = certificate.public_key()
if isinstance(public_key, RSAPublicKey):
return KeyType.RSA
if isinstance(public_key, EllipticCurvePublicKey):
return KeyType.EC
if isinstance(public_key, DSAPublicKey):
return KeyType.DSA
if isinstance(public_key, Ed25519PublicKey):
return KeyType.ED25519
if isinstance(public_key, Ed448PublicKey):
return KeyType.ED448
except (ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("Failed to detect key type", exc=exc)
return None
def generate_key_id(key_data: str) -> str:
"""Generate Key ID using SHA512 + urlsafe_b64encode."""
if not key_data:
return ""
return urlsafe_b64encode(sha512(key_data.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
def generate_key_id_legacy(key_data: str) -> str:
"""Generate Key ID using MD5 (legacy format for backwards compatibility)."""
if not key_data:
return ""
return md5(key_data.encode("utf-8")).hexdigest() # nosec
class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
is set, otherwise it can be used to verify remote data."""
@@ -96,41 +62,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
blank=True,
default="",
)
key_type = models.CharField(
max_length=16,
choices=KeyType.choices,
null=True,
blank=True,
help_text=_("Key algorithm type detected from the certificate's public key"),
)
cert_expiry = models.DateTimeField(
null=True,
blank=True,
help_text=_("Certificate expiry date"),
)
cert_subject = models.TextField(
null=True,
blank=True,
help_text=_("Certificate subject as RFC4514 string"),
)
fingerprint_sha256 = models.CharField(
max_length=95,
null=True,
blank=True,
help_text=_("SHA256 fingerprint of the certificate"),
)
fingerprint_sha1 = models.CharField(
max_length=59,
null=True,
blank=True,
help_text=_("SHA1 fingerprint of the certificate"),
)
kid = models.CharField(
max_length=128,
null=True,
blank=True,
help_text=_("Key ID generated from private key"),
)
_cert: Certificate | None = None
_private_key: PrivateKeyTypes | None = None
@@ -175,6 +106,41 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
return None
return self._private_key
@property
def fingerprint_sha256(self) -> str:
"""Get SHA256 Fingerprint of certificate_data"""
return fingerprint_sha256(self.certificate)
@property
def fingerprint_sha1(self) -> str:
"""Get SHA1 Fingerprint of certificate_data"""
return hexlify(self.certificate.fingerprint(hashes.SHA1()), ":").decode("utf-8") # nosec
@property
def kid(self):
"""Get Key ID used for JWKS"""
return (
md5(self.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
if self.key_data
else ""
) # nosec
@property
def key_type(self) -> str | None:
"""Get the key algorithm type from the certificate's public key"""
public_key = self.certificate.public_key()
if isinstance(public_key, RSAPublicKey):
return KeyType.RSA
if isinstance(public_key, EllipticCurvePublicKey):
return KeyType.EC
if isinstance(public_key, DSAPublicKey):
return KeyType.DSA
if isinstance(public_key, Ed25519PublicKey):
return KeyType.ED25519
if isinstance(public_key, Ed448PublicKey):
return KeyType.ED448
return None
def __str__(self) -> str:
return f"Certificate-Key Pair {self.name}"

View File

@@ -1,70 +0,0 @@
"""authentik crypto signals"""
from binascii import hexlify
from datetime import datetime
from ssl import CertificateError
from cryptography.hazmat.primitives import hashes
from cryptography.x509 import Certificate
from django.db.models.signals import pre_save
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.crypto.models import (
CertificateKeyPair,
detect_key_type,
fingerprint_sha256,
generate_key_id,
generate_key_id_legacy,
)
LOGGER = get_logger()
def extract_certificate_metadata(certificate: Certificate) -> dict[str, str | datetime]:
"""Extract all metadata fields from a certificate."""
metadata = {}
try:
metadata["key_type"] = detect_key_type(certificate)
metadata["cert_expiry"] = certificate.not_valid_after_utc
metadata["cert_subject"] = certificate.subject.rfc4514_string()
metadata["fingerprint_sha256"] = fingerprint_sha256(certificate)
metadata["fingerprint_sha1"] = hexlify(
certificate.fingerprint(hashes.SHA1()), ":" # nosec
).decode("utf-8")
except (ValueError, TypeError, AttributeError) as exc:
raise CertificateError(f"Invalid certificate metadata: {exc}") from exc
return metadata
@receiver(pre_save, sender="authentik_crypto.CertificateKeyPair")
def certificate_key_pair_pre_save(
sender: type[CertificateKeyPair], instance: CertificateKeyPair, **_
):
"""Automatically populate certificate metadata fields before saving"""
# Only extract metadata if certificate_data is present
if not instance.certificate_data:
return
try:
metadata = extract_certificate_metadata(instance.certificate)
except (CertificateError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("Failed to extract certificate metadata", exc=exc)
return
instance.key_type = metadata["key_type"]
instance.cert_expiry = metadata["cert_expiry"]
instance.cert_subject = metadata["cert_subject"]
instance.fingerprint_sha256 = metadata["fingerprint_sha256"]
instance.fingerprint_sha1 = metadata["fingerprint_sha1"]
# Generate kid if not set, or regenerate if key_data has changed
# Preserve existing kid (MD5 or SHA512) if it matches the current key_data
if instance.key_data:
new_kid = generate_key_id(instance.key_data)
legacy_kid = generate_key_id_legacy(instance.key_data)
if instance.kid not in (new_kid, legacy_kid):
instance.kid = new_kid

View File

@@ -20,7 +20,7 @@ from authentik.core.tests.utils import (
)
from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair, generate_key_id, generate_key_id_legacy
from authentik.crypto.models import CertificateKeyPair
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id, generate_key
@@ -173,33 +173,28 @@ class TestCrypto(APITestCase):
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
def test_list_always_includes_details(self):
"""Test API List always includes certificate details"""
def test_list_without_details(self):
"""Test API List (no details)"""
cert = create_test_cert()
self.client.force_login(create_test_admin_user())
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-list",
),
data={"name": cert.name},
data={"name": cert.name, "include_details": False},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
# All details should now always be included
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
self.assertIsNotNone(api_cert["cert_expiry"])
self.assertIsNotNone(api_cert["cert_subject"])
self.assertEqual(api_cert["fingerprint_sha1"], None)
self.assertEqual(api_cert["fingerprint_sha256"], None)
def test_certificate_download(self):
"""Test certificate export (download)"""
keypair = create_test_cert()
user = create_test_user()
user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair", keypair)
user.assign_perms_to_managed_role(
"authentik_crypto.view_certificatekeypair_certificate", keypair
)
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_certificate", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(
@@ -222,8 +217,8 @@ class TestCrypto(APITestCase):
"""Test private_key export (download)"""
keypair = create_test_cert()
user = create_test_user()
user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair_key", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_key", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(
@@ -431,114 +426,3 @@ class TestCrypto(APITestCase):
self.assertEqual(
1, final_count, "Should not create duplicate cert for same private key"
)
def test_metadata_extraction_with_cert_and_key(self):
"""Test that metadata is extracted when creating keypair with certificate and key"""
cert = create_test_cert()
# Verify all metadata fields are populated
self.assertIsNotNone(cert.key_type)
self.assertIsNotNone(cert.cert_expiry)
self.assertIsNotNone(cert.cert_subject)
self.assertIsNotNone(cert.fingerprint_sha256)
self.assertIsNotNone(cert.fingerprint_sha1)
# Verify kid is generated using SHA512 for new records
self.assertIsNotNone(cert.kid)
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
def test_metadata_extraction_without_key(self):
"""Test that metadata is extracted when creating keypair without private key"""
builder = CertificateBuilder(generate_id())
builder.build(subject_alt_names=[], validity_days=3)
# Create keypair with only certificate, no key
cert = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data=builder.certificate,
key_data="",
)
# Verify certificate metadata fields are populated
self.assertIsNotNone(cert.key_type)
self.assertIsNotNone(cert.cert_expiry)
self.assertIsNotNone(cert.cert_subject)
self.assertIsNotNone(cert.fingerprint_sha256)
self.assertIsNotNone(cert.fingerprint_sha1)
# Verify kid is empty when no key_data
self.assertEqual(cert.kid, None)
def test_metadata_extraction_invalid_cert(self):
"""Test that invalid certificate data doesn't crash, just skips metadata"""
cert = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data="invalid certificate data",
key_data="",
)
# Verify metadata fields are None for invalid cert
self.assertIsNone(cert.key_type)
self.assertIsNone(cert.cert_expiry)
self.assertIsNone(cert.cert_subject)
self.assertIsNone(cert.fingerprint_sha256)
self.assertIsNone(cert.fingerprint_sha1)
self.assertIsNone(cert.kid)
def test_kid_legacy_preservation(self):
"""Test that legacy MD5 kid is preserved when key_data hasn't changed"""
cert = create_test_cert()
# Simulate a legacy MD5 kid (as if backfilled from old system)
legacy_kid = generate_key_id_legacy(cert.key_data)
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
cert.refresh_from_db()
self.assertEqual(cert.kid, legacy_kid)
# Save the cert again (e.g., name change) - kid should be preserved
cert.name = generate_id()
cert.save()
cert.refresh_from_db()
self.assertEqual(cert.kid, legacy_kid)
def test_kid_regenerated_on_key_change(self):
"""Test that kid is regenerated when key_data changes"""
cert = create_test_cert()
original_kid = cert.kid
# Generate a new key and update the keypair
builder = CertificateBuilder(generate_id())
builder.build(subject_alt_names=[], validity_days=3)
cert.key_data = builder.private_key
cert.certificate_data = builder.certificate
cert.save()
cert.refresh_from_db()
# Kid should be regenerated for the new key
self.assertNotEqual(cert.kid, original_kid)
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
def test_kid_regenerated_on_key_change_from_legacy(self):
"""Test that kid is regenerated from legacy MD5 when key_data changes"""
cert = create_test_cert()
# Simulate a legacy MD5 kid
legacy_kid = generate_key_id_legacy(cert.key_data)
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
cert.refresh_from_db()
self.assertEqual(cert.kid, legacy_kid)
# Generate a new key and update the keypair
builder = CertificateBuilder(generate_id())
builder.build(subject_alt_names=[], validity_days=3)
cert.key_data = builder.private_key
cert.certificate_data = builder.certificate
cert.save()
cert.refresh_from_db()
# Kid should now be SHA512 for the new key
self.assertNotEqual(cert.kid, legacy_kid)
self.assertEqual(cert.kid, generate_key_id(cert.key_data))

View File

@@ -12,7 +12,6 @@ class DeviceAccessGroupSerializer(ModelSerializer):
fields = [
"pbm_uuid",
"name",
"attributes",
]

View File

@@ -3,7 +3,7 @@ from rest_framework.fields import SerializerMethodField
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.api.device_fact_snapshots import DeviceFactSnapshotSerializer
from authentik.endpoints.models import Connector, DeviceConnection, DeviceFactSnapshot
from authentik.endpoints.models import DeviceConnection
class DeviceConnectionSerializer(ModelSerializer):
@@ -12,19 +12,10 @@ class DeviceConnectionSerializer(ModelSerializer):
latest_snapshot = SerializerMethodField(allow_null=True)
def get_latest_snapshot(self, instance: DeviceConnection) -> DeviceFactSnapshotSerializer:
snapshot: DeviceFactSnapshot | None = instance.devicefactsnapshot_set.order_by(
"-created"
).first()
snapshot = instance.devicefactsnapshot_set.order_by("-created").first()
if not snapshot:
return None
connector: Connector = Connector.objects.get_subclass(pk=snapshot.connection.connector_id)
vendor = connector.controller.vendor_identifier()
return DeviceFactSnapshotSerializer(
snapshot,
context={
"vendor": vendor,
},
).data
return DeviceFactSnapshotSerializer(snapshot).data
class Meta:
model = DeviceConnection

View File

@@ -1,32 +1,11 @@
from enum import StrEnum
from rest_framework.fields import SerializerMethodField
from authentik.core.api.utils import ModelSerializer
from authentik.endpoints.controller import MERGED_VENDOR
from authentik.endpoints.facts import DeviceFacts
from authentik.endpoints.models import Connector, DeviceFactSnapshot
from authentik.lib.utils.reflection import all_subclasses
def get_vendor_choices():
choices = [(MERGED_VENDOR, MERGED_VENDOR)]
for connector_type in all_subclasses(Connector):
ident = connector_type().controller.vendor_identifier()
choices.append((ident, ident))
return choices
vendors = StrEnum("DeviceConnectorVendors", get_vendor_choices())
from authentik.endpoints.models import DeviceFactSnapshot
class DeviceFactSnapshotSerializer(ModelSerializer):
data = DeviceFacts()
vendor = SerializerMethodField()
def get_vendor(self, instance: DeviceFactSnapshot) -> vendors:
return self.context.get("vendor", MERGED_VENDOR)
class Meta:
model = DeviceFactSnapshot
@@ -35,7 +14,6 @@ class DeviceFactSnapshotSerializer(ModelSerializer):
"connection",
"created",
"expires",
"vendor",
]
extra_kwargs = {
"created": {"read_only": True},

View File

@@ -3,10 +3,11 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.endpoints.models import EndpointStage
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.flows.api.stages import StageSerializer
class EndpointStageSerializer(StageSerializer):
class EndpointStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""EndpointStage Serializer"""
connector_obj = ConnectorSerializer(source="connector", read_only=True)

View File

@@ -1,4 +1,3 @@
from django.db import models
from rest_framework.fields import (
BooleanField,
CharField,
@@ -15,12 +14,6 @@ from authentik.endpoints.models import Device
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.views.jwks import JWKSView
try:
from authentik.enterprise.models import LicenseUsageStatus
except ImportError:
class LicenseUsageStatus(models.TextChoices): ...
class AgentConfigSerializer(PassiveSerializer):
@@ -36,7 +29,6 @@ class AgentConfigSerializer(PassiveSerializer):
auth_terminate_session_on_expiry = BooleanField()
system_config = SerializerMethodField()
license_status = SerializerMethodField(required=False, allow_null=True)
def get_device_id(self, instance: AgentConnector) -> str:
device: Device = self.context["device"]
@@ -62,14 +54,6 @@ class AgentConfigSerializer(PassiveSerializer):
def get_system_config(self, instance: AgentConnector) -> ConfigSerializer:
return ConfigView.get_config(self.context["request"]).data
def get_license_status(self, instance: AgentConnector) -> LicenseUsageStatus:
try:
from authentik.enterprise.license import LicenseKey
return LicenseKey.cached_summary().status
except ModuleNotFoundError:
return None
class EnrollSerializer(PassiveSerializer):

View File

@@ -44,10 +44,6 @@ class MDMConfigResponseSerializer(PassiveSerializer):
class AgentConnectorController(BaseController[AgentConnector]):
@staticmethod
def vendor_identifier() -> str:
return "goauthentik.io/platform"
def supported_enrollment_methods(self):
return []

View File

@@ -2,7 +2,6 @@ from typing import TYPE_CHECKING
from uuid import uuid4
from django.db import models
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
@@ -17,7 +16,7 @@ from authentik.endpoints.models import (
)
from authentik.flows.stage import StageView
from authentik.lib.generators import generate_key
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
if TYPE_CHECKING:
@@ -52,10 +51,6 @@ class AgentConnector(Connector):
)
challenge_trigger_check_in = models.BooleanField(default=False)
@property
def icon_url(self):
return static("dist/assets/icons/icon.svg")
@property
def serializer(self) -> type[Serializer]:
from authentik.endpoints.connectors.agent.api.connectors import (
@@ -73,7 +68,7 @@ class AgentConnector(Connector):
return AuthenticatorEndpointStageView
@property
def controller(self) -> type[AgentConnectorController]:
def controller(self) -> type["AgentConnectorController"]:
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
return AgentConnectorController
@@ -102,7 +97,7 @@ class AgentDeviceUserBinding(DeviceUserBinding):
apple_enclave_key_id = models.TextField()
class DeviceToken(InternallyManagedMixin, ExpiringModel):
class DeviceToken(ExpiringModel):
"""Per-device token used for authentication."""
token_uuid = models.UUIDField(primary_key=True, default=uuid4)
@@ -148,7 +143,7 @@ class EnrollmentToken(ExpiringModel, SerializerModel):
]
class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
class DeviceAuthenticationToken(ExpiringModel):
identifier = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey(Device, on_delete=models.CASCADE)
@@ -165,7 +160,7 @@ class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
verbose_name_plural = _("Device authentication tokens")
class AppleNonce(InternallyManagedMixin, ExpiringModel):
class AppleNonce(ExpiringModel):
nonce = models.TextField()
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)

View File

@@ -1,5 +1,4 @@
from datetime import timedelta
from hashlib import sha256
from hmac import compare_digest
from django.http import HttpResponse
@@ -9,7 +8,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, IntegerField
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import DeviceAuthenticationToken, DeviceToken
from authentik.endpoints.connectors.agent.models import DeviceToken
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.flows.challenge import (
Challenge,
@@ -21,7 +20,6 @@ from authentik.lib.generators import generate_id
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.models import JWTAlgorithms
PLAN_CONTEXT_DEVICE_AUTH_TOKEN = "goauthentik.io/endpoints/device_auth_token" # nosec
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
QS_CHALLENGE = "challenge"
QS_CHALLENGE_RESPONSE = "response"
@@ -87,36 +85,12 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
response_class = EndpointAgentChallengeResponse
def get(self, request, *args, **kwargs):
# Check if we're in a device interactive auth flow, in which case we use that
# to prove which device is being used
if response := self.check_device_ia():
return response
stage: EndpointStage = self.executor.current_stage
keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
if not keypair:
return self.executor.stage_ok()
return super().get(request, *args, **kwargs)
def check_device_ia(self):
"""Check if we're in a device interactive authentication flow, and if so,
there won't be a browser extension to talk to. However we can authenticate
on the DTH header"""
if PLAN_CONTEXT_DEVICE_AUTH_TOKEN not in self.executor.plan.context:
return None
auth_token: DeviceAuthenticationToken = self.executor.plan.context.get(
PLAN_CONTEXT_DEVICE_AUTH_TOKEN
)
device_token_hash = self.request.headers.get("X-Authentik-Platform-Auth-DTH")
if not device_token_hash:
return None
if not compare_digest(
device_token_hash, sha256(auth_token.device_token.key.encode()).hexdigest()
):
return self.executor.stage_invalid("Invalid device token")
self.logger.debug("Setting device based on DTH header")
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = auth_token.device
return self.executor.stage_ok()
def get_challenge(self, *args, **kwargs) -> Challenge:
stage: EndpointStage = self.executor.current_stage
keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)

View File

@@ -1,4 +1,3 @@
from hashlib import sha256
from json import loads
from django.urls import reverse
@@ -8,14 +7,10 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.endpoints.connectors.agent.models import (
AgentConnector,
AgentDeviceConnection,
DeviceAuthenticationToken,
DeviceToken,
EnrollmentToken,
)
from authentik.endpoints.connectors.agent.stage import (
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE,
PLAN_CONTEXT_DEVICE_AUTH_TOKEN,
)
from authentik.endpoints.connectors.agent.stage import PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
@@ -40,11 +35,6 @@ class TestEndpointStage(FlowTestCase):
device=self.connection,
key=generate_id(),
)
self.device_auth_token = DeviceAuthenticationToken.objects.create(
device=self.device,
device_token=self.device_token,
connector=self.connector,
)
def test_endpoint_stage(self):
flow = create_test_flow()
@@ -204,31 +194,3 @@ class TestEndpointStage(FlowTestCase):
"response": [{"string": "Invalid challenge response", "code": "invalid"}]
},
)
def test_endpoint_stage_ia_dth(self):
"""Test with DTH"""
flow = create_test_flow()
stage = EndpointStage.objects.create(connector=self.connector)
FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
# Send an "invalid" request first, to populate the flow plan
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
plan = self.get_flow_plan()
plan.context[PLAN_CONTEXT_DEVICE_AUTH_TOKEN] = DeviceAuthenticationToken.objects.get(
pk=self.device_auth_token.pk
)
self.set_flow_plan(plan)
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
HTTP_X_AUTHENTIK_PLATFORM_AUTH_DTH=sha256(
self.device_token.key.encode()
).hexdigest(),
)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
plan = plan()
self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], self.device)

View File

@@ -5,8 +5,6 @@ from authentik.endpoints.models import Connector
from authentik.flows.stage import StageView
from authentik.lib.sentry import SentryIgnoredException
MERGED_VENDOR = "goauthentik.io/@merged"
class EnrollmentMethods(models.TextChoices):
# Automatically enrolled through user action
@@ -30,10 +28,6 @@ class BaseController[T: "Connector"]:
self.connector = connector
self.logger = get_logger().bind(connector=connector.name)
@staticmethod
def vendor_identifier() -> str:
raise NotImplementedError
def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
return []

View File

@@ -1,5 +1,4 @@
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.plumbing import build_basic_type
from drf_spectacular.types import OpenApiTypes
@@ -16,6 +15,7 @@ from authentik.core.api.utils import JSONDictField
class BigIntegerFieldFix(OpenApiSerializerFieldExtension):
target_class = "authentik.endpoints.facts.BigIntegerField"
def map_serializer_field(self, auto_schema, direction):
@@ -46,23 +46,9 @@ class DiskSerializer(Serializer):
class OperatingSystemSerializer(Serializer):
"""For example:
{"family":"linux","name":"Ubuntu","version":"24.04.3 LTS (Noble Numbat)","arch":"amd64"}
{"family": "windows","name":"Server 2022 Datacenter","version":"10.0.20348.4405","arch":"amd64"}
{"family": "windows","name":"Server 2022 Datacenter","version":"10.0.20348.4405","arch":"amd64"}
{"family": "mac_os", "name": "", "version": "26.2", "arch": "arm64"}
"""
family = ChoiceField(OSFamily.choices, required=True)
name = CharField(
required=False, help_text=_("Operating System name, such as 'Server 2022' or 'Ubuntu'")
)
version = CharField(
required=False,
help_text=_(
"Operating System version, must always be the version number but may contain build name"
),
)
name = CharField(required=False)
version = CharField(required=False)
arch = CharField(required=True)

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-08 23:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_endpoints", "0003_alter_endpointstage_options_endpointstage_mode"),
]
operations = [
migrations.AddField(
model_name="deviceaccessgroup",
name="attributes",
field=models.JSONField(blank=True, default=dict),
),
]

View File

@@ -15,7 +15,7 @@ from authentik.core.models import AttributesMixin, ExpiringModel
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.policies.models import PolicyBinding, PolicyBindingModel
from authentik.tasks.schedules.common import ScheduleSpec
@@ -28,7 +28,7 @@ LOGGER = get_logger()
DEVICE_FACTS_CACHE_TIMEOUT = 3600
class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindingModel):
class Device(ExpiringModel, AttributesMixin, PolicyBindingModel):
device_uuid = models.UUIDField(default=uuid4, primary_key=True)
name = models.TextField(unique=True)
@@ -43,7 +43,7 @@ class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindi
return f"goauthentik.io/endpoints/devices/{self.device_uuid}/facts"
@property
def cached_facts(self) -> DeviceFactSnapshot:
def cached_facts(self) -> "DeviceFactSnapshot":
if cached := cache.get(self.cache_key_facts):
return cached
facts = self.facts
@@ -51,7 +51,7 @@ class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindi
return facts
@property
def facts(self) -> DeviceFactSnapshot:
def facts(self) -> "DeviceFactSnapshot":
data = {}
last_updated = datetime.fromtimestamp(0, UTC)
for snapshot_data, snapshort_created in DeviceFactSnapshot.filter_not_expired(
@@ -86,7 +86,7 @@ class DeviceUserBinding(PolicyBinding):
verbose_name_plural = _("Device User bindings")
class DeviceConnection(InternallyManagedMixin, SerializerModel):
class DeviceConnection(SerializerModel):
device_connection_uuid = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE)
connector = models.ForeignKey("Connector", on_delete=models.CASCADE)
@@ -115,7 +115,7 @@ class DeviceConnection(InternallyManagedMixin, SerializerModel):
verbose_name_plural = _("Device connections")
class DeviceFactSnapshot(InternallyManagedMixin, ExpiringModel, SerializerModel):
class DeviceFactSnapshot(ExpiringModel, SerializerModel):
snapshot_id = models.UUIDField(primary_key=True, default=uuid4)
connection = models.ForeignKey(DeviceConnection, on_delete=models.CASCADE)
data = models.JSONField(default=dict)
@@ -157,7 +157,7 @@ class Connector(ScheduledModel, SerializerModel):
raise NotImplementedError
@property
def controller(self) -> type[BaseController[Connector]]:
def controller(self) -> type["BaseController[Connector]"]:
raise NotImplementedError
@property
@@ -175,7 +175,7 @@ class Connector(ScheduledModel, SerializerModel):
]
class DeviceAccessGroup(AttributesMixin, SerializerModel, PolicyBindingModel):
class DeviceAccessGroup(SerializerModel, PolicyBindingModel):
name = models.TextField(unique=True)
@@ -205,7 +205,7 @@ class EndpointStage(Stage):
mode = models.TextField(choices=StageMode.choices, default=StageMode.OPTIONAL)
@property
def view(self) -> type[StageView]:
def view(self) -> type["StageView"]:
from authentik.endpoints.stage import EndpointStageView
return EndpointStageView

View File

@@ -3,12 +3,6 @@
from django.conf import settings
from authentik.enterprise.apps import EnterpriseConfig
from authentik.tenants.flags import Flag
class AuditIncludeExpandedDiff(Flag[bool], key="enterprise_audit_include_expanded_diff"):
default = False
visibility = "none"
class AuthentikEnterpriseAuditConfig(EnterpriseConfig):

View File

@@ -12,7 +12,6 @@ from django.db.models.expressions import BaseExpression, Combinable
from django.db.models.signals import post_init
from django.http import HttpRequest
from authentik.enterprise.audit.apps import AuditIncludeExpandedDiff
from authentik.events.middleware import AuditMiddleware, should_log_model
from authentik.events.utils import cleanse_dict, sanitize_item
@@ -144,9 +143,5 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
# If we're clearing we just set the "flag" to True
if action_direction == "clear":
pk_set = True
elif AuditIncludeExpandedDiff.get():
related_model: type[Model] = m2m_field.related_model
instances = related_model.objects.filter(pk__in=pk_set)
pk_set = [self.serialize_simple(instance) for instance in instances]
thread_kwargs["diff"] = {m2m_field.related_name: {action_direction: pk_set}}
return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs)

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