Compare commits

...

41 Commits

Author SHA1 Message Date
e3f2ed0436 web: Prep for hash-less routing. 2025-04-03 15:19:19 +02:00
a5bb22a66a web: Move build observer. Prep. 2025-04-03 06:12:51 +02:00
ec49b2e0e0 website/integrations: calibre-web: document (#12477)
* website/integrations: calibre-web: add to sidebar

Adds the calibre-web integration to the sidebar.

Signed-off-by: 4d62 <github-user@sdko.org>

* website/integrations: calibre-web: init

Initializes the documentation with the placeholder. I have a feeling this is going to be funnnnnnnnnnnnnnnnn

Signed-off-by: 4d62 <github-user@sdko.org>

* website/integrations: calibre-web: service configuration

Adds configuration documentation for calibre-web

PS: Never setup a LDAP outpost before and I don't have calibre web so uhhh yea im gonna take care of this after the holidays (probably)

Signed-off-by: 4d62 <github-user@sdko.org>

* Update index.md

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

* Changed proider pair instructions to new version

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

* Update website/integrations/services/calibre-web/index.md

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

---------

Signed-off-by: 4d62 <github-user@sdko.org>
Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-04-02 12:57:53 -05:00
22ebe05706 website: bump image-size from 1.1.1 to 1.2.1 in /website (#13750)
Bumps [image-size](https://github.com/image-size/image-size) from 1.1.1 to 1.2.1.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/compare/v1.1.1...v1.2.1)

---
updated-dependencies:
- dependency-name: image-size
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:41:59 +02:00
f0e58a6f49 website/docs: sys-mgmt: service accounts (#13722)
* website/docs: ops: service accounts

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Update website/docs/sys-mgmt/service-accounts.md

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

* Dewi's suggestions

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-01 16:37:11 -05:00
Ben
a3d642c08e website/integrations: add mailcow (#13727)
* Add mailcow to Applications

* Update wording and layout

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

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Ben <bmfk_m@yahoo.de>

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

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Ben <bmfk_m@yahoo.de>

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

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Ben <bmfk_m@yahoo.de>

* lint

---------

Signed-off-by: Ben <bmfk_m@yahoo.de>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-01 15:09:31 -05:00
5d42cb9185 website: edit menu items (#13747)
for review

Co-authored-by: Tana M Berry <tana@goauthentik.com>
2025-04-01 15:00:18 -05:00
1fd0cc5bb5 website/integrations: slack,pocketbase,tandoor: convert to new authentik configuration format (#13742)
* website/integrations-all: update authentik configuration template

* website/integrations: slack,pocketbase,tandoor: convert to new authentik configuration format

* Revert "website/integrations-all: update authentik configuration template"

Not for this PR. Don't want to cause merge conflicts later on.

This reverts commit 8378502090.
2025-04-01 13:31:07 -05:00
deef365ff5 website/integrations-all: update authentik configuration template (#13740) 2025-04-01 11:51:31 -05:00
d1ae6287f2 web/admin: fix custom scope mappings being selected by default in proxy provider (#13735)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-04-01 18:35:35 +02:00
2e152cd264 web: bump vite from 5.4.15 to 5.4.16 in /web (#13743)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.15 to 5.4.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:29:16 +02:00
f5941e403b translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#13736)
Translate locale/en/LC_MESSAGES/django.po in zh_CN

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

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-01 18:18:59 +02:00
ff3cf8c10e core: bump goauthentik.io/api/v3 from 3.2025023.1 to 3.2025023.2 (#13746)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025023.1 to 3.2025023.2.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025023.1...v3.2025023.2)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025023.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:18:42 +02:00
bfa6328172 web/common: utils: fix infinite value handling in getRelativeTime function (#13564)
Squash sdko/closes-13562
2025-04-01 06:46:29 -07:00
4c9691c932 stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#13744)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-01 12:58:43 +02:00
a0f1566b4c web: bump API Client version (#13741)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-01 02:15:47 +02:00
46261a4f42 */saml: allow for domainless SAML URLs (#13737) 2025-04-01 01:41:18 +02:00
8b42ff1e97 core: fix error when viewing used_by for built-in source (#13588)
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-03-31 16:36:14 +00:00
ca4cb0d251 translate: Updates for file locale/en/LC_MESSAGES/django.po in fr (#13738)
* Translate locale/en/LC_MESSAGES/django.po in fr

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

* Translate locale/en/LC_MESSAGES/django.po in fr

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

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-03-31 15:54:37 +00:00
a5a0fa79dd website/docs: style guide (#13704)
* new word choices, tweaks

* shockingly, a typo

* tweaks

* Update website/docs/developer-docs/docs/style-guide.mdx

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

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.com>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-03-31 07:57:03 -05:00
c06a871f61 core: fix double slash in cache key (#13721) 2025-03-31 12:58:03 +02:00
4a3df67134 core, web: update translations (#13728)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-03-31 12:57:16 +02:00
422ccf61fa core: bump goauthentik.io/api/v3 from 3.2025022.6 to 3.2025023.1 (#13729)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025022.6 to 3.2025023.1.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025022.6...v3.2025023.1)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 12:27:56 +02:00
d989f23907 website: bump the build group in /website with 3 updates (#13730)
Bumps the build group in /website with 3 updates: [@rspack/binding-darwin-arm64](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack), [@rspack/binding-linux-arm64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) and [@rspack/binding-linux-x64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack).


Updates `@rspack/binding-darwin-arm64` from 1.2.8 to 1.3.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.0/packages/rspack)

Updates `@rspack/binding-linux-arm64-gnu` from 1.2.8 to 1.3.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.0/packages/rspack)

Updates `@rspack/binding-linux-x64-gnu` from 1.2.8 to 1.3.0
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.0/packages/rspack)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 12:27:44 +02:00
059180edef core: bump astral-sh/uv from 0.6.10 to 0.6.11 (#13733)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.6.10 to 0.6.11.
- [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.6.10...0.6.11)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  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-03-31 12:27:18 +02:00
22f30634a8 website/docs: Fix Caddy forward auth example (#13726) 2025-03-30 20:28:11 +02:00
35ff418c42 policies: buffered policy access view for concurrent authorization attempts when unauthenticated (#13629)
* policies: buffered policy access view for concurrent authorization attempts when unauthenticated

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

* better cleanup

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

* more polish

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

* more cleanup

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

* add tests

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

* fix multiple redirects, add e2e test

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

* unrelated: add sp initiated post test

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

* add SAML parallel test

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

* format

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

* optimise detection of when authentication is in progress

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

* better backoff timing

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-30 19:56:03 +02:00
7826e7a605 core: bump oss/go/microsoft/golang from 1.23-fips-bookworm to 1.24-fips-bookworm (#13027)
* core: bump oss/go/microsoft/golang

Bumps oss/go/microsoft/golang from 1.23-fips-bookworm to 1.24-fips-bookworm.

---
updated-dependencies:
- dependency-name: oss/go/microsoft/golang
  dependency-type: direct:production
...

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

* upstream docker image, use native fips

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

* bump go version

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-03-30 03:26:30 +02:00
64f1b8207d web: bump tar-fs from 2.1.1 to 2.1.2 in /web (#13713)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.1 to 2.1.2.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v2.1.2)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 00:51:08 +01:00
b2c13f0614 core: fix flaky tests introduced with is_superuser API fix (#13709)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 22:14:15 +01:00
6965628020 root: bump python patch version to 3.12.9 (#13710)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 22:13:34 +01:00
608f63e9a2 website/docs: add reference to setting in CVE (#13707)
* website/docs: add reference to setting in CVE

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

* reword

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 17:42:45 +01:00
22fa3a7fba web: bump API Client version (#13708)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-03-28 17:42:24 +01:00
bcfd6fefa7 release: 2025.2.3 (#13705)
* release: 2025.2.3

* fix uv lock not being bumped

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 17:08:57 +01:00
eae18d0016 website/docs: fix 2025 CVE category title (#13703)
* website/docs: fix 2025 CVE category title

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

* add sideeffect of changing session backend

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 14:55:56 +01:00
4a12a57c5f website/docs: update release notes for 2024.12 and 2025.2 (#13702)
* website/docs: update release notes for 2025.2 and 2024.12

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

* update

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

* update v2

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

* format

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 14:49:35 +01:00
71294b7deb security: fix CVE-2025-29928 (#13695)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 14:20:09 +01:00
5af907db0c stages/identification: refresh captcha on failure (#13697)
* refactor cleanup behavior after stage form submit

* refresh captcha on failing Identification stage

* Revert "stages/identification: check captcha after checking authentication (#13533)"

This reverts commit b7beac6795.

Including a Captcha stage in an Identification stage is partially to
prevent password spraying attacks. The reverted commit negated this
feature to fix a UX bug. After 6fde42a9170, the functionality can now be
reinstated.

---------

Co-authored-by: Simonyi Gergő <gergo@goauthentik.io>
2025-03-28 14:16:13 +01:00
63a118a2ba core: fix non-exploitable open redirect (#13696)
discovered by @dominic-r

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 14:15:39 +01:00
d9a3c34a44 core: fix core/user is_superuser filter (#13693)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-28 14:00:13 +01:00
23bdad7574 website: bump @types/semver from 7.5.8 to 7.7.0 in /website (#13682)
Bumps [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver) from 7.5.8 to 7.7.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

---
updated-dependencies:
- dependency-name: "@types/semver"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 13:21:30 +01:00
207 changed files with 2812 additions and 1099 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2025.2.2
current_version = 2025.2.3
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,6 +17,8 @@ optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml]

View File

@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
ARG TARGETOS
ARG TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
@ -94,9 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
FROM ghcr.io/astral-sh/uv:0.6.11 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2025.2.2"
__version__ = "2025.2.3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -46,7 +46,7 @@ LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
key = f"{CACHE_PREFIX}app_access/{user_pk}"
if page_number:
key += f"/{page_number}"
return key

View File

@ -1,13 +1,14 @@
"""User API Views"""
from datetime import timedelta
from importlib import import_module
from json import loads
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.contrib.sessions.backends.base import SessionBase
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic
from django.db.utils import IntegrityError
@ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer):
@ -373,7 +375,7 @@ class UsersFilter(FilterSet):
method="filter_attributes",
)
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
@ -391,6 +393,11 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"),
)
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
@ -769,7 +776,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
for session in session_ids:
SessionStore(session).delete()
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

@ -761,11 +761,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property
def component(self) -> str:
"""Return component used to edit this object"""
if self.managed == self.MANAGED_INBUILT:
return ""
raise NotImplementedError
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
return PropertyMapping
raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
@ -780,10 +786,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def __str__(self):

View File

@ -1,7 +1,10 @@
"""authentik core signals"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.base import SessionBase
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
@ -25,6 +28,7 @@ password_changed = Signal()
login_failed = Signal()
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application)
@ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
@receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted"""
cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
SessionStore(instance.session_key).delete()
@receiver(pre_save)

View File

@ -36,6 +36,7 @@ from authentik.flows.planner import (
)
from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
from authentik.lib.utils.urls import is_url_absolute
from authentik.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_values
@ -209,6 +210,8 @@ class SourceFlowManager:
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
flow_context.update(
{
# Since we authenticate the user by their token, they have no backend set

View File

@ -0,0 +1,19 @@
from django.apps import apps
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
class TestSourceAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_builtin_source_used_by(self):
"""Test Providers's types endpoint"""
apps.get_app_config("authentik_core").source_inbuilt()
response = self.client.get(
reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}),
)
self.assertEqual(response.status_code, 200)

View File

@ -1,6 +1,7 @@
"""Test Users API"""
from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
@ -15,7 +16,12 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.core.tests.utils import (
create_test_admin_user,
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
@ -26,7 +32,7 @@ class TestUsersAPI(APITestCase):
def setUp(self) -> None:
self.admin = create_test_admin_user()
self.user = User.objects.create(username="test-user")
self.user = create_test_user()
def test_filter_type(self):
"""Test API filtering by type"""
@ -41,6 +47,35 @@ class TestUsersAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
def test_filter_is_superuser(self):
"""Test API filtering by superuser status"""
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
# Test superuser
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": True,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["username"], admin.username)
# Test non-superuser
user = create_test_user()
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": False,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1, body)
self.assertEqual(body["results"][0]["username"], user.username)
def test_list_with_groups(self):
"""Test listing with groups"""
self.client.force_login(self.admin)
@ -99,6 +134,8 @@ class TestUsersAPI(APITestCase):
def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
)

View File

@ -69,6 +69,7 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query"
@ -453,6 +454,7 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests
# SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might

View File

@ -6,14 +6,22 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["flow"] = flow
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -18,6 +18,15 @@ class SerializerModel(models.Model):
@property
def serializer(self) -> type[BaseSerializer]:
"""Get serializer for this model"""
# Special handling for built-in source
if (
hasattr(self, "managed")
and hasattr(self, "MANAGED_INBUILT")
and self.managed == self.MANAGED_INBUILT
):
from authentik.core.api.sources import SourceSerializer
return SourceSerializer
raise NotImplementedError

View File

@ -35,3 +35,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@ -0,0 +1,89 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,121 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,7 +1,14 @@
"""API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [
("policies/all", PolicyViewSet),

View File

@ -1,23 +1,37 @@
"""authentik access helper classes"""
from typing import Any
from uuid import uuid4
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views.generic.base import View
from django.views.generic.base import TemplateView, View
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException):
@ -125,3 +139,65 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages:
messages.error(self.request, _(message))
return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest
from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -328,7 +328,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(PolicyAccessView):
class AuthorizationFlowInitView(BufferedPolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams

View File

@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.views import PolicyAccessView
from authentik.policies.views import BufferedPolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
class RACStartView(PolicyAccessView):
class RACStartView(BufferedPolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.13 on 2025-03-31 13:50
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"),
]
operations = [
migrations.AlterField(
model_name="samlprovider",
name="acs_url",
field=models.TextField(
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="ACS URL",
),
),
]

View File

@ -10,6 +10,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import CreatableType
from authentik.core.models import PropertyMapping, Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import (
DSA_SHA1,
@ -40,7 +41,9 @@ class SAMLBindings(models.TextChoices):
class SAMLProvider(Provider):
"""SAML 2.0 Endpoint for applications which support SAML."""
acs_url = models.URLField(verbose_name=_("ACS URL"))
acs_url = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
)
audience = models.TextField(
default="",
blank=True,

View File

@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import PolicyAccessView
from authentik.policies.views import BufferedPolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(PolicyAccessView):
class SAMLSSOView(BufferedPolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(PolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because PolicyAccessView's dispatch"""
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
return self.get(request, application_slug)

View File

@ -0,0 +1,35 @@
# Generated by Django 5.0.13 on 2025-03-31 13:53
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0017_fix_x509subjectname"),
]
operations = [
migrations.AlterField(
model_name="samlsource",
name="slo_url",
field=models.TextField(
blank=True,
default=None,
help_text="Optional URL if your IDP supports Single-Logout.",
null=True,
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SLO URL",
),
),
migrations.AlterField(
model_name="samlsource",
name="sso_url",
field=models.TextField(
help_text="URL that the initial Login request is sent to.",
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SSO URL",
),
),
]

View File

@ -20,6 +20,7 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.flows.challenge import RedirectChallenge
from authentik.flows.models import Flow
from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import (
DSA_SHA1,
@ -91,11 +92,13 @@ class SAMLSource(Source):
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
)
sso_url = models.URLField(
sso_url = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
verbose_name=_("SSO URL"),
help_text=_("URL that the initial Login request is sent to."),
)
slo_url = models.URLField(
slo_url = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
default=None,
blank=True,
null=True,

View File

@ -33,6 +33,7 @@ from authentik.flows.planner import (
)
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import is_url_absolute
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import nice64
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
@ -73,6 +74,8 @@ class InitiateView(View):
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
kwargs.update(
{
PLAN_CONTEXT_SSO: True,

File diff suppressed because one or more lines are too long

View File

@ -142,35 +142,38 @@ class IdentificationChallengeResponse(ChallengeResponse):
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user
# Password check
if current_stage.password_stage:
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
# Captcha check
if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None)
if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt")
verify_captcha_token(captcha_stage, captcha_token, client_ip)
# Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
return attrs

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.2.2 Blueprint schema",
"title": "authentik 2025.2.3 Blueprint schema",
"required": [
"version",
"entries"
@ -6423,8 +6423,6 @@
},
"acs_url": {
"type": "string",
"format": "uri",
"maxLength": 200,
"minLength": 1,
"title": "ACS URL"
},
@ -8733,8 +8731,6 @@
},
"sso_url": {
"type": "string",
"format": "uri",
"maxLength": 200,
"minLength": 1,
"title": "SSO URL",
"description": "URL that the initial Login request is sent to."
@ -8744,8 +8740,6 @@
"string",
"null"
],
"format": "uri",
"maxLength": 200,
"title": "SLO URL",
"description": "Optional URL if your IDP supports Single-Logout."
},

View File

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

7
go.mod
View File

@ -1,9 +1,6 @@
module goauthentik.io
go 1.23.0
toolchain go1.24.0
go 1.24.0
require (
beryju.io/ldap v0.1.0
github.com/coreos/go-oidc/v3 v3.13.0
@ -29,7 +26,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025022.6
goauthentik.io/api/v3 v3.2025023.2
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.12.0

4
go.sum
View File

@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg=
goauthentik.io/api/v3 v3.2025022.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025023.2 h1:4XHlnykN5jQH78liQ4cp2Jf8eigvQImIJp+A+bsq1nA=
goauthentik.io/api/v3 v3.2025023.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

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

View File

@ -1,5 +0,0 @@
//go:build requirefips
package backend
var FipsEnabled = true

View File

@ -1,5 +0,0 @@
//go:build !requirefips
package backend
var FipsEnabled = false

View File

@ -2,6 +2,7 @@ package ak
import (
"context"
"crypto/fips140"
"fmt"
"math/rand"
"net/http"
@ -203,7 +204,7 @@ func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
"golangVersion": runtime.Version(),
"opensslEnabled": cryptobackend.OpensslEnabled,
"opensslVersion": cryptobackend.OpensslVersion(),
"fipsEnabled": cryptobackend.FipsEnabled,
"fipsEnabled": fips140.Enabled(),
}
hostname, err := os.Hostname()
if err == nil {

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/ldap ./cmd/ldap
# Stage 2: Run

View File

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

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -1220,6 +1220,20 @@ msgstr ""
msgid "Reputation Scores"
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html
msgid "Permission denied"
msgstr ""

View File

@ -10,8 +10,8 @@
# Manuel Viens, 2023
# Mordecai, 2023
# nerdinator <florian.dupret@gmail.com>, 2024
# Tina, 2024
# Charles Leclerc, 2025
# Tina, 2025
# Marc Schmitt, 2025
#
#, fuzzy
@ -19,7 +19,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: Marc Schmitt, 2025\n"
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
@ -1347,6 +1347,22 @@ msgstr "Score de Réputation"
msgid "Reputation Scores"
msgstr "Scores de Réputation"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr "En attente de l'authentification..."
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
"Vous êtes déjà en cours d'authentification dans un autre onglet. Cette page "
"se rafraîchira lorsque l'authentification sera terminée."
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr "S'authentifier dans cet onglet"
#: authentik/policies/templates/policies/denied.html
msgid "Permission denied"
msgstr "Permission refusée"

View File

@ -14,7 +14,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-22 00:10+0000\n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: deluxghost, 2025\n"
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
@ -1234,6 +1234,20 @@ msgstr "信誉分数"
msgid "Reputation Scores"
msgstr "信誉分数"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr "正在等待身份验证…"
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr "您正在另一个标签页中验证身份。身份验证完成后,此页面会刷新。"
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr "在此标签页中验证身份"
#: authentik/policies/templates/policies/denied.html
msgid "Permission denied"
msgstr "权限被拒绝"

View File

@ -1,5 +1,5 @@
{
"name": "@goauthentik/authentik",
"version": "2025.2.2",
"version": "2025.2.3",
"private": true
}

View File

@ -17,7 +17,7 @@ COPY web .
RUN npm run build-proxy
# Stage 2: Build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
@ -43,7 +43,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/proxy ./cmd/proxy
# Stage 3: Run

View File

@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.2.2"
version = "2025.2.3"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.12.*"

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/rac ./cmd/rac
# Stage 2: Run

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/radius ./cmd/radius
# Stage 2: Run

View File

@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.2.2
version: 2025.2.3
description: Making authentication simple.
contact:
email: hello@goauthentik.io
@ -52245,9 +52245,8 @@ components:
format: uuid
acs_url:
type: string
format: uri
minLength: 1
maxLength: 200
format: uri
audience:
type: string
description: Value of the audience restriction field of the assertion. When
@ -52404,16 +52403,14 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL.
sso_url:
type: string
format: uri
minLength: 1
description: URL that the initial Login request is sent to.
maxLength: 200
format: uri
slo_url:
type: string
format: uri
nullable: true
description: Optional URL if your IDP supports Single-Logout.
maxLength: 200
format: uri
allow_idp_initiated:
type: boolean
description: Allows authentication flows initiated by the IdP. This can
@ -55214,7 +55211,6 @@ components:
acs_url:
type: string
format: uri
maxLength: 200
audience:
type: string
description: Value of the audience restriction field of the assertion. When
@ -55381,9 +55377,8 @@ components:
format: uuid
acs_url:
type: string
format: uri
minLength: 1
maxLength: 200
format: uri
audience:
type: string
description: Value of the audience restriction field of the assertion. When
@ -55556,15 +55551,13 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL.
sso_url:
type: string
format: uri
description: URL that the initial Login request is sent to.
maxLength: 200
format: uri
slo_url:
type: string
format: uri
nullable: true
description: Optional URL if your IDP supports Single-Logout.
maxLength: 200
format: uri
allow_idp_initiated:
type: boolean
description: Allows authentication flows initiated by the IdP. This can
@ -55747,16 +55740,14 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL.
sso_url:
type: string
format: uri
minLength: 1
description: URL that the initial Login request is sent to.
maxLength: 200
format: uri
slo_url:
type: string
format: uri
nullable: true
description: Optional URL if your IDP supports Single-Logout.
maxLength: 200
format: uri
allow_idp_initiated:
type: boolean
description: Allows authentication flows initiated by the IdP. This can

View File

@ -410,3 +410,77 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied_parallel(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id,
client_secret=self.client_secret,
signing_key=create_test_cert(),
redirect_uris=[
RedirectURI(
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
)
],
authorization_flow=authorization_flow,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
)
)
Application.objects.create(
name=generate_id(),
slug=self.app_slug,
provider=provider,
)
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
grafana_window = self.driver.current_window_handle
self.driver.get("http://localhost:3000")
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(grafana_window)
self.wait_for_url("http://localhost:3000/?orgId=1")
self.driver.get("http://localhost:3000/profile")
self.assertEqual(
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"),
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"),
self.user.email,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"),
self.user.email,
)

View File

@ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry
class TestProviderSAML(SeleniumTestCase):
"""test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False):
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs):
"""Setup client saml-sp container which we test SAML against"""
metadata_url = (
self.url(
@ -40,6 +40,7 @@ class TestProviderSAML(SeleniumTestCase):
"SP_ENTITY_ID": provider.issuer,
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"SP_METADATA_URL": metadata_url,
**kwargs,
},
)
@ -111,6 +112,74 @@ class TestProviderSAML(SeleniumTestCase):
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url="http://localhost:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True)
self.driver.get("http://localhost:9009")
self.login()
self.wait_for_url("http://localhost:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -450,3 +519,81 @@ class TestProviderSAML(SeleniumTestCase):
lambda driver: driver.current_url.startswith(should_url),
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post_buffer(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url=f"http://{self.host}:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009")
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
client_window = self.driver.current_window_handle
# We need to access the SP on the same host as the IdP for SameSite cookies
self.driver.get(f"http://{self.host}:9009")
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(client_window)
self.wait_for_url(f"http://{self.host}:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)

2
uv.lock generated
View File

@ -162,7 +162,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.2.2"
version = "2025.2.3"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },

114
web/package-lock.json generated
View File

@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.2-1742585853",
"@goauthentik/api": "^2025.2.3-1743464496",
"@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
@ -1835,9 +1835,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2025.2.2-1742585853",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.2.2-1742585853.tgz",
"integrity": "sha512-bg/816ljAuUixLxi8tZd3W7sEcHgG5aYl0IMkbTsFYOAuiOdl/5wqSWaVM8g8O9SQ9feP3v6xDLOGncMoJxh4g=="
"version": "2025.2.3-1743464496",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.2.3-1743464496.tgz",
"integrity": "sha512-35+SqFNoBZ+WNpyG2Xv/VKYKIIxjwRmIbgX5WZSpc9IlJVv7yyckUYvLpU2F0hZVUMDnxAUE5bsiNn7K4EQslw=="
},
"node_modules/@goauthentik/web": {
"resolved": "",
@ -8815,50 +8815,80 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/bare-events": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz",
"integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==",
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
"dev": true,
"license": "Apache-2.0",
"optional": true
},
"node_modules/bare-fs": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
"integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
"integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-events": "^2.0.0",
"bare-path": "^2.0.0",
"bare-stream": "^2.0.0"
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
"integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
"dev": true,
"optional": true
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
"integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-os": "^2.1.0"
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
"integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"b4a": "^1.6.6",
"streamx": "^2.20.0"
"streamx": "^2.21.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/base64-arraybuffer": {
@ -20170,9 +20200,10 @@
}
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
"integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
@ -22754,13 +22785,13 @@
}
},
"node_modules/streamx": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
"integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-fifo": "^1.3.2",
"queue-tick": "^1.0.1",
"text-decoder": "^1.1.0"
},
"optionalDependencies": {
@ -23215,17 +23246,18 @@
}
},
"node_modules/tar-fs": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
"integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^2.1.1",
"bare-path": "^2.1.0"
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
@ -24760,9 +24792,9 @@
}
},
"node_modules/vite": {
"version": "5.4.15",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -12,7 +12,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.2-1742585853",
"@goauthentik/api": "^2025.2.3-1743464496",
"@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",

View File

@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
@ -37,10 +37,10 @@ import "./AdminSidebar";
@customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface {
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
@property({ type: Boolean })
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
ws: WebsocketClient;
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
patchRouteParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
patchRouteParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
super.connectedCallback();
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
const { ESBuildObserver } = await import("src/development/build-observer");
new ESBuildObserver(process.env.WATCHER_URL);
}
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/administration/overview"
defaultURL="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>

View File

@ -6,7 +6,7 @@ import {
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers";
@ -95,62 +95,127 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
}
renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [
path: string | null,
/**
* The pathname to match against. If null, this is a parent item.
*/
pathname: string | null,
/**
* The label to display in the sidebar.
*/
label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
/**
* The attributes to apply to the sidebar item. This is a map of attribute name to value.
*
* The second attribute type is of string[] to help with the 'activeWhen' control,
* which was commonplace and singular enough to merit its own handler.
*/
attributes?: Record<string, unknown> | string[] | null,
/**
* The children of this sidebar item. This is a recursive structure.
*/
children?: SidebarEntry[],
];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[null, msg("Customization"), null, [
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")]]],
// ---
[
null,
msg("Dashboards"),
{ "?expanded": true },
[
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")],
],
],
[
null,
msg("Applications"),
null,
[
[
"/core/applications",
msg("Applications"),
[`/core/applications/:slug(${SLUG_PATTERN})`],
],
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
["/outpost/outposts", msg("Outposts")],
],
],
[
null,
msg("Events"),
null,
[
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")],
],
],
[
null,
msg("Customization"),
null,
[
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")],
],
],
[
null,
msg("Flows and Stages"),
null,
[
["/flow/flows", msg("Flows"), [`/flow/flows/:slug(${SLUG_PATTERN})`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")],
],
],
[
null,
msg("Directory"),
null,
[
["/identity/users", msg("Users"), [`/identity/users/:id(${ID_PATTERN})`]],
["/identity/groups", msg("Groups"), [`/identity/groups/:id(${UUID_PATTERN})`]],
["/identity/roles", msg("Roles"), [`/identity/roles/:id(${UUID_PATTERN})`]],
[
"/core/sources",
msg("Federation and Social login"),
[`/core/sources/:slug(${SLUG_PATTERN})`],
],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")],
],
],
[
null,
msg("System"),
null,
[
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")],
],
],
];
// Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: (attributes ?? {});
if (path) {
properties.path = path;
if (pathname) {
properties.pathname = pathname;
}
return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)}

View File

@ -1,155 +1,210 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { Route } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
import { html } from "lit";
export const ROUTES: Route[] = [
interface IDParameters {
id: string;
}
interface SlugParameters {
slug: string;
}
interface UUIDParameters {
uuid: string;
}
export const ROUTES = [
// Prevent infinite Shell loops
new Route(new RegExp("^/$")).redirect("/administration/overview"),
new Route(new RegExp("^#.*")).redirect("/administration/overview"),
new Route(new RegExp("^/library$")).redirect("/if/user/", true),
Route.redirect("^/$", "/administration/overview"),
Route.redirect("^#.*", "/administration/overview"),
Route.redirect("^/library$", "/if/user/", true),
// statically imported since this is the default route
new Route(new RegExp("^/administration/overview$"), async () => {
new Route("/administration/overview", () => {
return html`<ak-admin-overview></ak-admin-overview>`;
}),
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
new Route("/administration/dashboard/users", async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
}),
new Route(new RegExp("^/administration/system-tasks$"), async () => {
new Route("/administration/system-tasks", async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`;
}),
new Route(new RegExp("^/core/providers$"), async () => {
new Route("/core/providers", async () => {
await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`;
}),
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
}),
new Route(new RegExp("^/core/applications$"), async () => {
new Route<IDParameters>(
new URLPattern({
pathname: `/core/providers/:id(${ID_PATTERN})`,
}),
async (params) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view
.providerID=${parseInt(params.id, 10)}
></ak-provider-view>`;
},
),
new Route("/core/applications", async () => {
await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`;
}),
new Route(new RegExp(`^/core/applications/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route(`/core/applications/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
}),
new Route(new RegExp("^/core/sources$"), async () => {
new Route("/core/sources", async () => {
await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`;
}),
new Route(new RegExp(`^/core/sources/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route(`/core/sources/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
}),
new Route(new RegExp("^/core/property-mappings$"), async () => {
new Route("/core/property-mappings", async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`;
}),
new Route(new RegExp("^/core/tokens$"), async () => {
new Route("/core/tokens", async () => {
await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`;
}),
new Route(new RegExp("^/core/brands"), async () => {
new Route("/core/brands", async () => {
await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`;
}),
new Route(new RegExp("^/policy/policies$"), async () => {
new Route("/policy/policies", async () => {
await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`;
}),
new Route(new RegExp("^/policy/reputation$"), async () => {
new Route("/policy/reputation", async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
}),
new Route(new RegExp("^/identity/groups$"), async () => {
new Route("/identity/groups", async () => {
await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`;
}),
new Route(new RegExp(`^/identity/groups/(?<uuid>${UUID_REGEX})$`), async (args) => {
new Route<UUIDParameters>(`/identity/groups/:uuid(${UUID_PATTERN})`, async ({ uuid }) => {
await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
}),
new Route(new RegExp("^/identity/users$"), async () => {
new Route("/identity/users", async () => {
await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`;
}),
new Route(new RegExp(`^/identity/users/(?<id>${ID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/identity/users/:id(${ID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
}),
new Route(new RegExp("^/identity/roles$"), async () => {
new Route("/identity/roles", async () => {
await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`;
}),
new Route(new RegExp(`^/identity/roles/(?<id>${UUID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/identity/roles/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
return html`<ak-role-view roleId=${id}></ak-role-view>`;
}),
new Route(new RegExp("^/flow/stages/invitations$"), async () => {
new Route("/flow/stages/invitations", async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
}),
new Route(new RegExp("^/flow/stages/prompts$"), async () => {
new Route("/flow/stages/prompts", async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
}),
new Route(new RegExp("^/flow/stages$"), async () => {
new Route("/flow/stages", async () => {
await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`;
}),
new Route(new RegExp("^/flow/flows$"), async () => {
new Route("/flow/flows", async () => {
await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`;
}),
new Route(new RegExp(`^/flow/flows/(?<slug>${SLUG_REGEX})$`), async (args) => {
new Route<SlugParameters>(`/flow/flows/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
}),
new Route(new RegExp("^/events/log$"), async () => {
new Route("/events/log", async () => {
await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`;
}),
new Route(new RegExp(`^/events/log/(?<id>${UUID_REGEX})$`), async (args) => {
new Route<IDParameters>(`/events/log/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
}),
new Route(new RegExp("^/events/transports$"), async () => {
new Route("/events/transports", async () => {
await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`;
}),
new Route(new RegExp("^/events/rules$"), async () => {
new Route("/events/rules", async () => {
await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`;
}),
new Route(new RegExp("^/outpost/outposts$"), async () => {
new Route("/outpost/outposts", async () => {
await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
}),
new Route(new RegExp("^/outpost/integrations$"), async () => {
new Route("/outpost/integrations", async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
}),
new Route(new RegExp("^/crypto/certificates$"), async () => {
new Route("/crypto/certificates", async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}),
new Route(new RegExp("^/admin/settings$"), async () => {
new Route("/admin/settings", async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`;
}),
new Route(new RegExp("^/blueprints/instances$"), async () => {
new Route("/blueprints/instances", async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
new Route(new RegExp("^/debug$"), async () => {
new Route("/debug", async () => {
await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`;
}),
new Route(new RegExp("^/enterprise/licenses$"), async () => {
new Route("/enterprise/licenses", async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}),
];
] satisfies Route<never>[];

View File

@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { formatRouteHash } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -79,10 +79,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
}
quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
];
@ -195,10 +198,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
];

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
${msg("Create with Provider")}
</button>
</ak-application-wizard>
<ak-forms-modal .open=${getURLParam("createForm", false)}>
<ak-forms-modal .open=${getRouteParameter("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Application")} </span>
<ak-application-form slot="form"> </ak-application-form>

View File

@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Label";
import "@goauthentik/elements/buttons/ActionButton/ak-action-button";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { css, html } from "lit";
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p>
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"

View File

@ -1,7 +1,6 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
@ -11,6 +10,7 @@ import { type NavigableButton, type WizardButton } from "@goauthentik/components
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router";
import { msg } from "@lit/localize";
import { html } from "lit";

View File

@ -1,7 +1,7 @@
import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/flows/FlowImportForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ConfirmationForm";
@ -107,10 +107,9 @@ export class FlowListPage extends TablePage<Flow> {
<button
class="pf-c-button pf-m-plain"
@click=${() => {
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
const url = formatFlowURL(item);
window.open(url, "_blank");
}}
>
<pf-tooltip position="top" content=${msg("Execute")}>

View File

@ -1,10 +1,10 @@
import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram";
import "@goauthentik/admin/flows/FlowForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { DesignationToLabel, applyNextParam, formatFlowURL } from "@goauthentik/admin/flows/utils";
import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -151,12 +151,9 @@ export class FlowViewPage extends AKElement {
<button
class="pf-c-button pf-m-block pf-m-primary"
@click=${() => {
const finalURL = `${
window.location.origin
}/if/flow/${this.flow.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
const url = formatFlowURL(this.flow);
window.open(url, "_blank");
}}
>
${msg("Normal")}
@ -168,12 +165,16 @@ export class FlowViewPage extends AKElement {
.flowsInstancesExecuteRetrieve({
slug: this.flow.slug,
})
.then((link) => {
const finalURL = `${
link.link
}${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
.then(({ link }) => {
const finalURL = URL.canParse(link)
? new URL(link)
: new URL(
link,
window.location.origin,
);
applyNextParam(finalURL);
window.open(finalURL, "_blank");
});
}}

View File

@ -43,3 +43,51 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
return msg("Unknown layout");
}
}
/**
* Applies the next URL as a query parameter to the given URL or URLSearchParams object.
*
* @todo deprecate this once hash routing is removed.
*/
export function applyNextParam(
target: URL | URLSearchParams,
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): void {
const searchParams = target instanceof URL ? target.searchParams : target;
searchParams.set("next", destination.toString());
}
/**
* Creates a URLSearchParams object with the next URL as a query parameter.
*
* @todo deprecate this once hash routing is removed.
*/
export function createNextSearchParams(
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): URLSearchParams {
const searchParams = new URLSearchParams();
applyNextParam(searchParams, destination);
return searchParams;
}
/**
* Creates a URL to a flow, with the next URL as a query parameter.
*
* @param flow The flow to create the URL for.
* @param destination The next URL to redirect to after the flow is completed, `true` to use the current route.
*/
export function formatFlowURL(
flow: Flow,
destination: string | URL | null = window.location.pathname + "#" + window.location.hash,
): URL {
const url = new URL(`/if/flow/${flow.slug}/`, window.location.origin);
if (destination) {
applyNextParam(url, destination);
}
return url;
}

View File

@ -22,7 +22,7 @@ import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { UserOption } from "@goauthentik/elements/user/utils";
@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
order = "last_login";
@property({ type: Boolean })
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
@state()
me?: SessionUser;
@ -466,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideServiceAccounts: this.hideServiceAccounts,
});
}}

View File

@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/forms/ProxyForm";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
order = "name";
@state()
hideManaged = getURLParam<boolean>("hideManaged", true);
hideManaged = getRouteParameter<boolean>("hideManaged", true);
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideManaged: this.hideManaged,
});
}}

View File

@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
@ -22,11 +21,7 @@ export async function propertyMappingsProvider(page = 1, search = "") {
export function propertyMappingsSelector(instanceMappings?: string[]) {
if (!instanceMappings) {
return async (mappings: DualSelectPair<ScopeMapping>[]) =>
mappings.filter(
([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
!(scope?.managed ?? "").startsWith("goauthentik.io/providers"),
);
return async () => [];
}
return async () => {

View File

@ -3,7 +3,6 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { convertToSlug } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog";
import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md";
@ -21,7 +20,8 @@ import "@goauthentik/elements/ak-mdx";
import type { Replacer } from "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { formatAsSlug } from "@goauthentik/elements/router";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
(input: string): string => {
// The generated config is pretty unreliable currently so
// put it behind a flag
if (!getURLParam("generatedConfig", false)) {
if (!getRouteParameter("generatedConfig", false)) {
return input;
}
if (!this.provider) {
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
return html`<ak-tabs pageIdentifier="proxy-setup">
${servers.map((server) => {
return html`<section
slot="page-${convertToSlug(server.label)}"
slot="page-${formatAsSlug(server.label)}"
data-tab-title="${server.label}"
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
>

View File

@ -9,7 +9,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { createUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
@ -24,7 +24,7 @@ import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -117,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
activePath;
@state()
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
@state()
userPaths?: UserPath;
@ -131,8 +131,10 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
constructor() {
super();
const defaultPath = new DefaultUIConfig().defaults.userPath;
this.activePath = getURLParam<string>("path", defaultPath);
const defaultPath = createUIConfig().defaults.userPath;
this.activePath = getRouteParameter("path", defaultPath);
uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath;
@ -143,7 +145,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()),
pathStartswith: getURLParam("path", ""),
pathStartswith: getRouteParameter("path", ""),
isActive: this.hideDeactivated ? true : undefined,
includeGroups: false,
});
@ -225,7 +227,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
updateURLParams({
patchRouteParams({
hideDeactivated: this.hideDeactivated,
});
}}

View File

@ -79,11 +79,4 @@ export const DEFAULT_CONFIG = new Configuration({
],
});
// This is just a function so eslint doesn't complain about
// missing-whitespace-between-attributes or
// unexpected-character-in-attribute-name
export function AndNext(url: string): string {
return `?next=${encodeURIComponent(url)}`;
}
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

@ -1,5 +1,5 @@
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
import { getCookie } from "@goauthentik/common/utils";
import { getCookie } from "@goauthentik/common/http";
import {
CurrentBrand,

View File

@ -1,170 +1,60 @@
/**
* @file
* Client-side observer for ESBuild events.
* @file Client-side utilities.
*/
import type { Message as ESBuildMessage } from "esbuild";
import { TITLE_DEFAULT } from "@goauthentik/common/constants";
import { isAdminRoute } from "@goauthentik/elements/router";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
import { msg } from "@lit/localize";
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
import type { CurrentBrand } from "@goauthentik/api";
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
/**
* A client-side watcher for ESBuild.
* Create a title for the page.
*
* Note that this should be conditionally imported in your code, so that
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/client");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* }
* ```
}
* @param brand - The brand object to append to the title.
* @param segments - The segments to prepend to the title.
*/
export class ESBuildObserver extends EventSource {
/**
* Whether the watcher has a recent connection to the server.
*/
alive = true;
export function formatPageTitle(
brand: BrandTitleLike | undefined,
...segments: Array<string | undefined>
): string;
/**
* Create a title for the page.
*
* @param segments - The segments to prepend to the title.
*/
export function formatPageTitle(...segments: Array<string | undefined>): string;
/**
* Create a title for the page.
*
* @param args - The segments to prepend to the title.
* @param args - The brand object to append to the title.
*/
export function formatPageTitle(
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
): string {
const segments: string[] = [];
/**
* The number of errors that have occurred since the watcher started.
*/
errorCount = 0;
/**
* Whether a reload has been requested while offline.
*/
deferredReload = false;
/**
* The last time a message was received from the server.
*/
lastUpdatedAt = Date.now();
/**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
log("🛎️ Build completed! Reloading...");
// We use an animation frame to keep the reload from happening before the
// event loop has a chance to process the message.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
if (isAdminRoute()) {
segments.push(msg("Admin"));
}
const [arg1, ...rest] = args;
if (typeof arg1 === "object") {
const { brandingTitle = TITLE_DEFAULT } = arg1;
segments.push(brandingTitle);
} else {
segments.push(TITLE_DEFAULT);
}
for (const segment of rest) {
if (segment) {
segments.push(segment);
}
}
return segments.join(" - ");
}

View File

@ -3,9 +3,8 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.2";
export const VERSION = "2025.2.3";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";

28
web/src/common/http.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* @file HTTP utilities.
*/
/**
* Get the value of a cookie by its name.
*
* @param cookieName - The name of the cookie to retrieve.
* @returns The value of the cookie, or an empty string if the cookie is not found.
*/
export function getCookie(cookieName: string): string {
if (!cookieName) return "";
if (typeof document === "undefined") return "";
if (typeof document.cookie !== "string") return "";
if (!document.cookie) return "";
const search = cookieName + "=";
// Split the cookie string into individual name=value pairs...
const keyValPairs = document.cookie.split(";").map((cookie) => cookie.trim());
for (const pair of keyValPairs) {
if (!pair.startsWith(search)) continue;
return decodeURIComponent(pair.substring(search.length));
}
return "";
}

View File

@ -2,6 +2,7 @@ import { config } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
import {
ErrorEvent,
EventHint,
@ -64,7 +65,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
});
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
}
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
const Spotlight = await import("@spotlightjs/spotlight");
@ -82,13 +83,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
}
return cfg;
}
// Get the interface name from URL
export function currentInterface(): string {
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
let currentInterface = "unknown";
if (pathMatches && pathMatches.length >= 2) {
currentInterface = pathMatches[1];
}
return currentInterface.toLowerCase();
}

View File

@ -1,7 +1,7 @@
import { currentInterface } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
import { isUserRoute } from "@goauthentik/elements/router";
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
import { UiThemeEnum } from "@goauthentik/api";
export enum UserDisplay {
username = "username",
@ -18,15 +18,27 @@ export enum LayoutType {
export interface UIConfig {
enabledFeatures: {
// API Request drawer in navbar
/**
* Whether to show the API request drawer in the navbar.
*/
apiDrawer: boolean;
// Notification drawer in navbar
/**
* Whether to show the notification drawer in the navbar.
*/
notificationDrawer: boolean;
// Settings in user dropdown
/**
* Whether to show the settings in the user dropdown.
*/
settings: boolean;
// Application edit in library (only shown when user is superuser)
/**
* Whether to show the application edit button in the library.
*
* This is only shown when the user is a superuser.
*/
applicationEdit: boolean;
// Search bar
/**
* Whether to show the search bar.
*/
search: boolean;
};
navbar: {
@ -38,68 +50,77 @@ export interface UIConfig {
cardBackground: string;
};
pagination: {
/**
* Number of items to show per page in paginated lists.
*/
perPage: number;
};
layout: {
/**
* Layout type to use for the application.
*/
type: LayoutType;
};
/**
* Locale to use for the application.
*/
locale: string;
/**
* Default values.
*/
defaults: {
/**
* Default path to use for user API calls.
*/
userPath: string;
};
}
export class DefaultUIConfig implements UIConfig {
enabledFeatures = {
apiDrawer: true,
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
};
layout = {
type: LayoutType.row,
};
navbar = {
userDisplay: UserDisplay.username,
};
theme = {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
};
pagination = {
perPage: 20,
};
locale = "";
defaults = {
userPath: "users",
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
const uiConfig: UIConfig = {
enabledFeatures: {
// TODO: Is the intent that only user routes should have the API drawer disabled,
// or only admin routes?
apiDrawer: !isUserRoute(),
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
},
layout: {
type: LayoutType.row,
},
navbar: {
userDisplay: UserDisplay.username,
},
theme: {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
},
pagination: {
perPage: 20,
},
locale: "",
defaults: {
userPath: "users",
},
};
constructor() {
if (currentInterface() === "user") {
this.enabledFeatures.apiDrawer = false;
}
}
// TODO: Should we deep merge the overrides instead of shallow?
Object.assign(uiConfig, overrides);
return uiConfig;
}
let globalUiConfig: Promise<UIConfig>;
export function getConfigForUser(user: UserSelf): UIConfig {
const settings = user.settings;
let config = new DefaultUIConfig();
if (!settings) {
return config;
}
config = Object.assign(new DefaultUIConfig(), settings);
return config;
}
let cachedUIConfig: UIConfig | null = null;
export function uiConfig(): Promise<UIConfig> {
if (!globalUiConfig) {
globalUiConfig = me().then((user) => {
return getConfigForUser(user.user);
});
}
return globalUiConfig;
if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
return me().then((session) => {
cachedUIConfig = createUIConfig(session.user.settings);
return cachedUIConfig;
});
}

View File

@ -2,35 +2,6 @@ import { SentryIgnoredError } from "@goauthentik/common/errors";
import { CSSResult, css } from "lit";
export function getCookie(name: string): string {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export function convertToSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
export function isSlug(text: string): boolean {
const lowered = text.toLowerCase();
const forbidden = /([^\w-]|\s)/.test(lowered);
return lowered === text && !forbidden;
}
/**
* Truncate a string based on maximum word count
*/
@ -63,17 +34,29 @@ export function snakeToCamel(key: string) {
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>();
objects.forEach((obj) => {
const group = callback(obj);
if (!m.has(group)) {
m.set(group, []);
}
const tProviders = m.get(group) || [];
tProviders.push(obj);
});
return Array.from(m).sort();
}
/**
* Returns the first non-null and non-undefined argument.
*
* @deprecated Use nullish coalescing operator (??) instead.
* @remarks
*
* This needs a deeper look. Some instances of this function use `new Date()`
* which may cause issues during rendering.
*/
export function first<T>(...args: Array<T | undefined | null>): T {
for (let index = 0; index < args.length; index++) {
const element = args[index];
@ -157,23 +140,26 @@ export function adaptCSS(sheet: AdaptableStylesheet | AdaptableStylesheet[]): Ad
return Array.isArray(sheet) ? sheet.map(_adaptCSS) : _adaptCSS(sheet);
}
const _timeUnits = new Map<Intl.RelativeTimeFormatUnit, number>([
["year", 24 * 60 * 60 * 1000 * 365],
["month", (24 * 60 * 60 * 1000 * 365) / 12],
["day", 24 * 60 * 60 * 1000],
["hour", 60 * 60 * 1000],
["minute", 60 * 1000],
["second", 1000],
]);
export function getRelativeTime(d1: Date, d2: Date = new Date()): string {
const rtf = new Intl.RelativeTimeFormat("default", { numeric: "auto" });
const elapsed = d1.getTime() - d2.getTime();
const rtf = new Intl.RelativeTimeFormat("default", { numeric: "auto" });
const _timeUnits: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 1000 * 60 * 60 * 24 * 365],
["month", (24 * 60 * 60 * 1000 * 365) / 12],
["day", 1000 * 60 * 60 * 24],
["hour", 1000 * 60 * 60],
["minute", 1000 * 60],
["second", 1000],
];
// "Math.abs" accounts for both "past" & "future" scenarios
for (const [key, value] of _timeUnits) {
if (Math.abs(elapsed) > value || key == "second") {
return rtf.format(Math.round(elapsed / value), key);
if (Math.abs(elapsed) > value || key === "second") {
let rounded = Math.round(elapsed / value);
if (!isFinite(rounded)) {
rounded = 0;
}
return rtf.format(rounded, key);
}
}
return rtf.format(Math.round(elapsed / 1000), "second");

View File

@ -1,4 +1,4 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { formatAsSlug } from "@goauthentik/elements/router";
import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@ -34,7 +34,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// Do not stop propagation of this event; it must be sent up the tree so that a parent
// component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) {
this.input.value = convertToSlug(this.input.value);
this.input.value = formatAsSlug(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") {
@ -67,7 +67,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration."
const newSlug = convertToSlug(ev.target.value);
const newSlug = formatAsSlug(ev.target.value);
const oldSlug = this.input.value;
const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];

View File

@ -0,0 +1,169 @@
/**
* @file
* Client-side observer for ESBuild events.
*/
import type { Message as ESBuildMessage } from "esbuild";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
/**
* A client-side watcher for ESBuild.
*
* Note that this should be conditionally imported in your code, so that
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/development/build-observer");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* }
* ```
*
*/
export class ESBuildObserver extends EventSource {
/**
* Whether the watcher has a recent connection to the server.
*/
alive = true;
/**
* The number of errors that have occurred since the watcher started.
*/
errorCount = 0;
/**
* Whether a reload has been requested while offline.
*/
deferredReload = false;
/**
* The last time a message was received from the server.
*/
lastUpdatedAt = Date.now();
/**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
log("🛎️ Build completed! Reloading...");
// We use an animation frame to keep the reload from happening before the
// event loop has a chance to process the message.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
}
}

View File

@ -1,10 +1,6 @@
import {
EVENT_SIDEBAR_TOGGLE,
EVENT_WS_MESSAGE,
TITLE_DEFAULT,
} from "@goauthentik/common/constants";
import { formatPageTitle } from "@goauthentik/common/client";
import { EVENT_SIDEBAR_TOGGLE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { currentInterface } from "@goauthentik/common/sentry";
import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users";
import "@goauthentik/components/ak-nav-buttons";
@ -125,17 +121,8 @@ export class PageHeader extends WithBrandConfig(AKElement) {
this.uiConfig.navbar.userDisplay = UserDisplay.none;
}
setTitle(header?: string) {
const currentIf = currentInterface();
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
if (currentIf === "admin") {
title = `${msg("Admin")} - ${title}`;
}
// Prepend the header to the title
if (header !== undefined && header !== "") {
title = `${header} - ${title}`;
}
document.title = title;
setTitle(pageTitle?: string) {
document.title = formatPageTitle(this.brand, pageTitle);
}
willUpdate() {

View File

@ -1,6 +1,7 @@
import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { CURRENT_CLASS, EVENT_REFRESH } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { getURLParams, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router";
import { getRouteParams, patchRouteParams } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
@ -10,6 +11,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
import PFGlobal from "@patternfly/patternfly/patternfly-base.css";
const SLOT_PREFIX = "page-";
@customElement("ak-tabs")
export class Tabs extends AKElement {
@property()
@ -18,6 +21,14 @@ export class Tabs extends AKElement {
@property()
currentPage?: string;
get currentPageParamName(): string | null {
if (!this.currentPage) return null;
return this.currentPage.startsWith(SLOT_PREFIX)
? this.currentPage.slice(SLOT_PREFIX.length)
: this.currentPage;
}
@property({ type: Boolean })
vertical = false;
@ -68,13 +79,30 @@ export class Tabs extends AKElement {
super.disconnectedCallback();
}
onClick(slot?: string): void {
this.currentPage = slot;
const params: { [key: string]: string | undefined } = {};
params[this.pageIdentifier] = slot;
updateURLParams(params);
/**
* Sync route params with the current page.
*
* @todo This should be moved to a router component.
*/
#syncRouteParams(): void {
const { currentPageParamName } = this;
if (!currentPageParamName) return;
patchRouteParams({
[this.pageIdentifier]: currentPageParamName,
});
}
activatePage(nextPage?: string): void {
this.currentPage = nextPage;
this.#syncRouteParams();
const page = this.querySelector(`[slot='${this.currentPage}']`);
if (!page) return;
page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
page.dispatchEvent(new CustomEvent("activate"));
}
@ -82,7 +110,7 @@ export class Tabs extends AKElement {
renderTab(page: Element): TemplateResult {
const slot = page.attributes.getNamedItem("slot")?.value;
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
<button class="pf-c-tabs__link" @click=${() => this.onClick(slot)}>
<button class="pf-c-tabs__link" @click=${() => this.activatePage(slot)}>
<span class="pf-c-tabs__item-text"> ${page.getAttribute("data-tab-title")} </span>
</button>
</li>`;
@ -90,24 +118,41 @@ export class Tabs extends AKElement {
render(): TemplateResult {
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const params = getURLParams();
const params = getRouteParams();
const slotName = params[this.pageIdentifier];
if (
this.pageIdentifier in params &&
slotName &&
typeof slotName === "string" &&
!this.currentPage &&
this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
this.querySelector(`[slot='${slotName}']`) !== null
) {
// To update the URL to match with the current slot
this.onClick(params[this.pageIdentifier] as string);
console.debug(
`authentik/tabs (${this.pageIdentifier}): setting current page to`,
slotName,
);
this.activatePage(slotName);
}
}
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);
console.debug(
`authentik/tabs (${this.pageIdentifier}): setting current page to`,
wantedPage,
);
this.activatePage(wantedPage);
}
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
<ul class="pf-c-tabs__list">
${pages.map((page) => this.renderTab(page))}

View File

@ -1,6 +1,6 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { setURLParams } from "@goauthentik/elements/router/RouteMatch";
import { setRouteParams } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
@ -84,7 +84,7 @@ export class TreeViewNode extends AKElement {
if (this.host) {
this.host.activeNode = this;
}
setURLParams({ path: this.fullPath });
setRouteParams({ path: this.fullPath });
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,

View File

@ -1,11 +1,13 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors";
import { MessageLevel } from "@goauthentik/common/messages";
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
import { dateToUTC } from "@goauthentik/common/utils";
import { camelToSnake } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatAsSlug } from "@goauthentik/elements/router/slugs";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
@ -223,11 +225,11 @@ export abstract class Form<T> extends AKElement {
// Only attach handler if the slug is already equal to the name
// if not, they are probably completely different and shouldn't update
// each other
if (convertToSlug(input.value) !== slugField.value) {
if (formatAsSlug(input.value) !== slugField.value) {
return;
}
nameInput.addEventListener("input", () => {
slugField.value = convertToSlug(input.value);
slugField.value = formatAsSlug(input.value);
});
});
}

View File

@ -1,6 +1,6 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
import { formatAsSlug } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize";
import { CSSResult, css } from "lit";
@ -123,7 +123,7 @@ export class HorizontalFormElement extends AKElement {
if (this.name === "slug" || this.slugMode) {
this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => {
input.addEventListener("keyup", () => {
input.value = convertToSlug(input.value);
input.value = formatAsSlug(input.value);
});
});
}

View File

@ -1,66 +1,60 @@
import "@goauthentik/elements/EmptyState";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { until } from "lit/directives/until.js";
export const SLUG_REGEX = "[-a-zA-Z0-9_]+";
export const ID_REGEX = "\\d+";
export const UUID_REGEX = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };
export interface RouteArgs {
[key: string]: string;
}
export type RouteCallback<P = unknown> = (
params: P,
) => SlottedTemplateResult | Promise<SlottedTemplateResult>;
export class Route {
url: RegExp;
export type RouteInitTuple = [string | RegExp, RouteCallback | undefined];
private element?: TemplateResult;
private callback?: (args: RouteArgs) => Promise<TemplateResult>;
export class Route<P = unknown> {
public readonly pattern: URLPattern;
constructor(url: RegExp, callback?: (args: RouteArgs) => Promise<TemplateResult>) {
this.url = url;
this.callback = callback;
#callback: RouteCallback<P>;
constructor(patternInit: URLPatternInit | string, callback: RouteCallback<P>) {
this.pattern = new URLPattern(
typeof patternInit === "string"
? {
pathname: patternInit,
}
: patternInit,
);
this.#callback = callback;
}
redirect(to: string, raw = false): Route {
this.callback = async () => {
/**
* Create a new redirect route.
*
* @param patternInit The pattern to match.
* @param to The URL to redirect to.
* @param raw Whether to use the raw URL or not.
*/
static redirect(patternInit: URLPatternInit | string, to: string, raw = false): Route<unknown> {
return new Route(patternInit, () => {
console.debug(`authentik/router: redirecting ${to}`);
if (!raw) {
window.location.hash = `#${to}`;
} else {
window.location.hash = to;
}
return html``;
};
return this;
return nothing;
});
}
then(render: (args: RouteArgs) => TemplateResult): Route {
this.callback = async (args) => {
return render(args);
};
return this;
}
thenAsync(render: (args: RouteArgs) => Promise<TemplateResult>): Route {
this.callback = render;
return this;
}
render(args: RouteArgs): TemplateResult {
if (this.callback) {
return html`${until(
this.callback(args),
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
)}`;
}
if (this.element) {
return this.element;
}
throw new Error("Route does not have callback or element");
}
toString(): string {
return `<Route url=${this.url} callback=${this.callback ? "true" : "false"}>`;
render(params: P): TemplateResult {
return html`${until(
this.#callback(params),
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
)}`;
}
}

View File

@ -1,66 +0,0 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { Route } from "@goauthentik/elements/router/Route";
import { TemplateResult } from "lit";
export class RouteMatch {
route: Route;
arguments: { [key: string]: string };
fullUrl?: string;
constructor(route: Route) {
this.route = route;
this.arguments = {};
}
render(): TemplateResult {
return this.route.render(this.arguments);
}
toString(): string {
return `<RouteMatch url=${this.fullUrl} route=${this.route} arguments=${JSON.stringify(
this.arguments,
)}>`;
}
}
export function getURLParam<T>(key: string, fallback: T): T {
const params = getURLParams();
if (key in params) {
return params[key] as T;
}
return fallback;
}
export function getURLParams(): { [key: string]: unknown } {
const params = {};
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const urlParts = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR, 2);
const rawParams = decodeURIComponent(urlParts[1]);
try {
return JSON.parse(rawParams);
} catch {
return params;
}
}
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)}`;
if (replace) {
history.replaceState(undefined, "", newUrl);
} else {
history.pushState(undefined, "", newUrl);
}
}
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);
}

View File

@ -11,7 +11,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-router-404")
export class Router404 extends AKElement {
@property()
url = "";
pathname = "";
static get styles(): CSSResult[] {
return [PFBase, PFEmptyState, PFTitle];
@ -23,7 +23,7 @@ export class Router404 extends AKElement {
<i class="fas fa-question-circle pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${msg("Not found")}</h1>
<div class="pf-c-empty-state__body">
${msg(str`The URL "${this.url}" was not found.`)}
${msg(str`The URL "${this.pathname}" was not found.`)}
</div>
<a href="#/" class="pf-c-button pf-m-primary" type="button"
>${msg("Return home")}</a

View File

@ -1,57 +1,47 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { Route } from "@goauthentik/elements/router/Route";
import { RouteMatch } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/Router404";
import { matchRoute, pluckRoute } from "@goauthentik/elements/router/utils";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
// Poliyfill for hashchange.newURL,
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onhashchange
window.addEventListener("load", () => {
if (!window.HashChangeEvent)
(function () {
let lastURL = document.URL;
window.addEventListener("hashchange", function (event) {
Object.defineProperty(event, "oldURL", {
enumerable: true,
configurable: true,
value: lastURL,
});
Object.defineProperty(event, "newURL", {
enumerable: true,
configurable: true,
value: document.URL,
});
lastURL = document.URL;
});
})();
});
if (window.HashChangeEvent) return;
export function paramURL(url: string, params?: { [key: string]: unknown }): string {
let finalUrl = "#";
finalUrl += url;
if (params) {
finalUrl += ";";
finalUrl += encodeURIComponent(JSON.stringify(params));
}
return finalUrl;
}
export function navigate(url: string, params?: { [key: string]: unknown }): void {
window.location.assign(paramURL(url, params));
}
console.debug("authentik/router: polyfilling hashchange event");
let lastURL = document.URL;
window.addEventListener("hashchange", function (event) {
Object.defineProperty(event, "oldURL", {
enumerable: true,
configurable: true,
value: lastURL,
});
Object.defineProperty(event, "newURL", {
enumerable: true,
configurable: true,
value: document.URL,
});
lastURL = document.URL;
});
});
@customElement("ak-router-outlet")
export class RouterOutlet extends AKElement {
@property({ attribute: false })
current?: RouteMatch;
@state()
private currentPathname: string | null = null;
@property()
defaultUrl?: string;
public defaultURL?: string;
@property({ attribute: false })
routes: Route[] = [];
public routes: Route[] = [];
static get styles(): CSSResult[] {
return [
@ -59,6 +49,7 @@ export class RouterOutlet extends AKElement {
:host {
background-color: transparent !important;
}
*:first-child {
flex-direction: column;
}
@ -66,56 +57,78 @@ export class RouterOutlet extends AKElement {
];
}
constructor() {
super();
window.addEventListener("hashchange", (ev: HashChangeEvent) => this.navigate(ev));
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("hashchange", this.#refreshLocation);
}
firstUpdated(): void {
this.navigate();
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("hashchange", this.#refreshLocation);
}
navigate(ev?: HashChangeEvent): void {
let activeUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
if (ev) {
// Check if we've actually changed paths
const oldPath = new URL(ev.oldURL).hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
if (oldPath === activeUrl) return;
}
if (activeUrl === "") {
activeUrl = this.defaultUrl || "/";
window.location.hash = `#${activeUrl}`;
console.debug(`authentik/router: defaulted URL to ${window.location.hash}`);
protected firstUpdated(): void {
const currentPathname = pluckRoute(window.location).pathname;
if (currentPathname) return;
console.debug("authentik/router: defaulted route to empty pathname");
this.#redirectToDefault();
}
#redirectToDefault(): void {
const nextPathname = this.defaultURL || "/";
window.location.hash = "#" + nextPathname;
}
#refreshLocation = (event: HashChangeEvent): void => {
console.debug("authentik/router: hashchange event", event);
const nextPathname = pluckRoute(event.newURL).pathname;
const previousPathname = pluckRoute(event.oldURL).pathname;
if (previousPathname === nextPathname) {
console.debug("authentik/router: hashchange event, but no change in path", event, {
currentPathname: nextPathname,
previousPathname,
});
return;
}
let matchedRoute: RouteMatch | null = null;
this.routes.some((route) => {
const match = route.url.exec(activeUrl);
if (match !== null) {
matchedRoute = new RouteMatch(route);
matchedRoute.arguments = match.groups || {};
matchedRoute.fullUrl = activeUrl;
console.debug("authentik/router: found match ", matchedRoute);
return true;
}
return false;
});
if (!matchedRoute) {
console.debug(`authentik/router: route "${activeUrl}" not defined`);
const route = new Route(RegExp(""), async () => {
return html`<div class="pf-c-page__main">
<ak-router-404 url=${activeUrl}></ak-router-404>
</div>`;
});
matchedRoute = new RouteMatch(route);
matchedRoute.arguments = route.url.exec(activeUrl)?.groups || {};
matchedRoute.fullUrl = activeUrl;
if (!nextPathname) {
console.debug(`authentik/router: defaulted route to ${nextPathname}`);
this.#redirectToDefault();
return;
}
this.current = matchedRoute;
}
this.currentPathname = nextPathname;
};
render(): TemplateResult | undefined {
return this.current?.render();
let currentPathname = this.currentPathname;
if (!currentPathname) {
currentPathname = pluckRoute(window.location).pathname;
}
const match = matchRoute(currentPathname, this.routes);
if (!match) {
return html`<div class="pf-c-page__main">
<ak-router-404 pathname=${currentPathname}></ak-router-404>
</div>`;
}
console.debug("authentik/router: found match", match);
const { parameters, route } = match;
return route.render(parameters);
}
}

View File

@ -0,0 +1,26 @@
/**
* @file Router constants.
*/
/**
* Route separator, used to separate the path from the mock query string.
*/
export const ROUTE_SEPARATOR = "?";
/**
* Slug pattern, matching alphanumeric characters, underscores, and hyphens.
*/
export const SLUG_PATTERN = "[a-zA-Z0-9_\\-]+";
/**
* Numeric ID pattern, typically used for database IDs.
*/
export const ID_PATTERN = "\\d+";
/**
* UUID v4 pattern
*
* @todo Enforcing this format on the front-end may be a bit too strict.
* We may want to allow other UUID formats, or move this to a validation step.
*/
export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";

View File

@ -0,0 +1,6 @@
export * from "./Route.js";
export * from "./constants.js";
export * from "./Router404.js";
export * from "./RouterOutlet.js";
export * from "./utils.js";
export * from "./slugs.js";

View File

@ -0,0 +1,23 @@
/**
* Given a string, return a URL-friendly slug.
*/
export function formatAsSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
/**
* Type guard to check if a given string is a valid URL slug, i.e.
* only containing alphanumeric characters, dashes, and underscores.
*/
export function isSlug(input: unknown): input is string {
if (typeof input !== "string") return false;
if (!input) return false;
const lowered = input.toLowerCase();
if (input !== lowered) return false;
return /([^\w-]|\s)/.test(lowered);
}

View File

@ -0,0 +1,254 @@
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router";
import type { Route, RouteParameterRecord } from "@goauthentik/elements/router/Route";
export interface RouteMatch<P extends RouteParameterRecord = RouteParameterRecord> {
readonly route: Route<P>;
readonly parameters: P;
readonly pathname: string;
}
/**
* Match a route against a pathname.
*/
export function matchRoute<P extends RouteParameterRecord>(
pathname: string,
routes: Route<P>[],
): RouteMatch<P> | null {
if (!pathname) return null;
for (const route of routes) {
const match = route.pattern.exec({ pathname });
if (!match) continue;
console.debug(
`authentik/router: matched route ${route.pattern} to ${pathname} with params`,
match.pathname.groups,
);
return {
route: route as Route<P>,
parameters: match.pathname.groups as P,
pathname,
};
}
console.debug(`authentik/router: no route matched ${pathname}`);
return null;
}
/**
* Navigate to a route.
*
* @param {string} pathname The pathname of the route.
* @param {RouteParameterRecord} params The parameters to serialize.
*/
export function navigate(pathname: string, params?: RouteParameterRecord): void {
window.location.assign(formatRouteHash(pathname, params));
}
/**
* Create a route hash from a pathname and parameters.
*
* @param {string} pathname The pathname of the route.
* @param {RouteParameterRecord} params The parameters to serialize.
* @returns {string} The formatted route hash, starting with `#`.
* @see {@linkcode navigate} to navigate to a route.
*/
export function formatRouteHash(pathname: string, params?: RouteParameterRecord): string {
const routePrefix = "#" + pathname;
if (!params) return routePrefix;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "boolean" && value) {
searchParams.set(key, "true");
continue;
}
if (typeof value === "undefined" || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
searchParams.append(key, item.toString());
}
continue;
}
searchParams.set(key, String(value));
}
return [routePrefix, searchParams.toString()].join(ROUTE_SEPARATOR);
}
/**
* Create a route to an interface by name, optionally with parameters.
*/
export function formatInterfaceRoute(
interfaceName: RouteInterfaceName,
pathname?: string,
params?: RouteParameterRecord,
): string {
const prefix = `/if/${interfaceName}/`;
if (!pathname) return prefix;
return prefix + formatRouteHash(pathname, params);
}
export interface SerializedRoute {
pathname: string;
serializedParameters?: string;
}
export function pluckRoute(source: Pick<URL, "hash"> | string = window.location): SerializedRoute {
source = typeof source === "string" ? new URL(source) : source;
const [pathname, serializedParameters] = source.hash.slice(1).split(ROUTE_SEPARATOR, 2);
return {
pathname,
serializedParameters,
};
}
/**
* Get a parameter from the current route.
*
* @template T - The type of the parameter.
* @param {string} paramName - The name of the parameter to retrieve.
* @param {T} fallback - The fallback value to return if the parameter is not found.
*/
export function getRouteParameter<T>(paramName: string, fallback: T): T {
const params = getRouteParams();
if (Object.hasOwn(params, paramName)) {
return params[paramName] as T;
}
return fallback;
}
/**
* Get the route parameters from the URL.
*
* @template T - The type of the route parameters.
*/
export function getRouteParams<T = RouteParameterRecord>(): T {
const { serializedParameters } = pluckRoute();
if (!serializedParameters) return {} as T;
let searchParams: URLSearchParams;
try {
searchParams = new URLSearchParams(serializedParameters);
} catch (_error) {
console.warn("Failed to parse URL parameters", serializedParameters);
return {} as T;
}
const decodedParameters: Record<string, unknown> = {};
for (const [key, value] of searchParams.entries()) {
if (value === "true" || value === "") {
decodedParameters[key] = true;
continue;
}
if (value === "false") {
decodedParameters[key] = false;
continue;
}
decodedParameters[key] = value;
}
return decodedParameters as T;
}
/**
* Set the route parameters in the URL.
*
* @param nextParams - The JSON-serializable parameters to set in the URL.
* @param replace - Whether to replace the current history entry or create a new one.
*/
export function setRouteParams(nextParams: RouteParameterRecord, replace = true): void {
const { pathname } = pluckRoute();
const nextHash = formatRouteHash(pathname, nextParams);
if (replace) {
history.replaceState(undefined, "", nextHash);
} else {
history.pushState(undefined, "", nextHash);
}
}
/**
* Patch the route parameters in the URL, retaining existing parameters not specified in the input.
*
* @param patchedParams - The parameters to patch in the URL.
* @param replace - Whether to replace the current history entry or create a new one.
*
* @todo Most instances of this should be URL search params, not hash params.
*/
export function patchRouteParams(patchedParams: RouteParameterRecord, replace = true): void {
const currentParams = getRouteParams();
const nextParams = { ...currentParams, ...patchedParams };
setRouteParams(nextParams, replace);
}
/**
* Type guard to check if a given input is parsable as a URL.
*
* ```js
* isURLInput("https://example.com") // true
* isURLInput("invalid-url") // false
* isURLInput(new URL("https://example.com")) // true
* ```
*/
export function isURLInput(input: unknown): input is string | URL {
if (typeof input !== "string" && !(input instanceof URL)) return false;
if (!input) return false;
return URL.canParse(input);
}
/**
* The name identifier for the current interface.
*/
export type RouteInterfaceName = "user" | "admin" | "flow" | "unknown";
/**
* Read the current interface route parameter from the URL.
*
* @param location - The location object to read the pathname from. Defaults to `window.location`.
* * @returns The name of the current interface, or "unknown" if not found.
*/
export function readInterfaceRouteParam(
location: Pick<URL, "pathname"> = window.location,
): RouteInterfaceName {
const [, currentInterface = "unknown"] = location.pathname.match(/.+if\/(\w+)\//) || [];
return currentInterface.toLowerCase() as RouteInterfaceName;
}
/**
* Predicate to determine if the current route is for the admin interface.
*/
export function isAdminRoute(location: Pick<URL, "pathname"> = window.location): boolean {
return readInterfaceRouteParam(location) === "admin";
}
/**
* Predicate to determine if the current route is for the user interface.
*/
export function isUserRoute(location: Pick<URL, "pathname"> = window.location): boolean {
return readInterfaceRouteParam(location) === "user";
}

View File

@ -1,5 +1,5 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { pluckRoute } from "@goauthentik/elements/router";
import { CSSResult, css } from "lit";
import { TemplateResult, html } from "lit";
@ -69,9 +69,9 @@ export class SidebarItem extends AKElement {
}
@property()
path?: string;
pathname?: string;
activeMatchers: RegExp[] = [];
#activeMatchers: URLPattern[] = [];
@property({ type: Boolean })
expanded = false;
@ -94,41 +94,57 @@ export class SidebarItem extends AKElement {
}
@property({ attribute: false })
set activeWhen(regexp: string[]) {
regexp.forEach((r) => {
this.activeMatchers.push(new RegExp(r));
});
set activeWhen(nextPathnamePatterns: string[]) {
for (const pathname of nextPathnamePatterns) {
this.#activeMatchers.push(new URLPattern({ pathname }));
}
}
firstUpdated(): void {
this.onHashChange();
window.addEventListener("hashchange", () => this.onHashChange());
this.#hashListener();
window.addEventListener("hashchange", this.#hashListener);
}
onHashChange(): void {
const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
#hashListener = (): void => {
const currentPathname = pluckRoute(window.location).pathname;
this.childItems.forEach((item) => {
this.expandParentRecursive(activePath, item);
this.expandParentRecursive(currentPathname, item);
});
this.isActive = this.matchesPath(activePath);
}
private matchesPath(path: string): boolean {
if (!this.path) {
return false;
}
this.isActive = this.matchesPath(currentPathname);
};
const ourPath = this.path.split(";")[0];
const pathIsWholePath = new RegExp(`^${ourPath}$`).test(path);
const pathIsAnActivePath = this.activeMatchers.some((v) => v.test(path));
return pathIsWholePath || pathIsAnActivePath;
private matchesPath(targetPathname: string): boolean {
if (!this.pathname) return false;
const criteria = {
pathname: targetPathname,
};
const matchesWholePath = new URLPattern({
pathname: this.pathname,
}).test(criteria);
const activePath = this.#activeMatchers.some((v) => v.test(criteria));
return matchesWholePath || activePath;
}
expandParentRecursive(activePath: string, item: SidebarItem): void {
if (item.matchesPath(activePath) && item.parent) {
item.parent.expanded = true;
this.requestUpdate();
if (!item.childItems.length) {
requestAnimationFrame(() => {
this.scrollIntoView({
block: "nearest",
});
});
}
}
item.childItems.forEach((i) => this.expandParentRecursive(activePath, i));
}
@ -191,7 +207,7 @@ export class SidebarItem extends AKElement {
renderWithPath() {
return html`
<a
href="${this.isAbsoluteLink ? "" : "#"}${this.path}"
href="${this.isAbsoluteLink ? "" : "#"}${this.pathname}"
class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}"
>
<slot name="label"></slot>
@ -209,11 +225,11 @@ export class SidebarItem extends AKElement {
renderInner() {
if (this.childItems.length > 0) {
return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren();
return this.pathname ? this.renderWithPathAndChildren() : this.renderWithChildren();
}
return html`<li class="pf-c-nav__item">
${this.path ? this.renderWithPath() : this.renderWithLabel()}
${this.pathname ? this.renderWithPath() : this.renderWithLabel()}
</li>`;
}
}

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/chips/Chip";
import "@goauthentik/elements/chips/ChipGroup";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/table/TablePagination";
import "@goauthentik/elements/table/TableSearch";
@ -118,7 +118,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
data?: PaginatedResponse<T>;
@property({ type: Number })
page = getURLParam("tablePage", 1);
page = getRouteParameter("tablePage", 1);
/** @prop
*
@ -200,7 +200,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
}
});
if (this.searchEnabled()) {
this.search = getURLParam("search", "");
this.search = getRouteParameter("search", "");
}
}
@ -441,7 +441,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
renderSearch(): TemplateResult {
const runSearch = (value: string) => {
this.search = value;
updateURLParams({
patchRouteParams({
search: value,
});
this.fetch();
@ -524,7 +524,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
/* A simple pagination display, shown at both the top and bottom of the page. */
renderTablePagination(): TemplateResult {
const handler = (page: number) => {
updateURLParams({ tablePage: page });
patchRouteParams({ tablePage: page });
this.page = page;
this.fetch();
};

View File

@ -1,5 +1,5 @@
import "@goauthentik/elements/PageHeader";
import { updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { patchRouteParams } from "@goauthentik/elements/router/utils";
import { Table } from "@goauthentik/elements/table/Table";
import { msg } from "@lit/localize";
@ -60,7 +60,7 @@ export abstract class TablePage<T> extends Table<T> {
this.search = "";
this.requestUpdate();
this.fetch();
updateURLParams({
patchRouteParams({
search: "",
});
}}

View File

@ -1,8 +1,10 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { applyNextParam } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
import { msg, str } from "@lit/localize";
@ -57,12 +59,13 @@ export class SourceSettingsOAuth extends BaseUserSettings {
</button>`;
}
if (this.configureUrl) {
return html`<a
class="pf-c-button pf-m-primary"
href="${this.configureUrl}${AndNext(
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
)}"
>
const target = new URL(this.configureUrl);
const destination = formatInterfaceRoute("user", "settings", { page: "sources" });
applyNextParam(target, destination);
return html`<a class="pf-c-button pf-m-primary" href="${target}">
${msg("Connect")}
</a>`;
}

View File

@ -1,8 +1,10 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { applyNextParam } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
import { msg, str } from "@lit/localize";
@ -57,12 +59,13 @@ export class SourceSettingsSAML extends BaseUserSettings {
</button>`;
}
if (this.configureUrl) {
return html`<a
class="pf-c-button pf-m-primary"
href="${this.configureUrl}${AndNext(
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
)}"
>
const target = new URL(this.configureUrl);
const destination = formatInterfaceRoute("user", "settings", { page: "sources" });
applyNextParam(target, destination);
return html`<a class="pf-c-button pf-m-primary" href="${target}">
${msg("Connect")}
</a>`;
}

View File

@ -15,7 +15,7 @@ import "@goauthentik/flow/stages/password/PasswordStage";
// end of stage import
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
const { ESBuildObserver } = await import("src/development/build-observer");
new ESBuildObserver(process.env.WATCHER_URL);
}

View File

@ -72,7 +72,9 @@ export class BaseStage<
}
return this.host?.submit(object as unknown as Tout).then((successful) => {
if (successful) {
this.cleanup();
this.onSubmitSuccess();
} else {
this.onSubmitFailure();
}
return successful;
});
@ -124,7 +126,11 @@ export class BaseStage<
`;
}
cleanup(): void {
onSubmitSuccess(): void {
// Method that can be overridden by stages
return;
}
onSubmitFailure(): void {
// Method that can be overridden by stages
return;
}

View File

@ -9,7 +9,7 @@ import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern";
import type { TurnstileObject } from "turnstile-types";
import type * as _ from "turnstile-types";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -24,10 +24,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
interface TurnstileWindow extends Window {
turnstile: TurnstileObject;
}
type TokenHandler = (token: string) => void;
type Dims = { height: number };
@ -52,6 +48,8 @@ type CaptchaHandler = {
name: string;
interactive: () => Promise<unknown>;
execute: () => Promise<unknown>;
refreshInteractive: () => Promise<unknown>;
refresh: () => Promise<unknown>;
};
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
@ -119,6 +117,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.host.submit({ component: "ak-stage-captcha", token });
};
@property({ attribute: false })
refreshedAt = new Date();
@state()
activeHandler?: CaptchaHandler = undefined;
@state()
error?: string;
@ -127,16 +131,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
name: "grecaptcha",
interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
},
{
name: "hcaptcha",
interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
},
{
name: "turnstile",
interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile,
refreshInteractive: this.refreshTurnstileFrame,
refresh: this.refreshTurnstile,
},
];
@ -230,6 +240,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
});
}
async refreshGReCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
}
async refreshGReCaptcha() {
window.grecaptcha.reset();
window.grecaptcha.execute();
}
async renderHCaptchaFrame() {
this.renderFrame(
html`<div
@ -251,6 +270,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
);
}
async refreshHCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
}
async refreshHCaptcha() {
window.hcaptcha.reset();
window.hcaptcha.execute();
}
async renderTurnstileFrame() {
this.renderFrame(
html`<div
@ -262,13 +290,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
async executeTurnstile() {
return (window as unknown as TurnstileWindow).turnstile.render(
this.captchaDocumentContainer,
{
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
},
);
return window.turnstile.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
});
}
async refreshTurnstileFrame() {
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
}
async refreshTurnstile() {
window.turnstile.reset();
}
async renderFrame(captchaElement: TemplateResult) {
@ -336,16 +369,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined;
let found = false;
for (const { name, interactive, execute } of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${name}`);
for (const handler of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${handler.name}`);
try {
const runner = this.challenge.interactive ? interactive : execute;
const runner = this.challenge.interactive
? handler.interactive
: handler.execute;
await runner.apply(this);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`);
found = true;
this.activeHandler = handler;
break;
} catch (exc) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`);
console.debug(exc);
lastError = exc;
}
@ -370,6 +406,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
document.body.appendChild(this.captchaDocumentContainer);
}
}
updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
if (this.challenge.interactive) {
this.activeHandler?.refreshInteractive.apply(this);
} else {
this.activeHandler?.refresh.apply(this);
}
}
}
declare global {

View File

@ -49,6 +49,8 @@ export class IdentificationStage extends BaseStage<
@state()
captchaToken = "";
@state()
captchaRefreshedAt = new Date();
static get styles(): CSSResult[] {
return [
@ -179,12 +181,16 @@ export class IdentificationStage extends BaseStage<
this.form.appendChild(totp);
}
cleanup(): void {
onSubmitSuccess(): void {
if (this.form) {
this.form.remove();
}
}
onSubmitFailure(): void {
this.captchaRefreshedAt = new Date();
}
renderSource(source: LoginSource): TemplateResult {
const icon = renderSourceIcon(source.name, source.iconUrl);
return html`<li class="pf-c-login__main-footer-links-item">
@ -287,6 +293,7 @@ export class IdentificationStage extends BaseStage<
.onTokenChange=${(token: string) => {
this.captchaToken = token;
}}
.refreshedAt=${this.captchaRefreshedAt}
embedded
></ak-stage-captcha>
`

View File

@ -1,7 +1,8 @@
import { CSRFHeaderName } from "@goauthentik/common/api/middleware";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { first, getCookie } from "@goauthentik/common/utils";
import { getCookie } from "@goauthentik/common/http";
import { first } from "@goauthentik/common/utils";
import { Interface } from "@goauthentik/elements/Interface";
import "@goauthentik/elements/ak-locale-context";
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";

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