Compare commits

...

76 Commits

Author SHA1 Message Date
authentik-automation[bot]
2fedc3d0a0 release: 2025.10.2 2025-11-19 15:07:06 +00:00
authentik-automation[bot]
7f0b45f921 website/docs: add 2025.8.5 and 2025.10.2 release notes (cherry-pick #18268 to version-2025.10) (#18270)
website/docs: add 2025.8.5 and 2025.10.2 release notes (#18268)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-19 15:29:42 +01:00
authentik-automation[bot]
3905c281ad internal: Automated internal backport: 5000-sidebar.sec.patch to authentik-2025.10 (#18260)
Automated internal backport of patch 5000-sidebar.sec.patch to authentik-2025.10

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-19 15:10:38 +01:00
authentik-automation[bot]
e6099d43f5 internal: Automated internal backport: 1498-oauth2-cc-user-active.sec.patch to authentik-2025.10 (#18259)
Automated internal backport of patch 1498-oauth2-cc-user-active.sec.patch to authentik-2025.10

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-11-19 14:51:31 +01:00
authentik-automation[bot]
a91145bc7b internal: Automated internal backport: 1487-invitation-expiry.sec.patch to authentik-2025.10 (#18258)
Automated internal backport of patch 1487-invitation-expiry.sec.patch to authentik-2025.10

Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-11-19 14:51:03 +01:00
authentik-automation[bot]
3f38d5c7d9 stages/prompt: fix choices with labels causing error on submit (cherry-pick #18183 to version-2025.10) (#18236)
stages/prompt: fix choices with labels causing error on submit (#18183)

* stages/prompt: fix choices with labels causing error on submit



* fix tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-18 18:33:10 +01:00
authentik-automation[bot]
c00df0573c website/docs: update application description (cherry-pick #18125 to version-2025.10) (#18127)
website/docs: update application description (#18125)

Update due to 2025.10 changes

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-11-18 16:34:55 +00:00
authentik-automation[bot]
c3a0edee00 website/docs: Add instructions for installing RC versions (cherry-pick #18099 to version-2025.10) (#18193)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-11-17 23:52:39 +00:00
authentik-automation[bot]
8b81ca36ea web/sfe: downgrade bootstrap that was accidentally upgraded (cherry-pick #18157 to version-2025.10) (#18171)
* Cherry-pick #18157 to version-2025.10 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #18157
Original commit: 4caece7fef

* fix conflict

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-11-16 20:03:40 +01:00
authentik-automation[bot]
698de68a36 web: Disable library <datalist> on Firefox. (cherry-pick #18103 to version-2025.10) (#18135)
Cherry-pick #18103 to version-2025.10 (with conflicts)
This cherry-pick has conflicts that need manual resolution.

Original PR: #18103
Original commit: 1115e6f

Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-11-14 19:41:50 +01:00
authentik-automation[bot]
db35593b24 packages/django-channels-postgres/layer: fix query when subscribed to multiple channels (cherry-pick #18152 to version-2025.10) (#18153)
packages/django-channels-postgres/layer: fix query when subscribed to multiple channels (#18152)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-11-14 19:41:12 +01:00
authentik-automation[bot]
445fa31b57 web/admin: link to user on invitation list page (cherry-pick #18132 to version-2025.10) (#18134)
web/admin: link to user on invitation list page (#18132)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-13 22:38:06 +01:00
authentik-automation[bot]
a9aa1bf2c2 web/flows: improvements for hCaptcha (cherry-pick #16882 to version-2025.10) (#18128)
web/flows: improvements for hCaptcha (#16882)

* improvements for hCaptcha
Issue #16755

* web: Format.

---------

Co-authored-by: Tealk <12276250+Tealk@users.noreply.github.com>
Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2025-11-13 21:02:26 +01:00
authentik-automation[bot]
d018f0381c packages/django-dramatiq-postgres: broker: ensure locking happens with the same connection (cherry-pick #18095 to version-2025.10) (#18119)
packages/django-dramatiq-postgres: broker: ensure locking happens with the same connection (#18095)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-11-13 17:18:13 +00:00
authentik-automation[bot]
7dd1cd5c59 website/docs: fix wording in stages overview (cherry-pick #18061 to version-2025.10) (#18120)
website/docs: fix wording in stages overview (#18061)

Change flow to stage

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-11-13 16:03:31 +00:00
authentik-automation[bot]
c219a6804a web: Fix tab activation, blank provider URLs (cherry-pick #18031 to version-2025.10) (#18101)
web: Fix tab activation, blank provider URLs (#18031)

web: Fix tab activation.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-11-13 12:47:38 +01:00
authentik-automation[bot]
d9310d04b0 web: Fix RAC modal visibility. (cherry-pick #17941 to version-2025.10) (#18097)
web: Fix RAC modal visibility. (#17941)

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-11-12 23:22:02 +01:00
authentik-automation[bot]
f471ef0e2e cmd/server/healthcheck: remove worker HTTP healthcheck (cherry-pick #18090 to version-2025.10) (#18091)
cmd/server/healthcheck: remove worker HTTP healthcheck (#18090)

* cmd/server/healthcheck: remove worker HTTP healthcheck



* lint



---------

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-11-12 16:12:17 +01:00
authentik-automation[bot]
31a010c108 core: improve app launch URL formatting (cherry-pick #18076 to version-2025.10) (#18087)
core: improve app launch URL formatting (#18076)

* core: improve app launch URL formatting



* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-12 13:06:46 +01:00
authentik-automation[bot]
96e6ab291e providers/scim: allow custom schema data (cherry-pick #18073 to version-2025.10) (#18075)
providers/scim: allow custom schema data (#18073)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-12 00:54:08 +01:00
authentik-automation[bot]
ebf68311c2 events: fix timezone not set for log events (cherry-pick #18067 to version-2025.10) (#18071)
events: fix timezone not set for log events (#18067)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-11 21:20:06 +01:00
Jens L.
fd365b2a09 ci: revert to upstream GHA for release (#18058) (#18065)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-11-11 18:50:24 +01:00
authentik-automation[bot]
41104da41f ci: attempt to fix integration tests using dind (cherry-pick #18066 to version-2025.10) (#18069)
ci: attempt to fix integration tests using dind (#18066)

* ci: attempt to fix integration tests using dind



* bump dind version



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-11 18:18:53 +01:00
authentik-automation[bot]
7edebdec03 website/docs: update discord social login script example (cherry-pick #18026 to version-2025.10) (#18057)
website/docs: update discord social login script example (#18026)

update the guild membership example to no longer cause an exception from a missing import.

Closes #18025

Signed-off-by: TMUniversal <10200399+TMUniversal@users.noreply.github.com>
Co-authored-by: TMUniversal <10200399+TMUniversal@users.noreply.github.com>
2025-11-11 13:02:48 +01:00
authentik-automation[bot]
fb56a54eb1 website/release notes: fix broken urls (cherry-pick #18041 to version-2025.10) (#18044)
website/release notes: fix broken urls (#18041)

* website: fix bad escaping of URLs in release notes

## What

Fixes bad escaping of URLs in the release notes that resulted in mangled output.

v2024.6.4 had entries that looked like this:

```
##### `GET` /providers/google_workspace/{#123;id}#125;/
```

v2025.4.md had entries that looked like this:

```
##### `GET` /policies/unique_password/{#125;#123;policy_uuid}/
```

A couple of straightforward search-and-replaces has fixed the issue.

## Notes

Two of the release notes had bad escaping of URLs. I'm not sure how the error was made or got past,
but it was obvious when visiting the page.

@Beryju suggested that the bug is due to our using `{...}` to symbolize parameters in a URL while
Docusaurus wants to interpret `{...}` as an internal template instruction, resulting in odd
behavior. In either case, docusarus interpreted the hashtagged entries as links to unrelated issues
in Github (the same two issues, which were "bump version of pylint" and "bump version of sentry"),
which could be very confusing.

The inconsistencies between the two releases, and the working releases, suggests that the error was
introduced manually.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2025-11-10 15:50:29 -05:00
Jens L.
31cd6eb8ce ci: fix migrate-from-stable for old versions (#18019) (#18024)
ci: better logic for picking previous stable version

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-11-10 15:56:45 +01:00
authentik-automation[bot]
092c5eb33c website/docs: updates img-src csp (cherry-pick #18010 to version-2025.10) (#18012) 2025-11-06 21:11:37 +00:00
authentik-automation[bot]
3e41bba54d core: bump django from 5.2.7 to 5.2.8 (cherry-pick #17967 to version-2025.10) (#18003)
core: bump django from 5.2.7 to 5.2.8 (#17967)

* bump django from 5.2.7 to 5.2.8

* longer urls



* add debug statements

* Remove debug statements

* import MAX_URL_LENGTH constant from django.http.response

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-11-06 15:50:16 +01:00
authentik-automation[bot]
9f8fd6eabe website/docs: remove broken info box and fix sentence (cherry-pick #17963 to version-2025.10) (#17965)
webiste/docs: remove broken info box and fix sentence (#17963)

Remove broken info box and fix sentence.

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-11-05 15:29:14 +00:00
authentik-automation[bot]
35fb55da15 website/docs: added Note about email_verified scope mapping is set to false by default (cherry-pick #17942 to version-2025.10) (#17961)
website/docs: added Note about email_verified scope mapping is set to false by default (#17942)

* added Note about email_verified set to false

* Update website/docs/add-secure-apps/providers/property-mappings/index.md




* edits

* more edits

* Update website/docs/add-secure-apps/providers/property-mappings/index.md




---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-11-05 06:58:29 -06:00
authentik-automation[bot]
b1d571a5af tasks/schedules: fix rel obj not being associated or updated (cherry-pick #17934 to version-2025.10) (#17936)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix rel obj not being associated or updated (#17934)
2025-11-04 15:45:01 +01:00
authentik-automation[bot]
fb589592b5 brands: sort matched brand by match length (cherry-pick #17920 to version-2025.10) (#17935)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-11-04 14:41:42 +01:00
authentik-automation[bot]
6468bb5707 brands: add more matching tests (cherry-pick #16185 to version-2025.10) (#17924)
brands: add more matching tests (#16185)

* brands: reproduce matching error



* try some things



* fix tests



* fix tests



* Update authentik/brands/tests.py




* fix tests again?



* wip



---------

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-03 21:29:33 +00:00
authentik-automation[bot]
70406664dc release: 2025.10.1 2025-11-03 16:42:08 +00:00
authentik-automation[bot]
c58c194180 website/docs: 2025.10.1 release notes (cherry-pick #17918 to version-2025.10) (#17919)
website/docs: 2025.10.1 release notes (#17918)

* website/docs: 2025.10.1 release notes



* Apply suggestions from code review




* format



---------

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: Tana M Berry <tanamarieberry@yahoo.com>
2025-11-03 17:05:18 +01:00
authentik-automation[bot]
fad87741e7 providers/oauth2: fix kid always required for federation (cherry-pick #17914 to version-2025.10) (#17917)
providers/oauth2: fix kid always required for federation (#17914)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-03 16:26:31 +01:00
authentik-automation[bot]
f6679895e5 providers/radius: revert fix inverted message authenticator validation (#17855) (cherry-pick #17915 to version-2025.10) (#17916)
providers/radius: revert fix inverted message authenticator validation (#17855) (#17915)

Revert "providers/radius: fix inverted message authenticator validation (#17855)"

This reverts commit 09e3301c8f.

Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-03 16:26:17 +01:00
authentik-automation[bot]
a573a72ecb providers/radius: fix inverted message authenticator validation (cherry-pick #17855 to version-2025.10) (#17888)
providers/radius: fix inverted message authenticator validation (#17855)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-11-01 18:28:06 +01:00
authentik-automation[bot]
b72709ebbc web/a11y: User library -- fix issues surrounding element focus, ARIA labeling. (cherry-pick #17522 to version-2025.10) (#17828)
web/a11y: User library -- fix issues surrounding element focus, ARIA labeling. (#17522)

* web/a11y: Fix issues surrounding element focus, aria labeling.

* web: Fix focus

* web: Fix nested focus

* web: Fix menu visibility when anchor positioning is not supported.

* web: Fix icon fallback behavior, labels.

* web: Fix flickering, descriptions.

* web: Fix excess width on mobile.

* web: Fix rendering artifacts on mobile.

* web: Remove aria-controls behavior.

- This is buggy, similar to aria-owns, and may cause crashes.

* web: Fix tabpanel focus attempting to scroll page.

* web: Fix issues surrounding consistent tab panel parameter testing.

* web: add shared helpers.

* web: Tidy comments.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-11-01 17:05:19 +01:00
authentik-automation[bot]
449742fbc0 web: Consistent Tab Panel URL Parameters (cherry-pick #17804 to version-2025.10) (#17859)
web: Consistent Tab Panel URL Parameters (#17804)

* web: Fix tabpanel focus attempting to scroll page.

* web: Fix issues surrounding consistent tab panel parameter testing.

* web: add shared helpers.

* web: Tidy comments.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-11-01 17:04:43 +01:00
authentik-automation[bot]
1b02cc0dae internal: full openssl path (cherry-pick #17856 to version-2025.10) (#17860) 2025-10-31 15:40:51 +01:00
authentik-automation[bot]
b0945ee7e9 outpost: revert breaking signals change (cherry-pick #17847 to version-2025.10) (#17848)
outpost: revert breaking signals change (#17847)

I have no idea why this breaks tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-31 02:20:17 +01:00
authentik-automation[bot]
6682136af1 outposts: update permissions more eagerly (cherry-pick #17783 to version-2025.10) (#17841)
outposts: update permissions more eagerly (#17783)

* wip

* wip

* a

* a



* rm

* this

* rm test files

* cover one more case



---------

Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-10-31 00:33:54 +01:00
authentik-automation[bot]
24cb5ae4c1 tasks: sanitize log attributes (cherry-pick #17833 to version-2025.10) (#17842)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-30 19:03:13 +01:00
authentik-automation[bot]
9e272c7121 core: bump astral-sh/uv from 0.9.5 to 0.9.6 (cherry-pick #17820 to version-2025.10) (#17835)
core: bump astral-sh/uv from 0.9.5 to 0.9.6 (#17820)

Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.9.5 to 0.9.6.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.5...0.9.6)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-30 18:05:56 +01:00
authentik-automation[bot]
5dc7b7cdae web/admin: fix scim provider form (cherry-pick #17831 to version-2025.10) (#17834)
web/admin: fix scim provider form (#17831)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-30 17:52:38 +01:00
authentik-automation[bot]
2e2c52e49c internal/web/proxy: fix return status code during startup (cherry-pick #17827 to version-2025.10) (#17832)
internal/web/proxy: fix return status code during startup (#17827)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-30 17:37:03 +01:00
Jens L.
38f1ef0506 ci: rework internal repo (#17797) (#17829)
* ci: rework internal repo



* also fix retention workflow



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-10-30 17:32:03 +01:00
authentik-automation[bot]
3517562549 internal: fix go deprecation for +build (cherry-pick #17806 to version-2025.10) (#17824)
internal: fix go deprecation for +build (#17806)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-10-30 15:48:50 +01:00
authentik-automation[bot]
cdbe40143d root: use hashes for dockerfile FROM (cherry-pick #17795 to version-2025.10) (#17798)
* Cherry-pick #17795 to version-2025.10 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #17795
Original commit: 6f35c32190

* fix conflict

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

---------

Signed-off-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-29 14:01:28 +01:00
authentik-automation[bot]
5816f0d17c tasks: delay startup signals (cherry-pick #17769 to version-2025.10) (#17775)
tasks: delay startup signals (#17769)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-28 18:15:23 +00:00
authentik-automation[bot]
907ea8b2e9 packages/django-postgres-cache: use upsert instead of select/update in a transaction (cherry-pick #17760 to version-2025.10) (#17767)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-10-28 16:26:14 +01:00
authentik-automation[bot]
b38af89960 providers/oauth2: move encryption key field (cherry-pick #17722 to version-2025.10) (#17729)
providers/oauth2: move encryption key field (#17722)

it is often mis configured

closes #17678

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-28 16:14:11 +01:00
authentik-automation[bot]
d52db187bf providers/radius: fix panic when no cert is configured (cherry-pick #17762 to version-2025.10) (#17766)
providers/radius: fix panic when no cert is configured (#17762)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-28 16:12:21 +01:00
authentik-automation[bot]
2093e0e63f sources/oauth: Make PKCE verifier 128 characters (cherry-pick #17763 to version-2025.10) (#17765)
Co-authored-by: Alex Whitehead-Smith <alex.me.smith@gmail.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-10-28 16:07:13 +01:00
authentik-automation[bot]
2791d87ceb providers/proxy: fix missing JWT/claims header (cherry-pick #17759 to version-2025.10) (#17764)
providers/proxy: fix missing JWT/claims header (#17759)

* replace interface{} with any



* fix raw token not saved to map or json



* also fix proxy claims



* fix test



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-28 15:48:25 +01:00
authentik-automation[bot]
fdc3d95b59 root: Add Dockerfile label org.opencontainers.image.source (cherry-pick #17756 to version-2025.10) (#17757)
root: Add Dockerfile label org.opencontainers.image.source (#17756)

Add label source in dockerfiles

Co-authored-by: Erwan Hervé <62173453+Erwan-loot@users.noreply.github.com>
2025-10-28 13:48:44 +01:00
authentik-automation[bot]
de7a61cee0 website/docs: fix placeholder leftover (cherry-pick #17737 to version-2025.10) (#17738)
website/docs: fix placeholder leftover (#17737)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-27 21:31:46 +01:00
authentik-automation[bot]
f2805b9b8a release: 2025.10.0 2025-10-27 19:35:16 +00:00
authentik-automation[bot]
f48a91fbf4 website/docs: finalise 2025.10 release notes (cherry-pick #17728 to version-2025.10) (#17733)
website/docs: finalise 2025.10 release notes (#17728)

* website/docs: finalise 2025.10 release notes



* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-27 19:01:01 +00:00
authentik-automation[bot]
f056c0808d website/docs: update flow context ref (cherry-pick #17723 to version-2025.10) (#17732)
website/docs: update flow context ref (#17723)

* website/docs: update flow context ref



* format



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




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




---------

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: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-10-27 19:39:09 +01:00
authentik-automation[bot]
06a6d45139 enterprise: handle cached naive timezone (cherry-pick #17695 to version-2025.10) (#17730)
enterprise: handle cached naive timezone (#17695)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-27 19:13:52 +01:00
authentik-automation[bot]
0e12642f12 website/docs: blueprints: add a bit more info (cherry-pick #17704 to version-2025.10) (#17708)
website/docs: blueprints: add a bit more info (#17704)

* website/docs: blueprints: add a bit more info

* this might be worth mentioning

* fix

* a bit more info

Co-authored-by: Dominic R <dominic@sdko.org>
2025-10-26 14:18:03 +00:00
authentik-automation[bot]
01406d364e website/docs: add short-lived certificate recommendation (cherry-pick #17628 to version-2025.10) (#17633)
website/docs: add short-lived certificate recommendation (#17628)

Add certificate recommendation

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-10-25 02:32:38 +00:00
authentik-automation[bot]
b9b16dba59 website/docs: release notes: Add Zot integration (cherry-pick #17700 to version-2025.10) (#17701)
Co-authored-by: Dominic R <dominic@sdko.org>
2025-10-25 01:03:48 +00:00
authentik-automation[bot]
1ef83f3295 website/docs: eap add info about custom validation (cherry-pick #17642 to version-2025.10) (#17699)
website/docs: eap add info about custom validation (#17642)

* add info about custom validation

* tweaked table

* remove bullet

* remove other bullet

---------

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-10-24 21:07:58 +00:00
authentik-automation[bot]
343506d104 website/docs: add note about invite link not bound (cherry-pick #17657 to version-2025.10) (#17672)
website/docs: add note about invite link not bound (#17657)

* invite link not bound

* marcelo's truth

* jens tweak

---------

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-10-24 11:43:32 -05:00
authentik-automation[bot]
aeb4e1057e providers/proxy: drop headers with underscores (cherry-pick #17650 to version-2025.10) (#17651)
providers/proxy: drop headers with underscores (#17650)

drop any headers with underscores that we set in the remote system

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-22 16:28:52 +02:00
authentik-automation[bot]
0bcd1c268c website/docs: rel notes 2025.10: add 3 more integration guides (cherry-pick #17641 to version-2025.10) (#17652)
website/docs: rel notes 2025.10: add 3 more integration guides (#17641)

* add 3 more int guides

* Apply suggestion from @dominic-r



* is github's suggestion thingy usually this buggy

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-10-22 13:48:02 +00:00
authentik-automation[bot]
ecba1ffe94 enterprise: add prometheus metrics for license usage and expiry (cherry-pick #17606 to version-2025.10) (#17637)
enterprise: add prometheus metrics for license usage and expiry (#17606)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-21 18:55:14 +02:00
authentik-automation[bot]
b7d303936c release: 2025.10.0-rc3 2025-10-21 13:21:18 +00:00
authentik-automation[bot]
c1bc2a4565 ci: use forked release action to deal with large release notes (cherry-pick #17625 to version-2025.10) (#17626)
ci: use forked release action to deal with large release notes (#17625)

* ci: use forked release action to deal with large release notes



* bump build



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-10-21 14:39:57 +02:00
authentik-automation[bot]
1422c3aff3 core, web: update translations (cherry-pick #17605 to version-2025.10) (#17627)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-10-21 14:26:37 +02:00
authentik-automation[bot]
d4a77583ea website: fix active menu link background overlap (cherry-pick #17607 to version-2025.10) (#17620)
website: fix active menu link background overlap (#17607)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-10-21 07:12:41 -04:00
authentik-automation[bot]
78d270bf25 release: 2025.10.0-rc2 2025-10-21 00:19:36 +00:00
authentik-automation[bot]
6d1c7f90e2 release: 2025.10.0-rc1 2025-10-20 23:43:29 +00:00
161 changed files with 3754 additions and 2168 deletions

View File

@@ -142,7 +142,9 @@ updates:
labels:
- dependencies
- package-ecosystem: docker
directory: "/"
directories:
- /
- /website
schedule:
interval: daily
time: "04:00"

View File

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

View File

@@ -13,7 +13,6 @@ env:
jobs:
publish-source-docs:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
timeout-minutes: 120
steps:

View File

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

View File

@@ -9,7 +9,6 @@ on:
jobs:
test-container:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false

View File

@@ -80,7 +80,15 @@ jobs:
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# Previous stable tag
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# Current version family based on
current_version_family=$(python -c "from authentik import VERSION; print(VERSION)" | grep -vE -- 'rc[0-9]+$')
if [[ -n $current_version_family ]]; then
prev_stable=$current_version_family
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout $(prev_stable)
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (stable)

View File

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

View File

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

View File

@@ -5,10 +5,13 @@ on:
# schedule:
# - cron: "0 0 * * *" # every day at midnight
workflow_dispatch:
inputs:
dry-run:
type: boolean
description: Enable dry-run mode
jobs:
clean-ghcr:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
name: Delete old unused container images
runs-on: ubuntu-latest
steps:
@@ -18,12 +21,12 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Delete 'dev' containers older than a week
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v2
uses: snok/container-retention-policy@3b0972b2276b171b212f8c4efbca59ebba26eceb # v3.0.1
with:
image-names: dev-server,dev-ldap,dev-proxy
image-tags: "!gh-next,!gh-main"
cut-off: One week ago UTC
account-type: org
org-name: goauthentik
untagged-only: false
account: goauthentik
tag-selection: untagged
token: ${{ steps.generate_token.outputs.token }}
skip-tags: gh-next,gh-main
dry-run: ${{ inputs.dry-run }}

View File

@@ -19,7 +19,6 @@ permissions:
jobs:
publish:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false

View File

@@ -12,7 +12,6 @@ permissions:
jobs:
update-next:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
environment: internal-production
steps:

View File

@@ -87,7 +87,7 @@ jobs:
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
git push --follow-tags
- name: Create Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
token: "${{ steps.app-token.outputs.token }}"
tag_name: "version/${{ inputs.version }}"

View File

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

View File

@@ -1,21 +0,0 @@
---
name: Repo - Mirror to internal
on: [push, delete]
jobs:
to_internal:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }}
uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb # 5cf300935bc2e068f73ea69bcc411a8a997208eb
with:
target_repo_url: git@github.com:goauthentik/authentik-internal.git
ssh_private_key: ${{ secrets.GH_MIRROR_KEY }}
args: --tags --force
env:
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-trixie-slim@sha256:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe AS node-builder
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
@@ -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.3-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -63,7 +63,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/authentik ./cmd/server
# Stage 3: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1 AS geoip
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1@sha256:faecdca22579730ab0b7dea5aa9af350bb3c93cb9d39845c173639ead30346d2 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
@@ -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.4 AS uv
FROM ghcr.io/astral-sh/uv:0.9.6@sha256:4b96ee9429583983fd172c33a02ecac5242d63fb46bc27804748e38c1cc9ad0d AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips 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" \
@@ -139,6 +139,7 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

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

View File

@@ -1,8 +1,11 @@
"""Test brands"""
from json import loads
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
@@ -23,6 +26,7 @@ class TestBrands(APITestCase):
_flag = flag()
if _flag.visibility == "public":
self.default_flags[_flag.key] = _flag.get()
Brand.objects.all().delete()
def test_current_brand(self):
"""Test Current brand API"""
@@ -44,7 +48,6 @@ class TestBrands(APITestCase):
def test_brand_subdomain(self):
"""Test Current brand API"""
Brand.objects.all().delete()
Brand.objects.create(domain="bar.baz", branding_title="custom")
self.assertJSONEqual(
self.client.get(
@@ -65,7 +68,6 @@ class TestBrands(APITestCase):
def test_fallback(self):
"""Test fallback brand"""
Brand.objects.all().delete()
self.assertJSONEqual(
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
@@ -81,6 +83,109 @@ class TestBrands(APITestCase):
},
)
@apply_blueprint("default/default-brand.yaml")
def test_blueprint(self):
"""Test Current brand API"""
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
response.pop("flow_authentication", None)
response.pop("flow_invalidation", None)
response.pop("flow_user_settings", None)
self.assertEqual(
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
@apply_blueprint("default/default-brand.yaml")
def test_blueprint_with_other_brand(self):
"""Test Current brand API"""
Brand.objects.create(domain="bar.baz", branding_title="custom")
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
response.pop("flow_authentication", None)
response.pop("flow_invalidation", None)
response.pop("flow_user_settings", None)
self.assertEqual(
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
def test_brand_subdomain_same_suffix(self):
"""Test Current brand API"""
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom-strong",
"branding_custom_css": "",
"matched_domain": "foo.bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
def test_brand_subdomain_other_suffix(self):
"""Test Current brand API"""
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:brand-current"), HTTP_HOST="other.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom-weak",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
def test_create_default_multiple(self):
"""Test attempted creation of multiple default brands"""
Brand.objects.create(

View File

@@ -2,8 +2,8 @@
from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.db.models import Case, F, IntegerField, Q, Value, When
from django.db.models.functions import Length
from django.http.request import HttpRequest
from django.utils.html import _json_script_escapes
from django.utils.safestring import mark_safe
@@ -19,15 +19,36 @@ DEFAULT_BRAND = Brand(domain="fallback")
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""
db_brands = (
Brand.objects.annotate(host_domain=V(request.get_host()))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.order_by("default")
brand = (
Brand.objects.annotate(
host_domain=Value(request.get_host()),
domain_length=Length("domain"),
match_priority=Case(
When(
condition=Q(host_domain__iendswith=F("domain")),
then=F("domain_length"),
),
default=Value(-1),
output_field=IntegerField(),
),
is_default_fallback=Case(
When(
condition=Q(default=True),
then=Value(0),
),
default=Value(-2),
output_field=IntegerField(),
),
)
.filter(Q(match_priority__gt=-1) | Q(default=True))
.order_by("-match_priority", "-is_default_fallback")
.first()
)
brands = list(db_brands.all())
if len(brands) < 1:
if brand is None:
return DEFAULT_BRAND
return brands[0]
return brand
def context_processor(request: HttpRequest) -> dict[str, Any]:

View File

@@ -15,7 +15,7 @@ from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
from django.http import HttpRequest
from django.utils.functional import SimpleLazyObject, cached_property
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_cte import CTE, with_cte
@@ -585,18 +585,16 @@ class Application(SerializerModel, PolicyBindingModel):
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
from authentik.core.api.users import UserSerializer
url = None
if self.meta_launch_url:
url = self.meta_launch_url
elif provider := self.get_provider():
url = provider.launch_url
if user and url:
if isinstance(user, SimpleLazyObject):
user._setup()
user = user._wrapped
try:
return url % user.__dict__
return url % UserSerializer(instance=user).data
except Exception as exc: # noqa
LOGGER.warning("Failed to format launch url", exc=exc)
return url

View File

@@ -1,11 +1,21 @@
"""Enterprise app config"""
from django.conf import settings
from prometheus_client import Gauge
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
GAUGE_LICENSE_USAGE = Gauge(
"authentik_enterprise_license_usage",
"Enterprise license usage (percentage per user type).",
["user_type"],
)
GAUGE_LICENSE_EXPIRY = Gauge(
"authentik_enterprise_license_expiry_seconds", "Duration until license expires, in seconds."
)
class EnterpriseConfig(ManagedAppConfig):
"""Base app config for all enterprise apps"""

View File

@@ -217,7 +217,7 @@ class LicenseKey:
def summary(self) -> LicenseSummary:
"""Summary of license status"""
status = self.status()
latest_valid = datetime.fromtimestamp(self.exp)
latest_valid = datetime.fromtimestamp(self.exp).replace(tzinfo=UTC)
return LicenseSummary(
latest_valid=latest_valid,
internal_users=self.internal_users,

View File

@@ -1,18 +1,41 @@
"""Enterprise signals"""
from datetime import datetime
from datetime import UTC, datetime
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.utils.timezone import get_current_timezone
from django.utils.timezone import get_current_timezone, now
from authentik.enterprise.license import CACHE_KEY_ENTERPRISE_LICENSE
from authentik.enterprise.models import License
from authentik.enterprise.apps import GAUGE_LICENSE_EXPIRY, GAUGE_LICENSE_USAGE
from authentik.enterprise.license import CACHE_KEY_ENTERPRISE_LICENSE, LicenseKey
from authentik.enterprise.models import License, LicenseUsageStatus
from authentik.enterprise.tasks import enterprise_update_usage
from authentik.root.monitoring import monitoring_set
from authentik.tasks.schedules.models import Schedule
@receiver(monitoring_set)
def monitoring_set_enterprise(sender, **kwargs):
"""set enterprise gauges"""
summary = LicenseKey.cached_summary()
if summary.status == LicenseUsageStatus.UNLICENSED:
return
percentage_internal = (
0
if summary.internal_users <= 0
else LicenseKey.get_internal_user_count() / (summary.internal_users / 100)
)
percentage_external = (
0
if summary.external_users <= 0
else LicenseKey.get_external_user_count() / (summary.external_users / 100)
)
GAUGE_LICENSE_USAGE.labels(user_type="internal").set(percentage_internal)
GAUGE_LICENSE_USAGE.labels(user_type="external").set(percentage_external)
GAUGE_LICENSE_EXPIRY.set((summary.latest_valid.replace(tzinfo=UTC) - now()).total_seconds())
@receiver(pre_save, sender=License)
def pre_save_license(sender: type[License], instance: License, **_):
"""Extract data from license jwt and save it into model"""

View File

@@ -0,0 +1,49 @@
"""Enterprise metrics tests"""
from unittest.mock import MagicMock, patch
from django.test import TestCase
from prometheus_client import REGISTRY
from authentik.core.models import User
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import License
from authentik.enterprise.tests.test_license import expiry_valid
from authentik.lib.generators import generate_id
from authentik.root.monitoring import monitoring_set
class TestEnterpriseMetrics(TestCase):
"""Enterprise metrics tests"""
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=expiry_valid,
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
def test_usage_empty(self):
"""Test usage (no users)"""
License.objects.create(key=generate_id())
User.objects.all().delete()
create_test_user()
monitoring_set.send_robust(self)
self.assertEqual(
REGISTRY.get_sample_value(
"authentik_enterprise_license_usage", {"user_type": "internal"}
),
1.0,
)
self.assertEqual(
REGISTRY.get_sample_value(
"authentik_enterprise_license_usage", {"user_type": "external"}
),
0,
)

View File

@@ -1,7 +1,7 @@
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from django.utils.timezone import now
@@ -28,7 +28,7 @@ class LogEvent:
def from_event_dict(item: EventDict) -> "LogEvent":
event = item.pop("event")
log_level = item.pop("level").lower()
timestamp = datetime.fromisoformat(item.pop("timestamp"))
timestamp = datetime.fromisoformat(item.pop("timestamp")).replace(tzinfo=UTC)
item.pop("pid", None)
# Sometimes log entries have both `level` and `log_level` set, but `level` is always set
item.pop("log_level", None)

View File

@@ -145,7 +145,6 @@ worker:
consumer_listen_timeout: "seconds=30"
task_max_retries: 5
task_default_time_limit: "minutes=10"
lock_purge_interval: "minutes=1"
task_purge_interval: "days=1"
task_expiration: "days=30"
scheduler_interval: "seconds=60"

View File

@@ -49,6 +49,9 @@ def outpost_m2m_changed(sender, instance: Outpost | Provider, action: str, **_):
if action not in ["post_add", "post_remove", "post_clear"]:
return
if isinstance(instance, Outpost):
# Rebuild permissions when providers change
LOGGER.debug("Rebuilding outpost service account permissions", outpost=instance)
instance.build_user_permissions(instance.user)
outpost_controller.send_with_options(
args=(instance.pk,),
rel_obj=instance.service_connection,
@@ -92,6 +95,15 @@ def outpost_post_save(sender, instance: Outpost, created: bool, **_):
def outpost_related_post_save(sender, instance: OutpostServiceConnection | OutpostModel, **_):
for outpost in instance.outpost_set.all():
# Rebuild permissions in case provider's required objects changed
if isinstance(instance, OutpostModel):
LOGGER.info(
"Provider changed, rebuilding permissions and sending update",
outpost=outpost.name,
provider=instance.name if hasattr(instance, "name") else str(instance),
)
outpost.build_user_permissions(outpost.user)
LOGGER.debug("Sending update to outpost", outpost=outpost.name, trigger="provider_change")
outpost_send_update.send_with_options(
args=(outpost.pk,),
rel_obj=outpost,

View File

@@ -126,6 +126,30 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
},
)
def test_deactivate(self):
"""test deactivated user"""
self.user.is_active = False
self.user.save()
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": SCOPE_OPENID,
"client_id": self.provider.client_id,
"username": "sa",
"password": self.token.key,
},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content.decode(),
{
"error": "invalid_grant",
"error_description": TokenError.errors["invalid_grant"],
"request_id": response.headers["X-authentik-id"],
},
)
def test_permission_denied(self):
"""test permission denied"""
group = Group.objects.create(name="foo")

View File

@@ -336,7 +336,7 @@ class TokenParams:
self, request: HttpRequest, username: str, password: str
):
# Authenticate user based on credentials
user = User.objects.filter(username=username).first()
user = User.objects.filter(username=username, is_active=True).first()
if not user:
raise TokenError("invalid_grant")
token: Token = Token.filter_not_expired(
@@ -378,9 +378,11 @@ class TokenParams:
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to parse JWT for kid lookup", exc=exc)
raise TokenError("invalid_grant") from None
expected_kid = decode_unvalidated["header"]["kid"]
fallback_alg = decode_unvalidated["header"]["alg"]
expected_kid = decode_unvalidated["header"].get("kid")
fallback_alg = decode_unvalidated["header"].get("alg")
token = source = None
if not expected_kid or not fallback_alg:
return None, None
for source in self.provider.jwt_federation_sources.filter(
oidc_jwks__keys__contains=[{"kid": expected_kid}]
):

View File

@@ -83,7 +83,7 @@ class EnterpriseUser(BaseModel):
class User(BaseUser):
"""Modified User schema with added externalId field"""
model_config = ConfigDict(serialize_by_alias=True)
model_config = ConfigDict(serialize_by_alias=True, extra="allow")
id: str | int | None = None
schemas: list[str] = [SCIM_USER_SCHEMA]
@@ -106,6 +106,8 @@ class User(BaseUser):
class Group(BaseGroup):
"""Modified Group schema with added externalId field"""
model_config = ConfigDict(extra="allow")
id: str | int | None = None
schemas: list[str] = [SCIM_GROUP_SCHEMA]
externalId: str | None = None

View File

@@ -95,7 +95,12 @@ class SCIMUserTests(TestCase):
"""Test user creation with custom schema"""
schema = SCIMMapping.objects.create(
name="custom_schema",
expression="""return {"schemas": ["foo"]}""",
expression="""return {
"schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
"startDate": "2024-04-10T00:00:00+0000",
},
}""",
)
self.provider.property_mappings.add(schema)
scim_id = generate_id()
@@ -121,7 +126,10 @@ class SCIMUserTests(TestCase):
self.assertJSONEqual(
mock.request_history[1].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "foo"],
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
],
"active": True,
"emails": [
{
@@ -138,6 +146,9 @@ class SCIMUserTests(TestCase):
},
"displayName": f"{uid} {uid}",
"userName": uid,
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
"startDate": "2024-04-10T00:00:00+0000",
},
},
)

View File

@@ -6,6 +6,7 @@ from hashlib import sha512
from pathlib import Path
import orjson
from django.http import response as http_response
from sentry_sdk import set_tag
from xmlsec import enable_debug_trace
@@ -379,9 +380,6 @@ DRAMATIQ = {
"broker_class": "authentik.tasks.broker.Broker",
"channel_prefix": "authentik",
"task_model": "authentik.tasks.models.Task",
"lock_purge_interval": timedelta_from_string(
CONFIG.get("worker.lock_purge_interval")
).total_seconds(),
"task_purge_interval": timedelta_from_string(
CONFIG.get("worker.task_purge_interval")
).total_seconds(),
@@ -429,6 +427,7 @@ DRAMATIQ = {
},
),
("dramatiq.results.middleware.Results", {"store_results": True}),
("authentik.tasks.middleware.StartupSignalsMiddleware", {}),
("authentik.tasks.middleware.CurrentTask", {}),
("authentik.tasks.middleware.TenantMiddleware", {}),
("authentik.tasks.middleware.ModelDataMiddleware", {}),
@@ -471,6 +470,12 @@ STORAGES = {
},
}
# Django 5.2.8 and CVE-2025-64458 added a strong enforcement of 2048 characters
# as the maximum for a URL to redirect to, mostly for running on windows.
# However our URLs can easily exceed that with OAuth/SAML Query parameters or hash values
# 8192 should cover most cases..
http_response.MAX_URL_LENGTH = http_response.MAX_URL_LENGTH * 4
# Media files
if CONFIG.get("storage.media.backend", "file") == "s3":

View File

@@ -143,7 +143,7 @@ class OAuth2Client(BaseOAuthClient):
if self.source.source_type.urls_customizable and self.source.pkce:
pkce_mode = self.source.pkce
if pkce_mode != PKCEMethod.NONE:
verifier = generate_id()
verifier = generate_id(length=128)
self.request.session[SESSION_KEY_OAUTH_PKCE] = verifier
# https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
if pkce_mode == PKCEMethod.PLAIN:

View File

@@ -205,6 +205,7 @@ class TestOAuthSource(APITestCase):
session = self.client.session
state = session[f"oauth-client-{self.source.name}-request-state"]
verifier = session[SESSION_KEY_OAUTH_PKCE]
self.assertEqual(len(verifier), 128)
challenge = pkce_s256_challenge(verifier)
self.assertEqual(qs["redirect_uri"], ["http://testserver/source/oauth/callback/test/"])

View File

@@ -11,10 +11,10 @@ from authentik.stages.invitation.models import Invitation, InvitationStage
from authentik.stages.invitation.signals import invitation_used
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
INVITATION_TOKEN_KEY_CONTEXT = "token" # nosec
INVITATION_TOKEN_KEY = "itoken" # nosec
INVITATION_IN_EFFECT = "invitation_in_effect"
INVITATION = "invitation"
QS_INVITATION_TOKEN_KEY = "itoken" # nosec
PLAN_CONTEXT_INVITATION_TOKEN = "token" # nosec
PLAN_CONTEXT_INVITATION_IN_EFFECT = "invitation_in_effect"
PLAN_CONTEXT_INVITATION = "invitation"
class InvitationStageView(StageView):
@@ -23,13 +23,13 @@ class InvitationStageView(StageView):
def get_token(self) -> str | None:
"""Get token from saved get-arguments or prompt_data"""
# Check for ?token= and ?itoken=
if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY]
if INVITATION_TOKEN_KEY_CONTEXT in self.request.session.get(SESSION_KEY_GET, {}):
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY_CONTEXT]
if QS_INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
return self.request.session[SESSION_KEY_GET][QS_INVITATION_TOKEN_KEY]
if PLAN_CONTEXT_INVITATION_TOKEN in self.request.session.get(SESSION_KEY_GET, {}):
return self.request.session[SESSION_KEY_GET][PLAN_CONTEXT_INVITATION_TOKEN]
# Check for {'token': ''} in the context
if INVITATION_TOKEN_KEY_CONTEXT in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
if PLAN_CONTEXT_INVITATION_TOKEN in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][PLAN_CONTEXT_INVITATION_TOKEN]
return None
def get_invite(self) -> Invitation | None:
@@ -38,7 +38,7 @@ class InvitationStageView(StageView):
if not token:
return None
try:
invite: Invitation = Invitation.objects.filter(pk=token).first()
invite: Invitation | None = Invitation.filter_not_expired(pk=token).first()
except ValidationError:
self.logger.debug("invalid invitation", token=token)
return None
@@ -60,8 +60,8 @@ class InvitationStageView(StageView):
return self.executor.stage_ok()
return self.executor.stage_invalid(_("Invalid invite/invite not found"))
self.executor.plan.context[INVITATION_IN_EFFECT] = True
self.executor.plan.context[INVITATION] = invite
self.executor.plan.context[PLAN_CONTEXT_INVITATION_IN_EFFECT] = True
self.executor.plan.context[PLAN_CONTEXT_INVITATION] = invite
context = {}
always_merger.merge(context, self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}))

View File

@@ -1,9 +1,11 @@
"""invitation tests"""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
from rest_framework.test import APITestCase
@@ -16,9 +18,9 @@ from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.invitation.models import Invitation, InvitationStage
from authentik.stages.invitation.stage import (
INVITATION_TOKEN_KEY,
INVITATION_TOKEN_KEY_CONTEXT,
PLAN_CONTEXT_INVITATION_TOKEN,
PLAN_CONTEXT_PROMPT,
QS_INVITATION_TOKEN_KEY,
)
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@@ -77,6 +79,31 @@ class TestInvitationStage(FlowTestCase):
self.stage.continue_flow_without_invitation = False
self.stage.save()
def test_with_invitation_expired(self):
"""Test with invitation, expired"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
data = {"foo": "bar"}
invite = Invitation.objects.create(
created_by=get_anonymous_user(),
fixed_data=data,
expires=now() - timedelta(hours=1),
)
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-access-denied",
)
def test_with_invitation_get(self):
"""Test with invitation, check data in session"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
@@ -89,7 +116,7 @@ class TestInvitationStage(FlowTestCase):
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
session = self.client.session
@@ -114,7 +141,7 @@ class TestInvitationStage(FlowTestCase):
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
session = self.client.session
@@ -134,7 +161,7 @@ class TestInvitationStage(FlowTestCase):
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {INVITATION_TOKEN_KEY_CONTEXT: invite.pk.hex}
plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()

View File

@@ -306,7 +306,14 @@ class Prompt(SerializerModel):
if self.type in CHOICE_FIELDS:
field_class = ChoiceField
kwargs["choices"] = choices or []
kwargs["choices"] = []
if choices:
for choice in choices:
label, value = choice, choice
if isinstance(choice, dict):
label = choice.get("label", "")
value = choice.get("value", "")
kwargs["choices"].append((value, label))
if default:
kwargs["default"] = default

View File

@@ -23,6 +23,7 @@ from authentik import authentik_full_version
from authentik.events.models import Event, EventAction
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.reflection import class_to_path
from authentik.root.signals import post_startup, pre_startup, startup
from authentik.tasks.models import Task, TaskLog, TaskStatus, WorkerStatus
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
@@ -32,6 +33,14 @@ HEALTHCHECK_LOGGER = get_logger("authentik.worker").bind()
DB_ERRORS = (OperationalError, Error)
class StartupSignalsMiddleware(Middleware):
def after_process_boot(self, broker: Broker):
_startup_sender = type("WorkerStartup", (object,), {})
pre_startup.send(sender=_startup_sender)
startup.send(sender=_startup_sender)
post_startup.send(sender=_startup_sender)
class CurrentTask(BaseCurrentTask):
@classmethod
def get_task(cls) -> Task:

View File

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from django_dramatiq_postgres.models import TaskBase, TaskState
from authentik.events.logs import LogEvent
from authentik.events.utils import sanitize_item
from authentik.lib.models import SerializerModel
from authentik.lib.utils.errors import exception_to_dict
from authentik.tenants.models import Tenant
@@ -174,7 +175,7 @@ class TaskLog(models.Model):
log_level=log_event.log_level,
logger=log_event.logger,
timestamp=log_event.timestamp,
attributes=log_event.attributes,
attributes=sanitize_item(log_event.attributes),
)
@classmethod
@@ -193,7 +194,7 @@ class TaskLog(models.Model):
log_level=log_event.log_level,
logger=log_event.logger,
timestamp=log_event.timestamp,
attributes=log_event.attributes,
attributes=sanitize_item(log_event.attributes),
)
for log_event in log_events
]

View File

@@ -2,9 +2,10 @@ import pickle # nosec
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from uuid import UUID
from dramatiq.actor import Actor
from psqlextra.query import ConflictAction
from psqlextra.types import ConflictAction
if TYPE_CHECKING:
from authentik.tasks.schedules.models import Schedule
@@ -15,7 +16,7 @@ class ScheduleSpec:
actor: Actor
crontab: str
paused: bool = False
identifier: str | None = None
identifier: str | UUID | None = None
uid: str | None = None
args: Iterable[Any] = field(default_factory=tuple)
@@ -41,6 +42,8 @@ class ScheduleSpec:
return pickle.dumps(options)
def update_or_create(self) -> "Schedule":
from django.contrib.contenttypes.models import ContentType
from authentik.tasks.schedules.models import Schedule
update_values = {
@@ -50,10 +53,12 @@ class ScheduleSpec:
"kwargs": self.get_kwargs(),
"options": self.get_options(),
}
if self.rel_obj is not None:
update_values["rel_obj_content_type"] = ContentType.objects.get_for_model(self.rel_obj)
update_values["rel_obj_id"] = str(self.rel_obj.pk)
create_values = {
**update_values,
"crontab": self.crontab,
"rel_obj": self.rel_obj,
}
schedule = Schedule.objects.on_conflict(
@@ -62,7 +67,7 @@ class ScheduleSpec:
update_values=update_values,
).insert_and_get(
actor_name=self.actor.actor_name,
identifier=self.identifier,
identifier=str(self.identifier),
**create_values,
)

View File

@@ -13,6 +13,7 @@ def post_save_scheduled_model(sender, instance, **_):
return
for spec in instance.schedule_specs:
spec.rel_obj = instance
spec.identifier = instance.pk
schedule = spec.update_or_create()
if spec.send_on_save:
schedule.send()

View File

@@ -5,10 +5,3 @@ setup()
import django # noqa: E402
django.setup()
from authentik.root.signals import post_startup, pre_startup, startup # noqa: E402
_startup_sender = type("WorkerStartup", (object,), {})
pre_startup.send(sender=_startup_sender)
startup.send(sender=_startup_sender)
post_startup.send(sender=_startup_sender)

View File

@@ -5,7 +5,8 @@ from json import loads
from django.urls import reverse
from django_tenants.utils import get_public_schema_name
from authentik.core.models import Token, TokenIntents, User
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_user
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.tenants.models import Tenant
@@ -21,7 +22,7 @@ class TestRecovery(TenantAPITestCase):
def setUp(self):
super().setUp()
self.tenant = Tenant.objects.get(schema_name=get_public_schema_name())
self.user: User = User.objects.create_user(username="recovery-test-user")
self.user = create_test_user()
@CONFIG.patch("outposts.disable_embedded_outpost", True)
@CONFIG.patch("tenants.enabled", True)

View File

@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2025.10.0-rc1 Blueprint schema",
"title": "authentik 2025.10.2 Blueprint schema",
"required": [
"version",
"entries"

View File

@@ -60,22 +60,6 @@ func checkServer() int {
return 0
}
func splitHostPort(address string) (host, port string) {
lastColon := strings.LastIndex(address, ":")
if lastColon == -1 {
return address, ""
}
host = address[:lastColon]
port = address[lastColon+1:]
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host, port
}
func checkWorker() int {
pidB, err := os.ReadFile(workerPidFile)
if err != nil {
@@ -98,41 +82,6 @@ func checkWorker() int {
log.WithError(err).Warning("failed to signal worker process")
return 1
}
h := &http.Client{
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
}
host, port := splitHostPort(config.Get().Listen.HTTP)
if host == "0.0.0.0" || host == "::" {
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "::1", port)
_, err := h.Head(url)
if err != nil {
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "127.0.0.1", port)
res, err := h.Head(url)
if err != nil {
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
return 1
}
if res.StatusCode >= 400 {
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
return 1
}
}
} else {
url := fmt.Sprintf("http://%s:%s/-/health/ready/", host, port)
res, err := h.Head(url)
if err != nil {
log.WithError(err).Warning("failed to send healthcheck request")
return 1
}
if res.StatusCode >= 400 {
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
return 1
}
}
log.Info("successfully checked health")
return 0
}

View File

@@ -31,7 +31,7 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
@@ -52,7 +52,7 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
restart: unless-stopped
user: root
volumes:

View File

@@ -1 +1 @@
2025.10.0-rc1
2025.10.2

View File

@@ -6,7 +6,7 @@ import (
)
func OpensslVersion() string {
cmd := exec.Command("openssl", "version")
cmd := exec.Command("/usr/bin/openssl", "version")
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()

View File

@@ -93,7 +93,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
}),
)
if len(outposts.Results) < 1 {
log.Panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
log.Panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost")
}
outpost := outposts.Results[0]
@@ -122,6 +122,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
eventHandlers: []EventHandler{},
refreshHandlers: make([]func(), 0),
}
ac.logger.WithField("embedded", ac.IsEmbedded()).Info("Outpost mode")
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
err = ac.initEvent(akURL, outpost.Pk)
if err != nil {
@@ -135,6 +136,13 @@ func (a *APIController) Log() *log.Entry {
return a.logger
}
func (a *APIController) IsEmbedded() bool {
if m := a.Outpost.Managed.Get(); m != nil {
return *m == "goauthentik.io/outposts/embedded"
}
return false
}
// Start Starts all handlers, non-blocking
func (a *APIController) Start() error {
err := a.Server.Refresh()

View File

@@ -66,6 +66,7 @@ type Server interface {
API() *ak.APIController
Apps() []*Application
CryptoStore() *ak.CryptoStore
SessionBackend() string
}
func init() {
@@ -94,10 +95,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
CallbackSignature: []string{"true"},
}.Encode()
isEmbedded := false
if m := server.API().Outpost.Managed.Get(); m != nil {
isEmbedded = *m == "goauthentik.io/outposts/embedded"
}
isEmbedded := server.API().IsEmbedded()
// Configure an OpenID Connect aware OAuth2 client.
endpoint := GetOIDCEndpoint(
p,
@@ -153,6 +151,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
go a.authHeaderCache.Start()
if oldApp != nil && oldApp.sessions != nil {
a.sessions = oldApp.sessions
muxLogger.Debug("reusing existing session store")
} else {
sess, err := a.getStore(p, externalHost)
if err != nil {

View File

@@ -64,7 +64,7 @@ func (a *Application) getClaimsFromSession(r *http.Request) *types.Claims {
// Claims are always stored as types.Claims but may be deserialized differently:
// - Filesystem store (gob): preserves struct type as types.Claims
// - PostgreSQL store (JSON): deserializes as map[string]interface{}
// - PostgreSQL store (JSON): deserializes as map[string]any
// Handle struct type (filesystem store)
if c, ok := claims.(types.Claims); ok {
@@ -72,7 +72,7 @@ func (a *Application) getClaimsFromSession(r *http.Request) *types.Claims {
}
// Handle map type (PostgreSQL store)
if claimsMap, ok := claims.(map[string]interface{}); ok {
if claimsMap, ok := claims.(map[string]any); ok {
var c types.Claims
if err := mapstructure.Decode(claimsMap, &c); err != nil {
return nil

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/gorilla/sessions"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -27,7 +28,7 @@ func TestClaimsJSONSerialization(t *testing.T) {
Entitlements: []string{"read", "write"},
Sid: "session-id-456",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]interface{}{
UserAttributes: map[string]any{
"custom_field": "custom_value",
"department": "engineering",
},
@@ -70,35 +71,33 @@ func TestClaimsJSONSerialization(t *testing.T) {
assert.Equal(t, "engineering", parsedClaims.Proxy.UserAttributes["department"])
}
// TestClaimsMapSerialization tests that Claims stored as map[string]interface{} can be converted back
// TestClaimsMapSerialization tests that Claims stored as map[string]any can be converted back
func TestClaimsMapSerialization(t *testing.T) {
// Simulate how claims are stored in session as map (like from PostgreSQL JSONB)
claimsMap := map[string]interface{}{
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890), // json numbers become float64
"email": "test@example.com",
"email_verified": true,
"name": "Test User",
"preferred_username": "testuser",
"groups": []interface{}{"admin", "user"},
"entitlements": []interface{}{"read", "write"},
"groups": []any{"admin", "user"},
"entitlements": []any{"read", "write"},
"sid": "session-id-456",
"ak_proxy": map[string]interface{}{
"user_attributes": map[string]interface{}{
"ak_proxy": map[string]any{
"user_attributes": map[string]any{
"custom_field": "custom_value",
},
"backend_override": "custom-backend",
"host_header": "example.com",
"is_superuser": true,
},
"raw_token": "not-a-real-token",
}
// Convert map to Claims using JSON marshaling (like getClaimsFromSession does)
jsonData, err := json.Marshal(claimsMap)
require.NoError(t, err)
// Convert map to Claims using mapstructure marshaling (like getClaimsFromSession does)
var claims types.Claims
err = json.Unmarshal(jsonData, &claims)
err := mapstructure.Decode(claimsMap, &claims)
require.NoError(t, err)
// Verify fields
@@ -111,6 +110,7 @@ func TestClaimsMapSerialization(t *testing.T) {
assert.Equal(t, []string{"admin", "user"}, claims.Groups)
assert.Equal(t, []string{"read", "write"}, claims.Entitlements)
assert.Equal(t, "session-id-456", claims.Sid)
assert.Equal(t, "not-a-real-token", claims.RawToken)
// Verify proxy claims
require.NotNil(t, claims.Proxy)
@@ -122,7 +122,7 @@ func TestClaimsMapSerialization(t *testing.T) {
// TestClaimsMinimalFields tests that Claims work with minimal required fields
func TestClaimsMinimalFields(t *testing.T) {
claimsMap := map[string]interface{}{
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
}
@@ -144,11 +144,11 @@ func TestClaimsMinimalFields(t *testing.T) {
// TestClaimsWithEmptyArrays tests that empty arrays are handled correctly
func TestClaimsWithEmptyArrays(t *testing.T) {
claimsMap := map[string]interface{}{
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"groups": []interface{}{},
"entitlements": []interface{}{},
"groups": []any{},
"entitlements": []any{},
}
jsonData, err := json.Marshal(claimsMap)
@@ -167,7 +167,7 @@ func TestClaimsWithEmptyArrays(t *testing.T) {
// TestClaimsWithNullProxyClaims tests that null proxy claims don't cause issues
func TestClaimsWithNullProxyClaims(t *testing.T) {
claimsMap := map[string]interface{}{
claimsMap := map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"ak_proxy": nil,
@@ -185,18 +185,18 @@ func TestClaimsWithNullProxyClaims(t *testing.T) {
}
// TestGetClaimsFromSession_Success tests successful retrieval of claims from session
// uses a mock session that returns claims as map[string]interface{} to simulate
// uses a mock session that returns claims as map[string]any to simulate
// how PostgreSQL storage deserializes JSONB data
func TestGetClaimsFromSession_Success(t *testing.T) {
// Create a custom mock store that returns claims as map
store := &mockMapSessionStore{
claimsMap: map[string]interface{}{
claimsMap: map[string]any{
"sub": "user-id-123",
"exp": float64(1234567890),
"email": "test@example.com",
"email_verified": true,
"preferred_username": "testuser",
"groups": []interface{}{"admin", "user"},
"groups": []any{"admin", "user"},
},
}
@@ -217,9 +217,9 @@ func TestGetClaimsFromSession_Success(t *testing.T) {
assert.Equal(t, []string{"admin", "user"}, claims.Groups)
}
// mockMapSessionStore is a mock session store that returns claims as map[string]interface{}
// mockMapSessionStore is a mock session store that returns claims as map[string]any
type mockMapSessionStore struct {
claimsMap map[string]interface{}
claimsMap map[string]any
}
func (m *mockMapSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) {
@@ -314,7 +314,7 @@ func TestClaimsRoundTrip(t *testing.T) {
Entitlements: []string{"ent1", "ent2"},
Sid: "session-789",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]interface{}{
UserAttributes: map[string]any{
"attr1": "value1",
"attr2": float64(42),
"attr3": true,
@@ -329,8 +329,8 @@ func TestClaimsRoundTrip(t *testing.T) {
jsonData, err := json.Marshal(originalClaims)
require.NoError(t, err)
// Step 2: Deserialize to map[string]interface{} (simulating PostgreSQL load)
var claimsMap map[string]interface{}
// Step 2: Deserialize to map[string]any (simulating PostgreSQL load)
var claimsMap map[string]any
err = json.Unmarshal(jsonData, &claimsMap)
require.NoError(t, err)

View File

@@ -14,62 +14,83 @@ import (
"goauthentik.io/internal/outpost/proxyv2/types"
)
func (a *Application) addHeaders(headers http.Header, c *types.Claims) {
nh := a.getHeaders(c)
for key, val := range nh {
headers.Set(key, val)
}
a.removeDuplicateUnderscoreHeader(headers)
}
func (a *Application) removeDuplicateUnderscoreHeader(h http.Header) {
for key := range h {
ush := strings.ReplaceAll(key, "_", "-")
if _, ok := h[ush]; !ok {
h.Del(key)
}
}
}
func (a *Application) getHeaders(c *types.Claims) map[string]string {
headers := map[string]string{}
// https://docs.goauthentik.io/add-secure-apps/providers/proxy
headers["X-authentik-username"] = c.PreferredUsername
headers["X-authentik-groups"] = strings.Join(c.Groups, "|")
headers["X-authentik-entitlements"] = strings.Join(c.Entitlements, "|")
headers["X-authentik-email"] = c.Email
headers["X-authentik-name"] = c.Name
headers["X-authentik-uid"] = c.Sub
headers["X-authentik-jwt"] = c.RawToken
// System headers
headers["X-authentik-meta-jwks"] = a.endpoint.JwksUri
headers["X-authentik-meta-outpost"] = a.outpostName
headers["X-authentik-meta-provider"] = a.proxyConfig.Name
headers["X-authentik-meta-app"] = a.proxyConfig.AssignedApplicationSlug
headers["X-authentik-meta-version"] = constants.UserAgentOutpost()
if c.Proxy == nil {
return headers
}
if authz := a.setAuthorizationHeader(c); authz != "" {
headers["Authorization"] = authz
}
// Check if user has additional headers set that we should sent
userAttributes := c.Proxy.UserAttributes
if additionalHeaders, ok := userAttributes["additionalHeaders"]; ok {
a.log.WithField("headers", additionalHeaders).Trace("setting additional headers")
if additionalHeaders == nil {
return headers
}
for key, value := range additionalHeaders.(map[string]interface{}) {
headers[key] = toString(value)
}
}
return headers
}
// Attempt to set basic auth based on user's attributes
func (a *Application) setAuthorizationHeader(headers http.Header, c *types.Claims) {
func (a *Application) setAuthorizationHeader(c *types.Claims) string {
if !*a.proxyConfig.BasicAuthEnabled {
return
return ""
}
userAttributes := c.Proxy.UserAttributes
var ok bool
var username string
var password string
if password, ok = userAttributes[*a.proxyConfig.BasicAuthPasswordAttribute].(string); !ok {
password = ""
}
// Check if we should use email or a custom attribute as username
var username string
if username, ok = userAttributes[*a.proxyConfig.BasicAuthUserAttribute].(string); !ok {
username = c.Email
}
if username == "" && password == "" {
return
if password == "" {
return ""
}
authVal := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
a.log.WithField("username", username).Trace("setting http basic auth")
headers.Set("Authorization", fmt.Sprintf("Basic %s", authVal))
}
func (a *Application) addHeaders(headers http.Header, c *types.Claims) {
// https://docs.goauthentik.io/add-secure-apps/providers/proxy
headers.Set("X-authentik-username", c.PreferredUsername)
headers.Set("X-authentik-groups", strings.Join(c.Groups, "|"))
headers.Set("X-authentik-entitlements", strings.Join(c.Entitlements, "|"))
headers.Set("X-authentik-email", c.Email)
headers.Set("X-authentik-name", c.Name)
headers.Set("X-authentik-uid", c.Sub)
headers.Set("X-authentik-jwt", c.RawToken)
// System headers
headers.Set("X-authentik-meta-jwks", a.endpoint.JwksUri)
headers.Set("X-authentik-meta-outpost", a.outpostName)
headers.Set("X-authentik-meta-provider", a.proxyConfig.Name)
headers.Set("X-authentik-meta-app", a.proxyConfig.AssignedApplicationSlug)
headers.Set("X-authentik-meta-version", constants.UserAgentOutpost())
if c.Proxy == nil {
return
}
userAttributes := c.Proxy.UserAttributes
a.setAuthorizationHeader(headers, c)
// Check if user has additional headers set that we should sent
if additionalHeaders, ok := userAttributes["additionalHeaders"]; ok {
a.log.WithField("headers", additionalHeaders).Trace("setting additional headers")
if additionalHeaders == nil {
return
}
for key, value := range additionalHeaders.(map[string]interface{}) {
headers.Set(key, toString(value))
}
}
return fmt.Sprintf("Basic %s", authVal)
}
// getTraefikForwardUrl See https://doc.traefik.io/traefik/middlewares/forwardauth/

View File

@@ -1,12 +1,15 @@
package application
import (
"net/http"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/proxyv2/types"
)
func urlMustParse(u string) *url.URL {
@@ -48,3 +51,135 @@ func TestIsAllowlisted_Proxy_Domain(t *testing.T) {
assert.Equal(t, false, a.IsAllowlisted(urlMustParse("https://health.domain.tld/")))
assert.Equal(t, true, a.IsAllowlisted(urlMustParse("https://health.domain.tld/ping/qq")))
}
func TestAdHeaders_Standard(t *testing.T) {
a := newTestApplication()
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
})
assert.Equal(t, http.Header{
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_BasicAuth(t *testing.T) {
a := newTestApplication()
a.proxyConfig.BasicAuthEnabled = api.PtrBool(true)
a.proxyConfig.BasicAuthUserAttribute = api.PtrString("user")
a.proxyConfig.BasicAuthPasswordAttribute = api.PtrString("pass")
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"user": "foo",
"pass": "baz",
},
},
})
assert.Equal(t, http.Header{
"Authorization": []string{"Basic Zm9vOmJheg=="},
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_Extra(t *testing.T) {
a := newTestApplication()
h := http.Header{}
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
Proxy: &types.ProxyClaims{
UserAttributes: map[string]any{
"additionalHeaders": map[string]any{
"foo": "bar",
},
},
},
})
assert.Equal(t, http.Header{
"Foo": []string{"bar"},
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}
func TestAdHeaders_UnderscoreInitial(t *testing.T) {
a := newTestApplication()
h := http.Header{}
h.Set("X_AUTHENTIK_USERNAME", "another user")
h.Set("X-Authentik_username", "another user")
a.addHeaders(h, &types.Claims{
PreferredUsername: "foo",
Groups: []string{"foo", "bar"},
Entitlements: []string{"bar", "quox"},
Email: "bar@authentik.company",
Name: "foo",
Sub: "bar",
RawToken: "baz",
})
assert.Equal(t, http.Header{
"X-Authentik-Email": []string{"bar@authentik.company"},
"X-Authentik-Entitlements": []string{"bar|quox"},
"X-Authentik-Groups": []string{"foo|bar"},
"X-Authentik-Jwt": []string{"baz"},
"X-Authentik-Meta-App": []string{""},
"X-Authentik-Meta-Jwks": []string{""},
"X-Authentik-Meta-Outpost": []string{""},
"X-Authentik-Meta-Provider": []string{a.proxyConfig.Name},
"X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()},
"X-Authentik-Name": []string{"foo"},
"X-Authentik-Uid": []string{"bar"},
"X-Authentik-Username": []string{"foo"},
}, h)
}

View File

@@ -29,7 +29,10 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
// Add one to the validity to ensure we don't have a session with indefinite length
maxAge = int(*t) + 1
}
if a.isEmbedded {
sessionBackend := a.srv.SessionBackend()
switch sessionBackend {
case "postgres":
// New PostgreSQL store
ps, err := postgresstore.NewPostgresStore()
if err != nil {
@@ -46,30 +49,32 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
Path: "/",
})
a.log.Trace("using postgresql session backend")
return ps, nil
}
dir := os.TempDir()
cs, err := filesystemstore.GetPersistentStore(dir)
if err != nil {
return nil, err
}
cs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret))
// https://github.com/markbates/goth/commit/7276be0fdf719ddff753f3574ef0f967e4a5a5f7
// set the maxLength of the cookies stored on the disk to a larger number to prevent issues with:
// securecookie: the value is too long
// when using OpenID Connect, since this can contain a large amount of extra information in the id_token
case "filesystem":
dir := os.TempDir()
cs, err := filesystemstore.GetPersistentStore(dir)
if err != nil {
return nil, err
}
cs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret))
// https://github.com/markbates/goth/commit/7276be0fdf719ddff753f3574ef0f967e4a5a5f7
// set the maxLength of the cookies stored on the disk to a larger number to prevent issues with:
// securecookie: the value is too long
// when using OpenID Connect, since this can contain a large amount of extra information in the id_token
// Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk
cs.MaxLength(math.MaxInt)
cs.Options.HttpOnly = true
cs.Options.Secure = strings.ToLower(externalHost.Scheme) == "https"
cs.Options.Domain = *p.CookieDomain
cs.Options.SameSite = http.SameSiteLaxMode
cs.Options.MaxAge = maxAge
cs.Options.Path = "/"
a.log.WithField("dir", dir).Trace("using filesystem session backend")
return cs, nil
// Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk
cs.MaxLength(math.MaxInt)
cs.Options.HttpOnly = true
cs.Options.Secure = strings.ToLower(externalHost.Scheme) == "https"
cs.Options.Domain = *p.CookieDomain
cs.Options.SameSite = http.SameSiteLaxMode
cs.Options.MaxAge = maxAge
cs.Options.Path = "/"
return cs, nil
default:
a.log.WithField("backend", sessionBackend).Panic("unknown session backend type")
return nil, nil
}
}
func (a *Application) SessionName() string {

View File

@@ -41,6 +41,10 @@ func (ts *testServer) Apps() []*Application {
return ts.apps
}
func (ts *testServer) SessionBackend() string {
return "filesystem"
}
func newTestApplication() *Application {
ts := newTestServer()
a, _ := NewApplication(

View File

@@ -55,6 +55,11 @@ func NewProxyServer(ac *ak.APIController) ak.Outpost {
if ac.GlobalConfig.ErrorReporting.Enabled {
globalMux.Use(sentryhttp.New(sentryhttp.Options{}).Handle)
}
if ac.IsEmbedded() {
l.Info("using PostgreSQL session backend")
} else {
l.Info("using filesystem session backend")
}
s := &ProxyServer{
cryptoStore: ak.NewCryptoStore(ac.Client.CryptoApi),
apps: make(map[string]*application.Application),

View File

@@ -15,7 +15,9 @@ import (
)
func (ps *ProxyServer) Refresh() error {
providers, err := ak.Paginator(ps.akAPI.Client.OutpostsApi.OutpostsProxyList(context.Background()), ak.PaginatorOptions{
req := ps.akAPI.Client.OutpostsApi.OutpostsProxyList(context.Background())
ps.log.WithField("outpost_pk", ps.akAPI.Outpost.Pk).Debug("Requesting providers for outpost")
providers, err := ak.Paginator(req, ak.PaginatorOptions{
PageSize: 100,
Logger: ps.log,
})
@@ -25,6 +27,13 @@ func (ps *ProxyServer) Refresh() error {
if err != nil {
return err
}
ps.log.WithField("count", len(providers)).Debug("Fetched providers")
if len(providers) == 0 {
ps.log.Warning("No providers assigned to this outpost, check outpost configuration in authentik")
}
for i, p := range providers {
ps.log.WithField("index", i).WithField("name", p.Name).WithField("external_host", p.ExternalHost).WithField("assigned_to_app", p.AssignedApplicationName).Debug("Provider details")
}
apps := make(map[string]*application.Application)
for _, provider := range providers {
rsp := sentry.StartSpan(context.Background(), "authentik.outposts.proxy.application_ss")
@@ -52,6 +61,7 @@ func (ps *ProxyServer) Refresh() error {
ps.log.WithError(err).Warning("failed to setup application")
continue
}
ps.log.WithField("name", provider.Name).WithField("host", externalHost.Host).Info("Loaded application")
apps[externalHost.Host] = a
}
ps.apps = apps
@@ -70,3 +80,14 @@ func (ps *ProxyServer) CryptoStore() *ak.CryptoStore {
func (ps *ProxyServer) Apps() []*application.Application {
return maps.Values(ps.apps)
}
func (ps *ProxyServer) SessionBackend() string {
if ps.akAPI.IsEmbedded() {
return "postgres"
}
if !ps.akAPI.IsEmbedded() {
return "filesystem"
}
ps.log.Panic("failed to determine session backend type")
return ""
}

View File

@@ -1,10 +1,10 @@
package types
type ProxyClaims struct {
UserAttributes map[string]interface{} `json:"user_attributes"`
BackendOverride string `json:"backend_override"`
HostHeader string `json:"host_header"`
IsSuperuser bool `json:"is_superuser"`
UserAttributes map[string]any `json:"user_attributes" mapstructure:"user_attributes"`
BackendOverride string `json:"backend_override" mapstructure:"backend_override"`
HostHeader string `json:"host_header" mapstructure:"host_header"`
IsSuperuser bool `json:"is_superuser" mapstructure:"is_superuser"`
}
type Claims struct {
@@ -19,5 +19,5 @@ type Claims struct {
Sid string `json:"sid" mapstructure:"sid"`
Proxy *ProxyClaims `json:"ak_proxy" mapstructure:"ak_proxy"`
RawToken string `mapstructure:"-"`
RawToken string `json:"raw_token" mapstructure:"raw_token"`
}

View File

@@ -41,95 +41,92 @@ func (pi *ProviderInstance) SetEAPState(key string, state *protocol.State) {
}
func (pi *ProviderInstance) GetEAPSettings() protocol.Settings {
protocols := []protocol.ProtocolConstructor{
identity.Protocol,
legacy_nak.Protocol,
settings := protocol.Settings{
Logger: &logrusAdapter{pi.log},
Protocols: []protocol.ProtocolConstructor{
identity.Protocol,
legacy_nak.Protocol,
},
}
certId := pi.certId
if certId == "" {
return protocol.Settings{
Protocols: protocols,
}
return settings
}
cert := pi.s.cryptoStore.Get(certId)
if cert == nil {
return protocol.Settings{
Protocols: protocols,
}
return settings
}
return protocol.Settings{
Logger: &logrusAdapter{entry: pi.log},
Protocols: append(protocols, tls.Protocol, peap.Protocol),
ProtocolPriority: []protocol.Type{
identity.TypeIdentity,
tls.TypeTLS,
},
ProtocolSettings: map[protocol.Type]interface{}{
tls.TypeTLS: tls.Settings{
Config: &ttls.Config{
Certificates: []ttls.Certificate{*cert},
ClientAuth: ttls.RequireAnyClientCert,
},
HandshakeSuccessful: func(ctx protocol.Context, certs []*x509.Certificate) protocol.Status {
ident := ctx.GetProtocolState(identity.TypeIdentity).(*identity.State).Identity
settings.Protocols = append(settings.Protocols, tls.Protocol, peap.Protocol)
settings.ProtocolPriority = []protocol.Type{
identity.TypeIdentity,
tls.TypeTLS,
}
settings.ProtocolSettings = map[protocol.Type]any{
tls.TypeTLS: tls.Settings{
Config: &ttls.Config{
Certificates: []ttls.Certificate{*cert},
ClientAuth: ttls.RequireAnyClientCert,
},
HandshakeSuccessful: func(ctx protocol.Context, certs []*x509.Certificate) protocol.Status {
ident := ctx.GetProtocolState(identity.TypeIdentity).(*identity.State).Identity
ctx.Log().Debug("Starting authn flow")
pem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certs[0].Raw,
ctx.Log().Debug("Starting authn flow")
pem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certs[0].Raw,
})
fe := flow.NewFlowExecutor(context.Background(), pi.flowSlug, pi.s.ac.Client.GetConfig(), log.Fields{
"client": utils.GetIP(ctx.Packet().RemoteAddr),
"identity": ident,
})
fe.Answers[flow.StageIdentification] = ident
fe.DelegateClientIP(utils.GetIP(ctx.Packet().RemoteAddr))
fe.Params.Add("goauthentik.io/outpost/radius", "true")
fe.AddHeader("X-Authentik-Outpost-Certificate", url.QueryEscape(string(pem)))
passed, err := fe.Execute()
if err != nil {
ctx.Log().Warn("failed to execute flow", "error", err)
return protocol.StatusError
}
ctx.Log().Debug("Finished flow")
if !passed {
return protocol.StatusError
}
access, _, err := fe.ApiClient().OutpostsApi.OutpostsRadiusAccessCheck(context.Background(), pi.providerId).AppSlug(pi.appSlug).Execute()
if err != nil {
ctx.Log().Warn("failed to check access: %v", err)
return protocol.StatusError
}
if !access.Access.Passing {
ctx.Log().Info("Access denied for user")
return protocol.StatusError
}
if access.HasAttributes() {
ctx.AddResponseModifier(func(r, q *radius.Packet) error {
rawData, err := base64.StdEncoding.DecodeString(access.GetAttributes())
if err != nil {
ctx.Log().Warn("failed to decode attributes from core: %v", err)
return errors.New("attribute_decode_failed")
}
p, err := radius.Parse(rawData, pi.SharedSecret)
if err != nil {
ctx.Log().Warn("failed to parse attributes from core: %v", err)
return errors.New("attribute_parse_failed")
}
for _, attr := range p.Attributes {
r.Add(attr.Type, attr.Attribute)
}
return nil
})
fe := flow.NewFlowExecutor(context.Background(), pi.flowSlug, pi.s.ac.Client.GetConfig(), log.Fields{
"client": utils.GetIP(ctx.Packet().RemoteAddr),
"identity": ident,
})
fe.Answers[flow.StageIdentification] = ident
fe.DelegateClientIP(utils.GetIP(ctx.Packet().RemoteAddr))
fe.Params.Add("goauthentik.io/outpost/radius", "true")
fe.AddHeader("X-Authentik-Outpost-Certificate", url.QueryEscape(string(pem)))
passed, err := fe.Execute()
if err != nil {
ctx.Log().Warn("failed to execute flow", "error", err)
return protocol.StatusError
}
ctx.Log().Debug("Finished flow")
if !passed {
return protocol.StatusError
}
access, _, err := fe.ApiClient().OutpostsApi.OutpostsRadiusAccessCheck(context.Background(), pi.providerId).AppSlug(pi.appSlug).Execute()
if err != nil {
ctx.Log().Warn("failed to check access: %v", err)
return protocol.StatusError
}
if !access.Access.Passing {
ctx.Log().Info("Access denied for user")
return protocol.StatusError
}
if access.HasAttributes() {
ctx.AddResponseModifier(func(r, q *radius.Packet) error {
rawData, err := base64.StdEncoding.DecodeString(access.GetAttributes())
if err != nil {
ctx.Log().Warn("failed to decode attributes from core: %v", err)
return errors.New("attribute_decode_failed")
}
p, err := radius.Parse(rawData, pi.SharedSecret)
if err != nil {
ctx.Log().Warn("failed to parse attributes from core: %v", err)
return errors.New("attribute_parse_failed")
}
for _, attr := range p.Attributes {
r.Add(attr.Type, attr.Attribute)
}
return nil
})
}
return protocol.StatusSuccess
},
}
return protocol.StatusSuccess
},
},
}
return settings
}

View File

@@ -19,9 +19,7 @@ import (
staticWeb "goauthentik.io/web"
)
var (
ErrAuthentikStarting = errors.New("authentik starting")
)
var ErrAuthentikStarting = errors.New("authentik starting")
const (
maxBodyBytes = 32 * 1024 * 1024
@@ -99,11 +97,11 @@ func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request
if strings.Contains(accept, "application/json") {
header.Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusServiceUnavailable)
err = json.NewEncoder(rw).Encode(map[string]string{
"error": "authentik starting",
})
if err != nil {
ws.log.WithError(err).Warning("failed to write error message")
return
@@ -113,21 +111,18 @@ func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request
rw.WriteHeader(http.StatusServiceUnavailable)
loadingSplashFile, err := staticWeb.StaticDir.Open("standalone/loading/startup.html")
if err != nil {
ws.log.WithError(err).Warning("failed to open startup splash screen")
return
}
loadingSplashHTML, err := io.ReadAll(loadingSplashFile)
if err != nil {
ws.log.WithError(err).Warning("failed to read startup splash screen")
return
}
_, err = rw.Write(loadingSplashHTML)
if err != nil {
ws.log.WithError(err).Warning("failed to write startup splash screen")
return
@@ -138,7 +133,6 @@ func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request
// Fallback to just a status message
_, err = rw.Write([]byte("authentik starting"))
if err != nil {
ws.log.WithError(err).Warning("failed to write initializing HTML")
}

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -31,13 +31,14 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/ldap ./cmd/ldap
# Stage 2: Run
FROM ghcr.io/goauthentik/fips-debian:bookworm-slim-fips
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:9b4cedf932e97194f1825124830f2eec14254d90162dad28f97e505971543115
ARG VERSION
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io LDAP outpost, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"dependencies": {
"@eslint/js": "^9.31.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -473,7 +473,7 @@ class PostgresChannelLayerReceiver:
"""
DELETE
FROM {table}
WHERE {table}.{channel} IN (%s)
WHERE {table}.{channel} = ANY(%s)
AND {table}.{expires} >= %s
RETURNING {table}.{id}, {table}.{channel}, {table}.{message}
"""
@@ -484,7 +484,7 @@ class PostgresChannelLayerReceiver:
expires=sql.Identifier("expires"),
message=sql.Identifier("message"),
),
(tuple(self._subscribed_to), now()),
(list(self._subscribed_to), now()),
)
async for row in cursor:
message_id, channel, message = row

View File

@@ -61,6 +61,7 @@ def raise_connection_error(func: Callable[P, R]) -> Callable[P, R]:
try:
return func(*args, **kwargs)
except DATABASE_ERRORS as exc:
logger.warning("Database error encountered", exc=exc)
raise ConnectionError(str(exc)) from exc # type: ignore[no-untyped-call]
return wrapper
@@ -239,15 +240,18 @@ class _PostgresConsumer(Consumer):
self.in_processing: set[str] = set()
self.prefetch = prefetch
self.misses = 0
# We have two different connections here. One for locks and one for listening to
# notifications. We can't use the same connection for both as the listen connection might
# be blocked with pending notifications. We also can't use a Django connection as we can't
# be sure we'll get the same one every time to be able to release locks from the same
# connection.
self._locks_connection: DatabaseWrapper | None = None
self._listen_connection: DatabaseWrapper | None = None
self.postgres_channel = channel_name(self.queue_name, ChannelIdentifier.ENQUEUE)
# Override because dramatiq doesn't allow us setting this manually
self.timeout = Conf().worker["consumer_listen_timeout"]
self.lock_purge_interval = timedelta(seconds=Conf().lock_purge_interval)
self.lock_purge_last_run = timezone.now()
self.task_purge_interval = timedelta(seconds=Conf().task_purge_interval)
self.task_purge_last_run = timezone.now() - self.task_purge_interval
@@ -258,14 +262,17 @@ class _PostgresConsumer(Consumer):
self.scheduler_interval = timedelta(seconds=Conf().scheduler_interval)
self.scheduler_last_run = timezone.now() - self.scheduler_interval
@property
def connection(self) -> DatabaseWrapper:
return cast(DatabaseWrapper, connections[self.db_alias])
@property
def query_set(self) -> QuerySet[TaskBase]:
return self.broker.query_set
@property
def locks_connection(self) -> DatabaseWrapper:
if self._locks_connection is not None and self._locks_connection.is_usable():
return self._locks_connection
self._locks_connection = cast(DatabaseWrapper, connections.create_connection(self.db_alias))
return self._locks_connection
@property
def listen_connection(self) -> DatabaseWrapper:
if self._listen_connection is not None and self._listen_connection.is_usable():
@@ -320,21 +327,40 @@ class _PostgresConsumer(Consumer):
self.logger.debug("Message already consumed by self", message_id=message_id)
return None
lock_result = (
self.query_set.filter(message_id=message_id)
.exclude(state__in=(TaskState.DONE, TaskState.REJECTED))
.exclude(eta__gte=timezone.now() + timedelta(seconds=self.timeout))
.extra(
where=["pg_try_advisory_lock(%s)"],
params=[self._get_message_lock_id(message_id)],
with self.locks_connection.cursor() as cursor:
cursor.execute(
sql.SQL(
"""
UPDATE {table}
SET {state} = %(state)s, {mtime} = %(mtime)s
WHERE
{table}.{message_id} = %(message_id)s
AND
{table}.{state} != ALL(%(excluded_states)s)
AND
({table}.{eta} < %(maximum_eta)s OR {table}.{eta} IS NULL)
AND
pg_try_advisory_lock(%(lock_id)s)
"""
).format(
table=sql.Identifier(self.query_set.model._meta.db_table),
state=sql.Identifier("state"),
mtime=sql.Identifier("mtime"),
message_id=sql.Identifier("message_id"),
eta=sql.Identifier("eta"),
),
{
"state": TaskState.CONSUMED.value,
"mtime": timezone.now(),
"message_id": message_id,
"excluded_states": [TaskState.DONE.value, TaskState.REJECTED.value],
"maximum_eta": timezone.now() + timedelta(seconds=self.timeout),
"lock_id": self._get_message_lock_id(message_id),
},
)
.update(
state=TaskState.CONSUMED,
mtime=timezone.now(),
)
)
if lock_result != 1:
return None
if cursor.rowcount != 1:
self._unlock_message(message_id)
return None
task: TaskBase | None = (
self.query_set.defer(None).defer("result").filter(message_id=message_id).first()
@@ -405,9 +431,10 @@ class _PostgresConsumer(Consumer):
def _unlock_message(self, message_id: str) -> bool:
self.logger.debug("Unlocking message", message_id=message_id)
try:
with self.connection.cursor() as cursor:
with self.locks_connection.cursor() as cursor:
cursor.execute(
"SELECT pg_advisory_unlock(%s)", (self._get_message_lock_id(message_id),)
"SELECT pg_advisory_unlock(%s)",
(self._get_message_lock_id(message_id),),
)
return True
except DATABASE_ERRORS:
@@ -420,7 +447,7 @@ class _PostgresConsumer(Consumer):
self.in_processing.remove(str(message.message_id))
except KeyError:
pass
self._unlock_message(str(message.message_id))
self.to_unlock.add(str(message.message_id))
task = message.options.pop("task", None)
self.query_set.filter(
message_id=message.message_id,
@@ -453,7 +480,6 @@ class _PostgresConsumer(Consumer):
for message in messages:
self.to_unlock.add(str(message.message_id))
self.in_processing.remove(str(message.message_id))
self._purge_locks()
def _scheduler(self) -> None:
if not self.scheduler:
@@ -464,8 +490,6 @@ class _PostgresConsumer(Consumer):
self.schedule_last_run = timezone.now()
def _purge_locks(self) -> None:
if timezone.now() - self.lock_purge_last_run < self.lock_purge_interval:
return
while True:
try:
message_id = self.to_unlock.pop()
@@ -473,7 +497,6 @@ class _PostgresConsumer(Consumer):
break
if not self._unlock_message(str(message_id)):
return
self.lock_purge_last_run = timezone.now()
def _auto_purge(self) -> None:
if timezone.now() - self.task_purge_last_run < self.task_purge_interval:
@@ -492,15 +515,17 @@ class _PostgresConsumer(Consumer):
try:
self._purge_locks()
finally:
try:
self.connection.close()
except DATABASE_ERRORS:
pass
finally:
if self._listen_connection is not None:
conn = self._listen_connection
self._listen_connection = None
try:
conn.close()
except DATABASE_ERRORS:
pass
if self._locks_connection is not None:
conn = self._locks_connection
self._locks_connection = None
try:
conn.close()
except DATABASE_ERRORS:
pass
if self._listen_connection is not None:
conn = self._listen_connection
self._listen_connection = None
try:
conn.close()
except DATABASE_ERRORS:
pass

View File

@@ -63,10 +63,6 @@ class Conf:
def task_model(self) -> str:
return cast(str, self.conf["task_model"])
@property
def lock_purge_interval(self) -> int:
return cast(int, self.conf.get("lock_purge_interval", 60))
@property
def task_purge_interval(self) -> int:
# 24 hours

View File

@@ -1,15 +1,21 @@
import base64
import pickle # nosec
from datetime import UTC, datetime
from typing import Any
from django.conf import settings
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.core.cache.backends.db import DatabaseCache as BaseDatabaseCache
from django.db import DatabaseError
from django.db.utils import ProgrammingError
from django.utils.module_loading import import_string
from django.utils.timezone import now
from psqlextra.types import ConflictAction
from django_postgres_cache.models import CacheEntry
class DatabaseCache(BaseDatabaseCache):
def __init__(self, table: str, params: dict[str, Any]) -> None:
super().__init__(table, params)
self.reverse_key_func = import_string(params["REVERSE_KEY_FUNCTION"])
@@ -49,3 +55,87 @@ class DatabaseCache(BaseDatabaseCache):
if not entry:
return None
return int((entry.expires - now()).total_seconds())
def _base_set_expiry(self, timeout: float | None) -> datetime:
timeout = self.get_backend_timeout(timeout)
if timeout is None:
exp = datetime.max
else:
tz = UTC if settings.USE_TZ else None
exp = datetime.fromtimestamp(timeout, tz=tz)
exp.replace(microsecond=0)
return exp
def _base_set_data(
self,
key: Any,
value: Any,
timeout: float | None,
version: int | None = None,
) -> tuple[str, str, datetime]:
key = self.make_and_validate_key(key, version=version)
pickled = pickle.dumps(value, self.pickle_protocol)
# The DB column is expecting a string, so make sure the value is a
# string, not bytes. Refs #19274.
b64encoded = base64.b64encode(pickled).decode("latin1")
return (key, b64encoded, self._base_set_expiry(timeout))
def touch(
self,
key: Any,
timeout: float | None = DEFAULT_TIMEOUT,
version: int | None = None,
) -> bool:
key = self.make_and_validate_key(key, version=version)
expiry = self._base_set_expiry(timeout)
try:
count = CacheEntry.objects.filter(cache_key=key).update(expires=expiry)
return bool(count != 0)
except DatabaseError:
return False
def add(
self,
key: Any,
value: Any,
timeout: float | None = DEFAULT_TIMEOUT,
version: int | None = None,
) -> bool:
key, value, expiry = self._base_set_data(key, value, timeout, version)
try:
CacheEntry.objects.on_conflict(
["cache_key"],
ConflictAction.UPDATE,
update_values=dict(
expires=expiry,
),
).insert(
cache_key=key,
value=value,
expires=expiry,
)
# We don't know if the row already existed, we just return True for success
return True
except DatabaseError:
return False
def set(
self,
key: Any,
value: Any,
timeout: float | None = DEFAULT_TIMEOUT,
version: int | None = None,
) -> None:
key, value, expiry = self._base_set_data(key, value, timeout, version)
CacheEntry.objects.on_conflict(
["cache_key"],
ConflictAction.UPDATE,
).insert(
cache_key=key,
value=value,
expires=expiry,
)
def clear(self) -> None:
CacheEntry.objects.truncate()

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-10-28 14:04
import psqlextra.manager.manager
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("django_postgres_cache", "0001_initial"),
]
operations = [
migrations.AlterModelManagers(
name="cacheentry",
managers=[
("objects", psqlextra.manager.manager.PostgresManager()), # type: ignore[no-untyped-call]
],
),
]

View File

@@ -1,12 +1,14 @@
from django.db import models
from psqlextra.manager import PostgresManager
class CacheEntry(models.Model):
cache_key = models.TextField(primary_key=True)
value = models.TextField()
expires = models.DateTimeField(db_index=True)
objects = PostgresManager() # type: ignore[no-untyped-call]
class Meta:
default_permissions = []

View File

@@ -31,6 +31,7 @@ classifiers = [
dependencies = [
"django >=4.2,<6.0",
"django-postgres-extra >=2.0,<2.1",
]
[project.urls]

View File

@@ -17,7 +17,7 @@ COPY web .
RUN npm run build-proxy
# Stage 2: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -47,13 +47,14 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/proxy ./cmd/proxy
# Stage 3: Run
FROM ghcr.io/goauthentik/fips-debian:bookworm-slim-fips
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:9b4cedf932e97194f1825124830f2eec14254d90162dad28f97e505971543115
ARG VERSION
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io Proxy outpost image, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.10.0-rc1"
version = "2025.10.2"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"
@@ -11,7 +11,7 @@ dependencies = [
"dacite==1.9.2",
"deepmerge==2.0",
"defusedxml==0.7.1",
"django==5.2.7",
"django==5.2.8",
"django-channels-postgres",
"django-countries==7.6.1",
"django-cte==2.0.0",

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -31,13 +31,14 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/rac ./cmd/rac
# Stage 2: Run
FROM ghcr.io/goauthentik/guacd:v1.6.0-fips
FROM ghcr.io/goauthentik/guacd:v1.6.0-fips@sha256:1d99572b0260924149b8c923c021a32016f885fcea6d5cc8d58f718dfdc7a2dd
ARG VERSION
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io RAC outpost, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS builder
ARG TARGETOS
ARG TARGETARCH
@@ -31,13 +31,14 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/radius ./cmd/radius
# Stage 2: Run
FROM ghcr.io/goauthentik/fips-debian:bookworm-slim-fips
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:9b4cedf932e97194f1825124830f2eec14254d90162dad28f97e505971543115
ARG VERSION
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io Radius outpost, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.10.0-rc1
version: 2025.10.2
description: Making authentication simple.
contact:
email: hello@goauthentik.io

View File

@@ -30,7 +30,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
super().setUp()
self.ssl_folder = mkdtemp()
self.run_container(
image="library/docker:dind",
image="docker.io/library/docker:28.5.2-dind-alpine3.22",
network_mode="host",
privileged=True,
healthcheck=Healthcheck(

View File

@@ -30,7 +30,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
super().setUp()
self.ssl_folder = mkdtemp()
self.run_container(
image="library/docker:dind",
image="docker.io/library/docker:28.5.2-dind-alpine3.22",
network_mode="host",
privileged=True,
healthcheck=Healthcheck(

20
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = "==3.13.*"
[manifest]
@@ -170,7 +170,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.10.0rc1"
version = "2025.10.2"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
@@ -284,7 +284,7 @@ requires-dist = [
{ name = "dacite", specifier = "==1.9.2" },
{ name = "deepmerge", specifier = "==2.0" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "django", specifier = "==5.2.7" },
{ name = "django", specifier = "==5.2.8" },
{ name = "django-channels-postgres", editable = "packages/django-channels-postgres" },
{ name = "django-countries", specifier = "==7.6.1" },
{ name = "django-cte", specifier = "==2.0.0" },
@@ -977,16 +977,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.7"
version = "5.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
{ url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" },
]
[[package]]
@@ -1141,10 +1141,14 @@ version = "0.1.0"
source = { editable = "packages/django-postgres-cache" }
dependencies = [
{ name = "django" },
{ name = "django-postgres-extra" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=4.2,<6.0" }]
requires-dist = [
{ name = "django", specifier = ">=4.2,<6.0" },
{ name = "django-postgres-extra", specifier = ">=2.0,<2.1" },
]
[[package]]
name = "django-postgres-extra"
@@ -1628,6 +1632,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
]

141
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"license": "MIT",
"workspaces": [
"./packages/*"
@@ -1940,6 +1940,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz",
"integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
@@ -2317,6 +2318,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2338,6 +2340,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz",
"integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -2350,6 +2353,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz",
"integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2365,6 +2369,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz",
"integrity": "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.204.0",
"import-in-the-middle": "^1.8.1",
@@ -2757,6 +2762,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz",
"integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.1.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2773,6 +2779,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz",
"integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.1.0",
"@opentelemetry/resources": "2.1.0",
@@ -2790,6 +2797,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
"integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@@ -3118,7 +3126,6 @@
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
"integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
@@ -3147,17 +3154,6 @@
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@prisma/instrumentation": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.15.0.tgz",
@@ -4517,18 +4513,6 @@
}
}
},
"node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.1.tgz",
"integrity": "sha512-gRO+jk2ljxZlIn20QRskIvpLCMtzuLl5T0BY6L9uvPYD17uUrxlxWkvYCiVqED2q2q7CVtY52Uex4WcYo2FEXw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.2.1",
"node-gyp-build": "^4.8.2"
}
},
"node_modules/@swagger-api/apidom-reference": {
"version": "1.0.0-beta.30",
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.30.tgz",
@@ -4644,6 +4628,7 @@
"integrity": "sha512-V1r4wFdjaZIUIZZrV2Mb/prEeu03xvSm6oatPxsvnXKF9lNh5Jtk9QvUdiVfD9rrvi7bXrAVhg9Wpbmv/2Fl1g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@@ -5012,6 +4997,7 @@
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz",
"integrity": "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@babel/generator": "^7.26.5",
"@babel/parser": "^7.26.7",
@@ -5481,6 +5467,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.0.tgz",
"integrity": "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5525,6 +5512,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5634,6 +5622,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -6080,6 +6069,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.11.tgz",
"integrity": "sha512-U4iqPlHO0KQeK1mrsxCN0vZzw43/lL8POxgpzcJweopmqtoYy9nljJzWDIQS3EfjiYhfdtdk9Gtgz7MRXnz3GA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.23.5",
"@vue/compiler-core": "3.3.11",
@@ -6505,6 +6495,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7111,25 +7102,6 @@
"node": ">=18"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -7393,6 +7365,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -7423,6 +7396,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -7789,6 +7763,7 @@
"version": "3.30.2",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz",
"integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -8189,6 +8164,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8338,6 +8314,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -8565,7 +8542,6 @@
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.1.tgz",
"integrity": "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20"
}
@@ -8575,7 +8551,6 @@
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz",
"integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
@@ -8937,6 +8912,7 @@
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -9199,6 +9175,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10689,7 +10666,6 @@
"resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz",
"integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/fisker/git-hooks-list?sponsor=1"
}
@@ -11150,6 +11126,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.9.12.tgz",
"integrity": "sha512-SrTC0YxqPwnN7yKa8gg/giLyQ2pILCKoideIHbYbFQlWZjYt68D2A4Ae1hehO/aDQ6RmTcpqOV/O2yBtMzx/VQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12029,7 +12006,8 @@
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"peer": true
},
"node_modules/js-levenshtein-esm": {
"version": "2.0.0",
@@ -12312,6 +12290,7 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
@@ -15044,6 +15023,18 @@
"points-on-curve": "0.2.0"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -15131,6 +15122,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -15427,6 +15419,7 @@
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
"integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ramda"
@@ -15544,6 +15537,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -15553,6 +15547,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16149,6 +16144,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -16904,15 +16900,13 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz",
"integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/sort-package-json": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.4.0.tgz",
"integrity": "sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"detect-indent": "^7.0.1",
"detect-newline": "^4.0.1",
@@ -17053,6 +17047,7 @@
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -17501,7 +17496,6 @@
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
"integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pkgr/core": "^0.2.4"
},
@@ -17695,19 +17689,6 @@
"node": ">=6"
}
},
"node_modules/tree-sitter": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz",
"integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
}
},
"node_modules/tree-sitter-json": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz",
@@ -17980,6 +17961,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17993,6 +17975,7 @@
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
@@ -18354,6 +18337,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -18484,6 +18468,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -19163,6 +19148,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -19290,7 +19276,7 @@
"@swc/core": "^1.13.19",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"base64-js": "^1.5.1",
"bootstrap": "^5.3.8",
"bootstrap": "^4.6.2",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"prettier": "^3.5.3",
@@ -19310,6 +19296,27 @@
"@swc/core-win32-ia32-msvc": "^1.6.13",
"@swc/core-win32-x64-msvc": "^1.6.13"
}
},
"packages/sfe/node_modules/bootstrap": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
"deprecated": "This version of Bootstrap is no longer supported. Please upgrade to the latest version.",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"jquery": "1.9.1 - 3",
"popper.js": "^1.16.1"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/web",
"version": "2025.10.0-rc1",
"version": "2025.10.2",
"license": "MIT",
"private": true,
"scripts": {

View File

@@ -19,7 +19,7 @@
"@swc/core": "^1.13.19",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"base64-js": "^1.5.1",
"bootstrap": "^5.3.8",
"bootstrap": "^4.6.2",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"prettier": "^3.5.3",

View File

@@ -1,5 +1,7 @@
import "#admin/admin-overview/AdminOverviewPage";
import { globalAK } from "#common/global";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "#elements/router/Route";
import { html } from "lit";
@@ -158,3 +160,14 @@ export const ROUTES: Route[] = [
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}),
];
/**
* Application route helpers.
*
* @TODO: This API isn't quite right yet. Revisit after the hash router is replaced.
*/
export const ApplicationRoute = {
EditURL(slug: string, base = globalAK().api.base) {
return `${base}if/admin/#/core/applications/${slug}`;
},
} as const;

View File

@@ -265,15 +265,6 @@ export function renderForm({
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Encryption Key")} name="encryptionKey">
<!-- NOTE: 'null' cast to 'undefined' on encryptionKey to satisfy Lit requirements -->
<ak-crypto-certificate-search
label=${msg("Encryption Key")}
placeholder=${msg("Select an encryption key...")}
certificate=${ifPresent(provider.encryptionKey)}
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to encrypt the tokens.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
@@ -382,6 +373,23 @@ export function renderForm({
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Encryption Key")} name="encryptionKey">
<!-- NOTE: 'null' cast to 'undefined' on encryptionKey to satisfy Lit requirements -->
<ak-crypto-certificate-search
label=${msg("Encryption Key")}
placeholder=${msg("Select an encryption key...")}
certificate=${ifPresent(provider.encryptionKey)}
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg(
"Key used to encrypt the tokens. Only enable this if the application using this provider supports JWE tokens.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg("authentik only supports RSA-OAEP-256 for encryption.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input
name="subMode"
label=${msg("Subject mode")}

View File

@@ -17,7 +17,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
}
async send(data: SCIMProvider): Promise<SCIMProvider> {
if (this.instance) {
if (this.instance?.pk) {
return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({
id: this.instance.pk,
sCIMProviderRequest: data,

View File

@@ -119,7 +119,10 @@ export class InvitationListPage extends TablePage<Invitation> {
</ak-label>
`
: nothing}`,
html`${item.createdBy?.username}`,
html`<div>
<a href="#/identity/users/${item.createdBy.pk}">${item.createdBy.username}</a>
</div>
<small>${item.createdBy.name}</small>`,
html`${item.expires?.toLocaleString() || msg("-")}`,
html` <ak-forms-modal>
<span slot="submit">${msg("Update")}</span>

View File

@@ -182,6 +182,17 @@ html > form > input {
overflow: hidden;
}
@media not (prefers-contrast: more) {
.less-contrast-sr-only {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
}
/* #endregion */
/* #region Icons */
@@ -529,7 +540,8 @@ fieldset {
}
.pf-c-form__helper-text {
text-wrap: pretty;
text-wrap: balance;
text-wrap: pretty; /* Supporting browsers. */
}
::placeholder {

View File

@@ -0,0 +1,45 @@
:host {
--icon-border: 0;
--app-icon-shadow-blend-color: color-mix(
in srgb,
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150)) 100%,
black 100%
);
display: flex;
place-content: center;
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
width: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
}
:host([size="pf-m-lg"]) {
--icon-height: 4rem;
--icon-border: 0.25rem;
}
:host([size="pf-m-md"]) {
--icon-height: 2rem;
--icon-border: 0.125rem;
}
:host([size="pf-m-sm"]) {
--icon-height: 1rem;
--icon-border: 0.125rem;
}
:host([size="pf-m-xl"]) {
--icon-height: 6rem;
--icon-border: 0.25rem;
}
.icon {
font-size: var(--icon-font-size, var(--icon-height));
color: var(--ak-global--Color--100);
padding: var(--icon-border);
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
line-height: 1;
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
}

View File

@@ -1,102 +1,64 @@
import { PFSize } from "#common/enums";
import Styles from "#elements/AppIcon.css";
import { AKElement } from "#elements/Base";
import { match, P } from "ts-pattern";
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { msg, str } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFFAIcons from "@patternfly/patternfly/base/patternfly-fa-icons.css";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
export interface IAppIcon {
name?: string;
icon?: string;
size?: PFSize;
name?: string | null;
icon?: string | null;
size?: PFSize | null;
}
@customElement("ak-app-icon")
export class AppIcon extends AKElement implements IAppIcon {
@property({ type: String })
name?: string;
public static readonly FontAwesomeProtocol = "fa://";
static styles: CSSResult[] = [PFFAIcons, Styles];
@property({ type: String })
icon?: string;
public name: string | null = null;
@property({ type: String })
public icon: string | null = null;
@property({ reflect: true })
size: PFSize = PFSize.Medium;
static styles: CSSResult[] = [
PFFAIcons,
PFAvatar,
css`
:host {
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
display: flex;
place-content: center;
}
:host([size="pf-m-lg"]) {
--icon-height: 4rem;
--icon-border: 0.25rem;
}
:host([size="pf-m-md"]) {
--icon-height: 2rem;
--icon-border: 0.125rem;
}
:host([size="pf-m-sm"]) {
--icon-height: 1rem;
--icon-border: 0.125rem;
}
:host([size="pf-m-xl"]) {
--icon-height: 6rem;
--icon-border: 0.25rem;
}
.pf-c-avatar {
--pf-c-avatar--BorderRadius: 0;
--pf-c-avatar--Height: calc(
var(--icon-height) + var(--icon-border) + var(--icon-border)
);
--pf-c-avatar--Width: calc(
var(--icon-height) + var(--icon-border) + var(--icon-border)
);
}
.icon {
--app-icon-shadow-blend-color: color-mix(
in srgb,
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150))
100%,
black 100%
);
font-size: var(--icon-font-size, var(--icon-height));
color: var(--ak-global--Color--100);
padding: var(--icon-border);
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
line-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
}
div {
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
}
`,
];
public size: PFSize = PFSize.Medium;
render(): TemplateResult {
// prettier-ignore
return match([this.name, this.icon])
.with([P.nullish, P.nullish],
() => html`<div><i part="icon" aria-hidden="true" class="icon fas fa-question-circle"></i></div>`)
.with([P._, P.string.startsWith("fa://")],
([_name, icon]) => html`<div><i part="icon" aria-hidden="true" class="icon fas ${icon.replaceAll("fa://", "")}"></i></div>`)
.with([P._, P.string],
([_name, icon]) => html`<img part="icon" aria-hidden="true" class="icon pf-c-avatar" src="${icon}" alt="${msg("Application Icon")}" />`)
.with([P.string, P.nullish],
([name]) => html`<span part="icon" aria-hidden="true" class="icon">${name.charAt(0).toUpperCase()}</span>`)
.exhaustive();
const applicationName = this.name ?? msg("Application");
const label = msg(str`${applicationName} Icon`);
if (this.icon?.startsWith(AppIcon.FontAwesomeProtocol)) {
return html`<i
part="icon font-awesome"
role="img"
aria-label=${label}
class="icon fas ${this.icon.slice(AppIcon.FontAwesomeProtocol.length)}"
></i>`;
}
const insignia = this.name?.charAt(0).toUpperCase() ?? "<22>";
if (this.icon) {
return html`<img
part="icon image"
role="img"
aria-label=${label}
class="icon"
src=${this.icon}
alt=${insignia}
/>`;
}
return html`<span part="icon insignia" role="img" aria-label=${label} class="icon"
>${insignia}</span
>`;
}
}

View File

@@ -1,4 +1,4 @@
import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "#common/constants";
import { CURRENT_CLASS, EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base";
import { getURLParams, updateURLParams } from "#elements/router/RouteMatch";
@@ -7,8 +7,7 @@ import { isFocusable } from "#elements/utils/focus";
import { msg } from "@lit/localize";
import { css, CSSResult, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
@@ -20,18 +19,6 @@ export class Tabs extends AKElement {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
#focusTargetRef = createRef<HTMLSlotElement>();
@property()
pageIdentifier = "page";
@property()
currentPage?: string;
@property({ type: Boolean })
vertical = false;
static styles: CSSResult[] = [
PFGlobal,
PFTabs,
@@ -55,41 +42,106 @@ export class Tabs extends AKElement {
`,
];
observer: MutationObserver;
@property({ type: String })
public pageIdentifier = "page";
constructor() {
super();
this.observer = new MutationObserver(() => {
this.requestUpdate();
});
@property({ type: Boolean, useDefault: true })
public vertical = false;
@state()
protected activeTabName: string | null = null;
@state()
protected tabs: ReadonlyMap<string, Element> = new Map();
#focusTargetRef = createRef<HTMLSlotElement>();
#observer: MutationObserver | null = null;
#updateTabs = (): void => {
this.tabs = new Map(
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
return [element.getAttribute("slot") || "", element];
}),
);
};
public override connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(this.#updateTabs);
this.addEventListener("focus", this.#delegateFocusListener);
if (!this.activeTabName) {
const params = getURLParams();
const tabParam = params[this.pageIdentifier];
if (
tabParam &&
typeof tabParam === "string" &&
this.querySelector(`[slot='${tabParam}']`)
) {
this.activeTabName = tabParam;
} else {
this.#updateTabs();
this.activeTabName = this.tabs.keys().next().value || null;
}
}
}
connectedCallback(): void {
super.connectedCallback();
this.observer.observe(this, {
public override firstUpdated(): void {
this.#observer?.observe(this, {
attributes: true,
childList: true,
subtree: true,
});
this.addEventListener("focus", this.#delegateFocusListener);
this.dispatchActivateEvent();
}
disconnectedCallback(): void {
this.observer.disconnect();
public override disconnectedCallback(): void {
this.#observer?.disconnect();
super.disconnectedCallback();
}
onClick(slot?: string): void {
this.currentPage = slot;
const params: { [key: string]: string | undefined } = {};
params[this.pageIdentifier] = slot;
updateURLParams(params);
const page = this.querySelector(`[slot='${this.currentPage}']`);
if (!page) return;
public findActiveTabPanel(): Element | null {
return this.querySelector(`[slot='${this.activeTabName}']`);
}
page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
page.dispatchEvent(new CustomEvent("activate"));
public activateTab(nextTabName: string): void {
if (!nextTabName) {
console.warn("Cannot activate falsey tab name:", nextTabName);
return;
}
if (!this.tabs.has(nextTabName)) {
console.warn("Cannot activate unknown tab name:", nextTabName, this.tabs);
return;
}
const firstTab = this.tabs.keys().next().value || null;
// We avoid adding the tab parameter to the URL if it's the first tab
// to both reduce URL length and ensure that tests do not have to deal with
// unnecessary URL parameters.
updateURLParams({
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
});
this.activeTabName = nextTabName;
this.dispatchActivateEvent();
}
public dispatchActivateEvent(tabPanel = this.findActiveTabPanel()): void {
if (!tabPanel) {
console.warn("Cannot dispatch activate event, no tab panel found");
return;
}
tabPanel.dispatchEvent(new CustomEvent(EVENT_REFRESH));
tabPanel.dispatchEvent(new CustomEvent("activate"));
}
#delegateFocusListener = (event: FocusEvent) => {
@@ -103,47 +155,35 @@ export class Tabs extends AKElement {
// We don't want to refocus if the user is tabbing between elements inside the tabpanel.
if (focusableElement && event.relatedTarget !== focusableElement) {
focusableElement.focus();
focusableElement.focus({
preventScroll: true,
});
}
};
renderTab(page: Element): TemplateResult {
const slot = page.attributes.getNamedItem("slot")?.value;
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
renderTab(slotName: string, tabPanel: Element): TemplateResult {
return html` <li
class="pf-c-tabs__item ${slotName === this.activeTabName ? CURRENT_CLASS : ""}"
>
<button
type="button"
role="tab"
id=${`${slot}-tab`}
aria-selected=${slot === this.currentPage ? "true" : "false"}
aria-controls=${ifPresent(slot)}
id=${`${slotName}-tab`}
aria-selected=${slotName === this.activeTabName ? "true" : "false"}
aria-controls=${ifPresent(slotName)}
class="pf-c-tabs__link"
@click=${() => this.onClick(slot)}
@click=${() => this.activateTab(slotName)}
>
<span class="pf-c-tabs__item-text"> ${page.getAttribute("aria-label")}</span>
<span class="pf-c-tabs__item-text"> ${tabPanel.getAttribute("aria-label")}</span>
</button>
</li>`;
}
render(): TemplateResult {
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const params = getURLParams();
if (
this.pageIdentifier in params &&
!this.currentPage &&
this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
) {
// To update the URL to match with the current slot
this.onClick(params[this.pageIdentifier] as string);
}
}
if (!this.currentPage) {
if (pages.length < 1) {
return html`<h1>${msg("no tabs defined")}</h1>`;
}
const wantedPage = pages[0].attributes.getNamedItem("slot")?.value;
this.onClick(wantedPage);
if (!this.tabs.size) {
return html`<h1>${msg("no tabs defined")}</h1>`;
}
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
<ul
class="pf-c-tabs__list"
@@ -151,11 +191,13 @@ export class Tabs extends AKElement {
aria-orientation=${this.vertical ? "vertical" : "horizontal"}
aria-label=${ifPresent(this.ariaLabel)}
>
${pages.map((page) => this.renderTab(page))}
${Array.from(this.tabs, ([slotName, tabPanel]) =>
this.renderTab(slotName, tabPanel),
)}
</ul>
</div>
<slot name="header"></slot>
<slot ${ref(this.#focusTargetRef)} name="${ifDefined(this.currentPage)}"></slot>`;
<slot ${ref(this.#focusTargetRef)} name=${ifPresent(this.activeTabName)}></slot>`;
}
}

View File

@@ -8,7 +8,6 @@ import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "#common/constants";
import { AKElement } from "#elements/Base";
import { customEvent } from "#elements/utils/customEvents";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
@@ -26,6 +25,10 @@ import { customElement, property } from "lit/decorators.js";
*/
@customElement("ak-locale-context")
export class LocaleContext extends WithBrandConfig(AKElement) {
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
/// @attribute The text representation of the current locale */
@property({ attribute: true, type: String })
locale = DEFAULT_LOCALE;
@@ -90,10 +93,6 @@ export class LocaleContext extends WithBrandConfig(AKElement) {
// works just fine for almost every use case.
this.dispatchEvent(customEvent(EVENT_LOCALE_CHANGE));
}
render() {
return html`<slot></slot>`;
}
}
export default LocaleContext;

View File

@@ -1,6 +1,7 @@
import { ROUTE_SEPARATOR } from "#common/constants";
import { Route } from "#elements/router/Route";
import { RouteParameterRecord } from "#elements/router/shared";
import { TemplateResult } from "lit";
@@ -49,10 +50,10 @@ export function getURLParam<T>(key: string, fallback: T): T {
return fallback;
}
export function getURLParams(): { [key: string]: unknown } {
export function getURLParams(): RouteParameterRecord {
const params = {};
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const urlParts = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR, 2);
const urlParts = window.location.hash.slice(1).split(ROUTE_SEPARATOR, 2);
const rawParams = decodeURIComponent(urlParts[1]);
try {
return JSON.parse(rawParams);
@@ -63,21 +64,43 @@ export function getURLParams(): { [key: string]: unknown } {
return params;
}
export function setURLParams(params: { [key: string]: unknown }, replace = true): void {
const paramsString = JSON.stringify(params);
const currentUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
const newUrl = `#${currentUrl};${encodeURIComponent(paramsString)}`;
/**
* Serialize route parameters to a JSON string, removing empty values.
*
* @param params The route parameters to serialize.
*/
export function prepareURLParams(params: RouteParameterRecord): RouteParameterRecord {
const preparedParams: RouteParameterRecord = {};
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== "") {
preparedParams[key] = value;
}
}
return preparedParams;
}
export function serializeURLParams(params: RouteParameterRecord): string {
const preparedParams = prepareURLParams(params);
return Object.keys(preparedParams).length === 0 ? "" : JSON.stringify(preparedParams);
}
export function setURLParams(params: RouteParameterRecord, replace = true): void {
const [currentHash] = window.location.hash.slice(1).split(ROUTE_SEPARATOR);
let nextHash = "#" + currentHash;
const preparedParams = prepareURLParams(params);
if (Object.keys(preparedParams).length) {
nextHash += ROUTE_SEPARATOR + encodeURIComponent(JSON.stringify(preparedParams));
}
if (replace) {
history.replaceState(undefined, "", newUrl);
history.replaceState(undefined, "", nextHash);
} else {
history.pushState(undefined, "", newUrl);
history.pushState(undefined, "", nextHash);
}
}
export function updateURLParams(params: { [key: string]: unknown }, replace = true): void {
const currentParams = getURLParams();
for (const key in params) {
currentParams[key] = params[key] as string;
}
setURLParams(currentParams, replace);
export function updateURLParams(params: RouteParameterRecord, replace = true): void {
setURLParams({ ...getURLParams(), ...params }, replace);
}

View File

@@ -0,0 +1,6 @@
/**
* @file Common types for routing.
*/
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };

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