Compare commits

...

43 Commits

Author SHA1 Message Date
authentik-automation[bot]
70406664dc release: 2025.10.1 2025-11-03 16:42:08 +00:00
authentik-automation[bot]
c58c194180 website/docs: 2025.10.1 release notes (cherry-pick #17918 to version-2025.10) (#17919)
website/docs: 2025.10.1 release notes (#17918)

* website/docs: 2025.10.1 release notes



* Apply suggestions from code review




* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-11-03 17:05:18 +01:00
authentik-automation[bot]
fad87741e7 providers/oauth2: fix kid always required for federation (cherry-pick #17914 to version-2025.10) (#17917)
providers/oauth2: fix kid always required for federation (#17914)

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

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

This reverts commit 09e3301c8f.

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

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

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

* web: Fix focus

* web: Fix nested focus

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

* web: Fix icon fallback behavior, labels.

* web: Fix flickering, descriptions.

* web: Fix excess width on mobile.

* web: Fix rendering artifacts on mobile.

* web: Remove aria-controls behavior.

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

* web: Fix tabpanel focus attempting to scroll page.

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

* web: add shared helpers.

* web: Tidy comments.

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

* web: Fix tabpanel focus attempting to scroll page.

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

* web: add shared helpers.

* web: Tidy comments.

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

I have no idea why this breaks tests

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

* wip

* wip

* a

* a



* rm

* this

* rm test files

* cover one more case



---------

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

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

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

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

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

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



* also fix retention workflow



---------

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

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

This cherry-pick has conflicts that need manual resolution.

Original PR: #17795
Original commit: 6f35c32190

* fix conflict

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

---------

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

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

it is often mis configured

closes #17678

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

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

* replace interface{} with any



* fix raw token not saved to map or json



* also fix proxy claims



* fix test



---------

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

Add label source in dockerfiles

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

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

* website/docs: finalise 2025.10 release notes



* format



---------

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

* website/docs: update flow context ref



* format



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




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




---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-10-27 19:39:09 +01:00
authentik-automation[bot]
06a6d45139 enterprise: handle cached naive timezone (cherry-pick #17695 to version-2025.10) (#17730)
enterprise: handle cached naive timezone (#17695)

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

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

* this might be worth mentioning

* fix

* a bit more info

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

Add certificate recommendation

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

* add info about custom validation

* tweaked table

* remove bullet

* remove other bullet

---------

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

* invite link not bound

* marcelo's truth

* jens tweak

---------

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

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

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

* add 3 more int guides

* Apply suggestion from @dominic-r



* is github's suggestion thingy usually this buggy

---------

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

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

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



* bump build



---------

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-trixie-slim@sha256:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe AS node-builder
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -63,7 +63,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/authentik ./cmd/server
# Stage 3: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1 AS geoip
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1@sha256:faecdca22579730ab0b7dea5aa9af350bb3c93cb9d39845c173639ead30346d2 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
@@ -76,9 +76,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.9.4 AS uv
FROM ghcr.io/astral-sh/uv:0.9.6@sha256:4b96ee9429583983fd172c33a02ecac5242d63fb46bc27804748e38c1cc9ad0d AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
@@ -139,6 +139,7 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info." \
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -378,9 +378,11 @@ class TokenParams:
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to parse JWT for kid lookup", exc=exc)
raise TokenError("invalid_grant") from None
expected_kid = decode_unvalidated["header"]["kid"]
fallback_alg = decode_unvalidated["header"]["alg"]
expected_kid = decode_unvalidated["header"].get("kid")
fallback_alg = decode_unvalidated["header"].get("alg")
token = source = None
if not expected_kid or not fallback_alg:
return None, None
for source in self.provider.jwt_federation_sources.filter(
oidc_jwks__keys__contains=[{"kid": expected_kid}]
):

View File

@@ -429,6 +429,7 @@ DRAMATIQ = {
},
),
("dramatiq.results.middleware.Results", {"store_results": True}),
("authentik.tasks.middleware.StartupSignalsMiddleware", {}),
("authentik.tasks.middleware.CurrentTask", {}),
("authentik.tasks.middleware.TenantMiddleware", {}),
("authentik.tasks.middleware.ModelDataMiddleware", {}),

View File

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

View File

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

View File

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

View File

@@ -16,9 +16,9 @@ from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.invitation.models import Invitation, InvitationStage
from authentik.stages.invitation.stage import (
INVITATION_TOKEN_KEY,
INVITATION_TOKEN_KEY_CONTEXT,
PLAN_CONTEXT_INVITATION_TOKEN,
PLAN_CONTEXT_PROMPT,
QS_INVITATION_TOKEN_KEY,
)
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@@ -89,7 +89,7 @@ class TestInvitationStage(FlowTestCase):
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
session = self.client.session
@@ -114,7 +114,7 @@ class TestInvitationStage(FlowTestCase):
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
session = self.client.session
@@ -134,7 +134,7 @@ class TestInvitationStage(FlowTestCase):
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {INVITATION_TOKEN_KEY_CONTEXT: invite.pk.hex}
plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
2025.10.0-rc1
2025.10.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.10.0-rc1"
version = "2025.10.1"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"

View File

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

View File

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

View File

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

10
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = "==3.13.*"
[manifest]
@@ -170,7 +170,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.10.0rc1"
version = "2025.10.1"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
@@ -1141,10 +1141,14 @@ version = "0.1.0"
source = { editable = "packages/django-postgres-cache" }
dependencies = [
{ name = "django" },
{ name = "django-postgres-extra" },
]
[package.metadata]
requires-dist = [{ name = "django", specifier = ">=4.2,<6.0" }]
requires-dist = [
{ name = "django", specifier = ">=4.2,<6.0" },
{ name = "django-postgres-extra", specifier = ">=2.0,<2.1" },
]
[[package]]
name = "django-postgres-extra"

73
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2025.10.0-rc1",
"version": "2025.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2025.10.0-rc1",
"version": "2025.10.1",
"license": "MIT",
"workspaces": [
"./packages/*"
@@ -1940,6 +1940,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz",
"integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
@@ -2317,6 +2318,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2338,6 +2340,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz",
"integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -2350,6 +2353,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz",
"integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2365,6 +2369,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz",
"integrity": "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.204.0",
"import-in-the-middle": "^1.8.1",
@@ -2757,6 +2762,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz",
"integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.1.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2773,6 +2779,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz",
"integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.1.0",
"@opentelemetry/resources": "2.1.0",
@@ -2790,6 +2797,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
"integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@@ -3118,7 +3126,6 @@
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
"integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
@@ -4517,18 +4524,6 @@
}
}
},
"node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.1.tgz",
"integrity": "sha512-gRO+jk2ljxZlIn20QRskIvpLCMtzuLl5T0BY6L9uvPYD17uUrxlxWkvYCiVqED2q2q7CVtY52Uex4WcYo2FEXw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.2.1",
"node-gyp-build": "^4.8.2"
}
},
"node_modules/@swagger-api/apidom-reference": {
"version": "1.0.0-beta.30",
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.30.tgz",
@@ -4644,6 +4639,7 @@
"integrity": "sha512-V1r4wFdjaZIUIZZrV2Mb/prEeu03xvSm6oatPxsvnXKF9lNh5Jtk9QvUdiVfD9rrvi7bXrAVhg9Wpbmv/2Fl1g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@@ -5012,6 +5008,7 @@
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz",
"integrity": "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@babel/generator": "^7.26.5",
"@babel/parser": "^7.26.7",
@@ -5481,6 +5478,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.0.tgz",
"integrity": "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -5525,6 +5523,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5634,6 +5633,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -6080,6 +6080,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.11.tgz",
"integrity": "sha512-U4iqPlHO0KQeK1mrsxCN0vZzw43/lL8POxgpzcJweopmqtoYy9nljJzWDIQS3EfjiYhfdtdk9Gtgz7MRXnz3GA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.23.5",
"@vue/compiler-core": "3.3.11",
@@ -6505,6 +6506,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7393,6 +7395,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -7423,6 +7426,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -7789,6 +7793,7 @@
"version": "3.30.2",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz",
"integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -8189,6 +8194,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8338,6 +8344,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -8565,7 +8572,6 @@
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.1.tgz",
"integrity": "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20"
}
@@ -8575,7 +8581,6 @@
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz",
"integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
@@ -8937,6 +8942,7 @@
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -9199,6 +9205,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10689,7 +10696,6 @@
"resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz",
"integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/fisker/git-hooks-list?sponsor=1"
}
@@ -11150,6 +11156,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.9.12.tgz",
"integrity": "sha512-SrTC0YxqPwnN7yKa8gg/giLyQ2pILCKoideIHbYbFQlWZjYt68D2A4Ae1hehO/aDQ6RmTcpqOV/O2yBtMzx/VQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12312,6 +12319,7 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
@@ -15131,6 +15139,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -15427,6 +15436,7 @@
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
"integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ramda"
@@ -15544,6 +15554,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -15553,6 +15564,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16149,6 +16161,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -16904,15 +16917,13 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz",
"integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/sort-package-json": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.4.0.tgz",
"integrity": "sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"detect-indent": "^7.0.1",
"detect-newline": "^4.0.1",
@@ -17053,6 +17064,7 @@
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -17501,7 +17513,6 @@
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
"integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pkgr/core": "^0.2.4"
},
@@ -17695,19 +17706,6 @@
"node": ">=6"
}
},
"node_modules/tree-sitter": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz",
"integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
}
},
"node_modules/tree-sitter-json": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz",
@@ -17980,6 +17978,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17993,6 +17992,7 @@
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
@@ -18354,6 +18354,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -18484,6 +18485,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -19163,6 +19165,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ 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`.
* @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.
*
* @category Routing
@@ -51,7 +51,7 @@ export function isUserRoute(location: Pick<URL, "pathname"> = window.location):
* The input is converted to lowercase and non-alphanumeric characters are
* replaced with a hyphen. Trailing whitespace and hyphens are removed.
*
* @param input - The input string to format.
* @param input The input string to format.
*
* @category Routing
*
@@ -68,7 +68,7 @@ export function formatSlug(input: string): string {
/**
* Predicate to determine if the input is a valid route slug.
*
* @param input - The input string to check.
* @param input The input string to check.
*/
export function isSlug(input: string): boolean {
return kebabCase(input) === input;

View File

@@ -285,7 +285,7 @@ export abstract class Table<T extends object>
@property({ attribute: false })
public data: PaginatedResponse<T> | null = null;
@property({ type: Number })
@property({ type: Number, useDefault: true })
public page = getURLParam(this.#pageParam, 1);
/**
@@ -396,7 +396,7 @@ export abstract class Table<T extends object>
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("page")) {
updateURLParams({
[this.#pageParam]: this.page,
[this.#pageParam]: this.page === 1 ? null : this.page,
});
}
if (changedProperties.has("search")) {

View File

@@ -65,7 +65,7 @@ export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
export type LitFC<P> = (
props: P,
children?: null | SlottedTemplateResult,
) => SlottedTemplateResult | SlottedTemplateResult[];
) => SlottedTemplateResult | SlottedTemplateResult[] | null;
//#endregion

View File

@@ -0,0 +1,22 @@
export function isInteractiveElement(target: Element | null | undefined): target is HTMLElement {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
if (target.hasAttribute("disabled") || target.inert) {
return false;
}
const { tabIndex } = target;
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
return (
visible &&
(tabIndex === 0 ||
tabIndex === -1 ||
target.matches("button, [role='button'], a[href], input, select, textarea"))
);
}

View File

@@ -2,60 +2,56 @@ import "#elements/AppIcon";
import "#user/LibraryApplication/RACLaunchEndpointModal";
import "#elements/buttons/Dropdown";
import { globalAK } from "#common/global";
import { truncateWords } from "#common/strings";
import { rootInterface } from "#common/theme";
import { LitFC } from "#elements/types";
import type { UserInterface } from "#user/index.entrypoint";
import { Application } from "@goauthentik/api";
import { spread } from "@open-wc/lit-helpers";
import type { HTMLAttributes } from "react";
import { msg, str } from "@lit/localize";
import { html, nothing } from "lit";
import { html } from "lit";
export const AnchorPositionSupported = CSS.supports("position-anchor", "--test");
export interface CardMenuProps extends HTMLAttributes<HTMLDivElement> {
cardID: string;
descriptionID: string;
application: Application;
editURL?: string | URL | null;
}
export const CardMenu: LitFC<CardMenuProps> = ({
application,
cardID,
descriptionID,
editURL,
...props
}) => {
const { me, uiConfig } = rootInterface<UserInterface>();
const editURL =
uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser
? `${globalAK().api.base}if/admin/#/core/applications/${application.slug}`
: null;
const { metaDescription, metaPublisher } = application;
const truncatedDescription = truncateWords(metaDescription, 50);
const menuID = `${cardID}-actions-menu`;
const menuAnchor = `--${cardID}-actions-menu-anchor`;
if (!metaPublisher && !truncatedDescription && !editURL) {
return null;
}
const applicationName = application.name || msg("application");
return html`<div class="pf-c-dropdown" part="card-header-actions" ${spread(props)}>
<button
part="card-header-actions-button"
class="pf-c-dropdown__toggle"
type="button"
id="add-mfa-toggle"
style="anchor-name: ${menuAnchor};"
aria-haspopup="menu"
aria-controls=${menuID}
popovertarget=${menuID}
popovertargetaction="toggle"
tabindex="0"
aria-label=${msg(str`Actions for "${application.name}"`)}
tabindex="-1"
aria-label=${msg(str`Actions for "${applicationName}"`)}
>
<span part="card-header-actions-icon" class="pf-c-dropdown__toggle-text">&vellip;</span>
</button>
@@ -64,7 +60,7 @@ export const CardMenu: LitFC<CardMenuProps> = ({
part="card-header-actions-menu"
style="position-anchor: ${menuAnchor};"
id=${menuID}
popover
?popover=${AnchorPositionSupported}
>
${metaPublisher || truncatedDescription
? html`<li role="presentation">
@@ -77,8 +73,8 @@ export const CardMenu: LitFC<CardMenuProps> = ({
? html`<div part="card-header-action-publisher">
<small>${metaPublisher}</small>
</div>`
: nothing}
${metaPublisher
: null}
${truncatedDescription
? html`<p
class="pf-c-content"
part="card-header-action-description"
@@ -86,24 +82,24 @@ export const CardMenu: LitFC<CardMenuProps> = ({
>
${truncatedDescription}
</p>`
: nothing}
: null}
</div>
</li>
<hr class="pf-c-divider" />`
: nothing}
: null}
${editURL
? html`<li role="presentation">
<a
part="card-header-action"
role="menuitem"
href=${editURL}
href=${editURL.toString()}
class="pf-c-dropdown__menu-item"
>
<i class="fas fa-edit" aria-hidden="true"></i>
<i class="fas fa-edit" role="img"></i>
&nbsp;${msg(str`Edit application...`)}</a
>
</li>`
: nothing}
: null}
</menu>
</div>`;
};

View File

@@ -17,26 +17,30 @@ import { kebabCase } from "change-case";
import type { HTMLAttributes } from "react";
import { msg, str } from "@lit/localize";
import { html } from "lit";
import { html, nothing } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { createRef, ref, RefOrCallback } from "lit/directives/ref.js";
import { styleMap } from "lit/directives/style-map.js";
const RAC_LAUNCH_URL = "goauthentik.io://providers/rac/launch";
export interface AKLibraryAppProps extends HTMLAttributes<HTMLDivElement> {
application?: Application;
editURL?: string | URL | null;
background?: string | null;
appIndex: number;
groupIndex: number;
targetRef?: RefOrCallback | null;
}
export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
application,
editURL,
background,
appIndex,
groupIndex,
className = "",
targetRef,
...props
}) => {
if (!application) {
@@ -55,7 +59,7 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
modalRef.value?.show();
};
const cardID = `app-card-${groupIndex}-${appIndex}`;
const cardID = `app-${application.pk}`;
const titleID = `${cardID}-title`;
const descriptionID = `${cardID}-description`;
const cardHeader = CardHeader({
@@ -64,31 +68,37 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
});
const rac = application.launchUrl === RAC_LAUNCH_URL;
const primaryRef = targetRef ? ref(targetRef) : nothing;
const extendedProps = {
"aria-label": msg(str`Open "${application.name}"`),
"tabindex": "0",
"class": "card-header-aspect-wrapper",
"title": ifPresent(application.name),
"id": cardID,
...props,
};
return html`<div
role="gridcell"
part="app-card"
part="card-wrapper"
data-application-name=${ifPresent(dataID)}
aria-labelledby=${titleID}
aria-describedby=${descriptionID}
style=${styleMap({ background: background || null })}
${spread(props)}
>
<div part="card" class="pf-c-card pf-m-hoverable pf-m-compact ${classMap(classes)}">
<ak-app-icon
part="card-header-icon"
exportparts="icon:card-header-icon"
size=${PFSize.Large}
name=${application.name}
icon=${ifPresent(application.metaIcon)}
></ak-app-icon>
${rac
? html`<div
${primaryRef}
role="button"
tabindex="0"
@click=${launchModal}
class="card-header-aspect-wrapper"
aria-label=${msg(str`Open "${application.name}"`)}
title=${ifPresent(application.name)}
${spread(extendedProps)}
>
<ak-library-rac-endpoint-launch
${ref(modalRef)}
@@ -97,18 +107,17 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
${cardHeader}
</div>`
: html`<a
tabindex="0"
class="card-header-aspect-wrapper"
aria-label=${msg(str`Open "${application.name}"`)}
title=${ifPresent(application.name)}
${primaryRef}
href=${ifPresent(application.launchUrl)}
target=${ifPresent(application.openInNewTab, "_blank")}
${spread(extendedProps)}
>${cardHeader}</a
>`}
${CardMenu({
application,
cardID,
descriptionID,
editURL,
})}
</div>
</div>`;

View File

@@ -0,0 +1,291 @@
/* #region Host */
:host {
--app-card-aspect-ratio: 4 / 3;
--app-card-min-width: 6.5rem;
--app-group-header-min-height: calc(var(--app-card-min-width) / 4);
--app-icon-offset: 1rem;
--app-card-row-coefficient: 3.5;
@media (min-width: 390px) {
--app-card-aspect-ratio: 1;
--app-group-template-columns: repeat(auto-fill, var(--app-card-min-width));
}
@media (min-width: 409px) {
--app-card-min-width: 7rem;
}
@media (min-width: 768px) {
--app-icon-offset: 0.5rem;
--app-card-min-width: 10rem;
}
}
/* #region Card */
.card-header-aspect-wrapper {
display: grid;
grid-template-columns: 1fr;
padding-inline: 1rem;
padding-block: calc(var(--pf-global--LineHeight--md) * var(--app-card-title-padding));
height: 100%;
position: relative;
position: absolute;
inset: 0;
}
[part="card"] {
--pf-c-card--BoxShadow: var(--pf-global--BoxShadow--md-bottom);
--pf-c-card--BackgroundColor: var(--pf-global--BackgroundColor--150);
transition: box-shadow 150ms ease-in-out;
border: 0.5px solid var(--pf-global--BorderColor--100);
height: 100%;
&:hover {
--pf-c-card--m-hoverable--hover--BoxShadow: var(--pf-global--BoxShadow--xl-bottom);
}
}
[data-anchor-strategy="anchor-position"] [part="card"] {
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts on mobile. */
}
/* #region Header */
[part="card-header"] {
padding: 0 !important;
display: grid;
grid-template-rows: repeat(
auto-fill,
minmax(calc(var(--app-card-min-width) / var(--app-card-row-coefficient)), auto)
);
&:hover {
text-decoration: none;
.pf-c-card__title {
text-decoration: underline;
}
}
}
/* #region Title */
[part="card-title"] {
padding: 0 !important;
z-index: 1;
text-stroke-width: 0.15em;
text-stroke-color: var(--pf-c-card--BackgroundColor);
-webkit-text-stroke-width: 0.15em;
-webkit-text-stroke-color: var(--pf-c-card--BackgroundColor);
paint-order: stroke fill;
display: flex;
grid-row: -2 / -2;
justify-content: center;
height: 100%;
align-items: center;
.clamp-wrapper {
--clamp-padding: calc(0.1em * var(--app-card-row-coefficient));
display: box;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
box-orient: vertical;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
text-wrap: balance;
line-height: 1.2;
padding-block: var(--clamp-padding);
max-height: calc((var(--pf-global--LineHeight--md) * 2rem) - (var(--clamp-padding) / 2));
}
}
/* #region Icon */
ak-app-icon {
--icon-height: 50%;
--icon-font-size: calc(var(--app-card-min-width) / 2.3);
--app-icon--shadow-background-color: var(--pf-c-card--BackgroundColor);
&::part(icon) {
position: absolute;
inset: 0;
object-fit: contain;
place-self: center;
inset-block-start: -1.5rem;
inset-block-end: var(--app-icon-offset, 0);
padding: 0;
@media (max-width: 767px) {
inset-block-start: -0.75rem;
}
}
}
/* #region Group Header */
[part="app-group-header"] {
@media not (prefers-contrast: more) {
--ak-legend-padding-inline-base: 1rem;
padding-block-start: 0 !important;
padding-inline: 0 !important;
margin-inline: 0 !important;
}
}
/* #region App List */
[part="app-list"] {
display: grid;
gap: var(--pf-global--spacer--md);
grid-template-columns: repeat(var(--app-list-column-count, 1), 1fr);
justify-items: center;
@media (max-width: 767px) {
--app-list-column-count: 1 !important;
justify-items: normal;
}
}
/* #region App Group */
[part="app-group"] {
--app-group-border-color: transparent;
display: grid;
gap: var(--pf-global--spacer--md);
grid-template-columns: var(--app-group-template-columns, 1fr);
width: round(down, 100%, calc(var(--app-card-min-width) / var(--app-list-column-count, 1)));
grid-auto-rows: minmax(min-content, 0);
@media (max-width: 767px) {
width: auto;
grid-template-rows: auto;
}
@media not (prefers-contrast: more) {
padding: 0 !important;
border: none !important;
}
}
/* #region Group Separator */
[part="app-group-separator"] {
grid-column: 1 / -1;
border-color: transparent;
}
/* #region Card Wrapper */
[part="card-wrapper"] {
aspect-ratio: var(--app-card-aspect-ratio);
position: relative;
contain-intrinsic-size: var(--app-card-min-width);
&[aria-selected="true"] {
outline: auto var(--ak-accent);
}
}
/* #region Card Header Actions Description */
[part="card-header-action-description"] {
text-wrap: balance;
text-wrap: pretty; /* Supporting browsers. */
}
/* #region Card Header Actions */
[part="card-header-actions"] {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
/* #region Card Header Actions Menu */
[part="card-header-actions-menu"] {
inset: auto;
border: none;
width: max-content;
min-width: 10ch;
max-width: 20ch;
/* This drop shadow both adds contrast and forces Firefox to order the layers correctly. */
filter: drop-shadow(0px 3px 1px var(--pf-global--BackgroundColor--dark-transparent-200));
li {
list-style-type: none;
}
}
[data-anchor-strategy="anchor-position"] [part="card-header-actions-menu"] {
position: fixed;
inset-inline-start: auto;
inset-block-start: anchor(end);
inset-inline-end: anchor(end);
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts on mobile. */
&:popover-open {
display: block;
}
@media (min-width: 390px) {
inset-inline-start: anchor(center);
}
}
/* Fallback if popover + anchor positioning is not supported */
[data-anchor-strategy="fallback"] [part="card-header-actions-menu"] {
display: none;
position: absolute;
inset-inline: auto calc(100% - 1.5em);
@media (min-width: 390px) {
inset-block-start: 0;
inset-inline-start: 100%;
}
}
[data-anchor-strategy="fallback"]
[part="card-header-actions"]:focus-within
[part="card-header-actions-menu"] {
display: block;
}
/* #region Card Header Actions Button */
[part="card-header-actions-button"] {
border: 0.5px solid transparent;
border-end-start-radius: var(--pf-global--BorderRadius--sm);
--pf-c-dropdown__toggle--before--BorderWidth: 0;
&:hover {
--pf-c-dropdown__toggle--BackgroundColor: var(--pf-c-card--m-flat--BorderColor);
border-color: var(--pf-c-card--m-flat--BorderColor);
}
}
/* #region Card Header Actions icon */
[part="card-header-actions-icon"] {
font-weight: bold;
font-family:
system-ui,
-apple-system,
monospace;
}

View File

@@ -0,0 +1,109 @@
import type { AppGroupEntry } from "./types.js";
import { rootInterface } from "#common/theme";
import { LayoutType } from "#common/ui/config";
import { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { UserInterface } from "#user/index.entrypoint";
import { AnchorPositionSupported } from "#user/LibraryApplication/CardMenu";
import { AKLibraryApp } from "#user/LibraryApplication/index";
import { ApplicationRoute } from "#admin/Routes";
import { Application } from "@goauthentik/api";
import { spread } from "@open-wc/lit-helpers";
import { kebabCase } from "change-case";
import { HTMLAttributes } from "react";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { RefOrCallback } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
const LayoutColumnCount = {
[LayoutType.row]: 1,
[LayoutType.column_2]: 2,
[LayoutType.column_3]: 3,
} as const satisfies Record<LayoutType, number>;
export interface AKLibraryApplicationListProps extends HTMLAttributes<HTMLDivElement> {
groupedApps: AppGroupEntry[];
layout: LayoutType;
background?: string | null;
selectedApp?: Application | null;
targetRef?: RefOrCallback | null;
}
/**
* Renders the current library list of a User's Applications.
*/
export const AKLibraryApplicationList: LitFC<AKLibraryApplicationListProps> = ({
groupedApps,
layout = LayoutType.row,
background,
selectedApp,
targetRef,
...props
}) => {
const columnCount = LayoutColumnCount[layout] ?? 1;
const { me, uiConfig } = rootInterface<UserInterface>();
const canEdit = !!(uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser);
return html`<div
role="presentation"
part="app-list"
data-anchor-strategy=${AnchorPositionSupported ? "anchor-position" : "fallback"}
style="--app-list-column-count: ${columnCount}"
${spread(props)}
>
${repeat(
groupedApps,
([groupLabel]) => groupLabel,
([groupLabel, apps], groupIndex) => {
const groupID = kebabCase(groupLabel);
const activeDescendantID =
selectedApp && apps.includes(selectedApp) ? `app-${selectedApp.pk}` : nothing;
return html`<fieldset
data-group-id=${ifPresent(groupID)}
part="app-group"
data-group-index=${groupIndex}
data-app-count=${apps.length}
aria-activedescendant=${activeDescendantID}
>
<legend
class="pf-c-content ${!groupLabel ? "less-contrast-sr-only" : ""}"
part="app-group-header"
>
<h2 id=${`app-group-${groupID}`}>${groupLabel || msg("Ungrouped")}</h2>
</legend>
${repeat(
apps,
(application) => application.pk,
(application, appIndex) => {
const selected = selectedApp === application;
const editURL = canEdit
? ApplicationRoute.EditURL(application.slug)
: null;
return AKLibraryApp({
application,
appIndex,
groupIndex,
background,
editURL,
"targetRef": selected ? targetRef : null,
"aria-selected": selected,
});
},
)}
<hr part="app-group-separator" aria-hidden="true" />
</fieldset>`;
},
)}
</div>`;
};

View File

@@ -1,225 +0,0 @@
:host {
--app-card-aspect-ratio: 4 / 3;
--app-card-min-width: 6.5rem;
--app-group-header-min-height: calc(var(--app-card-min-width) / 4);
--app-icon-offset: 1rem;
--app-card-row-coefficient: 3.5;
@media (min-width: 390px) {
--app-card-aspect-ratio: 1;
--app-group-template-columns: repeat(auto-fill, var(--app-card-min-width));
}
@media (min-width: 409px) {
--app-card-min-width: 7rem;
}
@media (min-width: 768px) {
--app-icon-offset: 0.5rem;
--app-card-min-width: 10rem;
}
}
.card-header-aspect-wrapper {
display: grid;
grid-template-columns: 1fr;
padding-inline: 1rem;
padding-block: calc(var(--pf-global--LineHeight--md) * var(--app-card-title-padding));
height: 100%;
position: relative;
}
.pf-c-card {
--pf-c-card--BoxShadow: var(--pf-global--BoxShadow--md-bottom);
--pf-c-card--BackgroundColor: var(--pf-global--BackgroundColor--150);
transition: box-shadow 150ms ease-in-out;
border: 0.5px solid var(--pf-global--BorderColor--100);
height: 100%;
&:hover {
--pf-c-card--m-hoverable--hover--BoxShadow: var(--pf-global--BoxShadow--xl-bottom);
}
.pf-c-card__header {
padding: 0 !important;
display: grid;
grid-template-rows: repeat(
auto-fill,
minmax(calc(var(--app-card-min-width) / var(--app-card-row-coefficient)), auto)
);
&:hover {
text-decoration: none;
.pf-c-card__title {
text-decoration: underline;
}
}
}
.pf-c-card__title {
padding: 0 !important;
z-index: 1;
text-stroke-width: 0.15em;
text-stroke-color: var(--pf-c-card--BackgroundColor);
-webkit-text-stroke-width: 0.15em;
-webkit-text-stroke-color: var(--pf-c-card--BackgroundColor);
paint-order: stroke fill;
display: flex;
grid-row: -2 / -2;
justify-content: center;
height: 100%;
align-items: center;
.clamp-wrapper {
--clamp-padding: calc(0.1em * var(--app-card-row-coefficient));
display: box;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
box-orient: vertical;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
text-wrap: balance;
line-height: 1.2;
padding-block: var(--clamp-padding);
max-height: calc(
(var(--pf-global--LineHeight--md) * 2rem) - (var(--clamp-padding) / 2)
);
}
}
}
[part="card-header-icon"] {
--icon-height: 50%;
--icon-font-size: calc(var(--app-card-min-width) / 2.3);
&::part(icon) {
--app-icon--shadow-background-color: var(--pf-c-card--BackgroundColor);
position: absolute;
inset: 0;
object-fit: contain;
place-self: center;
inset-block-start: -1.5rem;
inset-block-end: var(--app-icon-offset, 0);
padding: 0;
}
}
[part="app-group-header"] {
grid-column: 1 / -1;
}
[part="app-list"] {
display: grid;
gap: var(--pf-global--spacer--md);
grid-template-columns: repeat(var(--app-list-column-count, 1), 1fr);
justify-items: center;
@media (max-width: 767px) {
--app-list-column-count: 1 !important;
justify-items: normal;
}
}
[part="app-group"] {
--app-group-border-color: transparent;
display: grid;
gap: var(--pf-global--spacer--md);
grid-template-columns: var(--app-group-template-columns, 1fr);
width: round(down, 100%, calc(var(--app-card-min-width) / var(--app-list-column-count, 1)));
grid-auto-rows: minmax(min-content, 0);
@media (max-width: 767px) {
width: auto;
grid-template-rows: auto;
}
@media (min-width: 768px) {
border-inline-start: 0.5px solid var(--app-group-border-color);
padding-inline-start: calc(1rem - 0.5px);
}
@media (prefers-contrast: more) {
&:not([data-group-index="0"]) {
--app-group-border-color: var(--pf-global--BorderColor--200);
}
}
}
[part="app-group-separator"] {
grid-column: 1 / -1;
border-color: transparent;
}
[part="app-card"] {
aspect-ratio: var(--app-card-aspect-ratio);
position: relative;
&[aria-selected="true"] {
outline: auto var(--ak-accent);
}
}
[part="card-header-action-description"] {
text-wrap: pretty;
max-width: 30ch;
}
[part="card-header-actions"] {
position: absolute;
top: 0;
right: 0;
}
[part="card-header-actions-menu"] {
position: fixed;
inset: unset;
inset-block-start: anchor(end);
inset-inline-end: anchor(end);
width: fit-content;
min-width: unset;
border: none;
@media (min-width: 390px) {
inset-inline-start: anchor(center);
}
}
[part="card-header-actions-button"] {
border: 0.5px solid transparent;
border-end-start-radius: var(--pf-global--BorderRadius--sm);
--pf-c-dropdown__toggle--before--BorderWidth: 0;
&:hover {
--pf-c-dropdown__toggle--BackgroundColor: var(--pf-c-card--m-flat--BorderColor);
border-color: var(--pf-c-card--m-flat--BorderColor);
}
&[aria-expanded="true"] {
border-color: var(--pf-c-card--m-flat--BorderColor);
&:before {
--pf-c-dropdown__toggle--before--BorderBottomColor: var(--pf-global--active-color--100);
border-bottom-width: var(--pf-c-dropdown__toggle--focus--before--BorderBottomWidth);
}
}
}
[part="card-header-actions-icon"] {
font-weight: bold;
font-family:
system-ui,
-apple-system,
monospace;
}

View File

@@ -1,119 +0,0 @@
import Styles from "./ak-library-application-list.css";
import type { AppGroupEntry } from "./types.js";
import { LayoutType } from "#common/ui/config";
import { AKElement } from "#elements/Base";
import { ifPresent } from "#elements/utils/attributes";
import { AKLibraryApp } from "#user/LibraryApplication/index";
import { Application } from "@goauthentik/api";
import { kebabCase } from "change-case";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
const LayoutColumnCount = {
[LayoutType.row]: 1,
[LayoutType.column_2]: 2,
[LayoutType.column_3]: 3,
} as const satisfies Record<LayoutType, number>;
/**
* @element ak-library-application-list
* @class LibraryPageApplicationList
*
* Renders the current library list of a User's Applications.
*
*/
@customElement("ak-library-application-list")
export class LibraryPageApplicationList extends AKElement {
static styles = [
// ---
PFBase,
PFEmptyState,
PFDropdown,
PFContent,
PFGrid,
PFButton,
PFCard,
PFDivider,
Styles,
];
@property({ attribute: true })
public layout: LayoutType = LayoutType.row;
@property({ attribute: true })
public background: string | null = null;
@property({ attribute: false })
public selected: Application | null = null;
@property({ attribute: false })
public apps: AppGroupEntry[] = [];
render() {
const columnCount = LayoutColumnCount[this.layout] ?? 1;
return html`<div
part="app-list"
style="--app-list-column-count: ${columnCount}"
aria-colcount=${columnCount}
role="grid"
aria-label=${msg("Available applications")}
>
${repeat(
this.apps,
([groupLabel]) => groupLabel,
([groupLabel, apps], groupIndex) => {
return html`<div
role="rowgroup"
data-group-id=${ifPresent(kebabCase(groupLabel))}
aria-labelledby="app-group-${groupIndex}"
part="app-group"
data-group-index=${groupIndex}
data-app-count=${apps.length}
>
<div class="pf-c-content" part="app-group-header">
<h2 id="app-group-${groupIndex}">${groupLabel}</h2>
</div>
${repeat(
apps,
(application) => application.pk,
(application, appIndex) =>
AKLibraryApp({
application,
appIndex,
groupIndex,
"part": "app-card",
"background": this.background,
"aria-live": "polite",
"aria-selected": this.selected === application,
}),
)}
<hr part="app-group-separator" aria-hidden="true" />
</div>`;
},
)}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-library-application-list": LibraryPageApplicationList;
}
}

View File

@@ -1,31 +0,0 @@
import { AKElement } from "#elements/Base";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css";
/**
* Library Page Application List Empty
*
* Display a message if there are no applications defined in the current instance. If the user is an
* administrator, provide a link to the "Create a new application" page.
*/
@customElement("ak-library-application-search-empty")
export class LibraryPageApplicationSearchEmpty extends AKElement {
static styles = [PFBase, PFEmptyState, PFContent, PFSpacing];
render() {
return html` <div class="pf-c-empty-state pf-m-full-height">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${msg("Search returned no results.")}</h1>
</div>
</div>`;
}
}

View File

@@ -1,20 +0,0 @@
input[name="application-search"] {
background-color: transparent;
display: block;
width: 100%;
font-size: var(--pf-global--FontSize--xl);
border-inline: none;
border-block-start: none;
&:focus,
&:hover {
--pf-c-form-control--BorderBottomColor: var(--ak-accent);
}
}
input[name="application-search"] {
@media not (prefers-contrast: more) {
outline: none;
}
}

View File

@@ -1,166 +0,0 @@
import Styles from "./ak-library-application-search.css";
import {
LibraryPageSearchEmpty,
LibraryPageSearchReset,
LibraryPageSearchSelected,
LibraryPageSearchUpdated,
} from "./events.js";
import { AKElement } from "#elements/Base";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { ifPresent } from "#elements/utils/attributes";
import type { Application } from "@goauthentik/api";
import Fuse, { FuseResult } from "fuse.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
/**
* @element ak-library-list-search
*
* @class LibraryPageApplicationSearch
*
* @classdesc
*
* The interface between our list of applications shown to the user, an input box, and the Fuse
* fuzzy search library.
*
* @fires LibraryPageSearchUpdated
* @fires LibraryPageSearchEmpty
* @fires LibraryPageSearchReset
*
*/
@customElement("ak-library-application-search")
export class LibraryPageApplicationSearch extends AKElement {
static styles = [
// ---
PFBase,
PFDisplay,
PFFormControl,
Styles,
];
@property({ attribute: false })
set apps(value: Application[]) {
this.fuse.setCollection(value);
}
@state()
protected query = getURLParam<string | null>("q", "");
protected searchInput = createRef<HTMLInputElement>();
protected fuse = new Fuse<Application>([], {
keys: [
{ name: "name", weight: 3 },
"slug",
"group",
{ name: "metaDescription", weight: 0.5 },
{ name: "metaPublisher", weight: 0.5 },
],
findAllMatches: true,
includeScore: true,
shouldSort: true,
ignoreFieldNorm: true,
useExtendedSearch: true,
threshold: 0.3,
});
public override connectedCallback() {
super.connectedCallback();
if (this.query) {
const matchingApps = this.fuse.search(this.query);
if (matchingApps.length) {
this.#dispatchSelected(matchingApps);
}
}
}
public reset(): void {
const searchInput = this.searchInput.value;
if (searchInput) {
searchInput.value = "";
}
this.query = "";
updateURLParams({
q: this.query,
});
this.dispatchEvent(new LibraryPageSearchReset());
}
#dispatchSelected = (apps: FuseResult<Application>[]) => {
this.dispatchEvent(new LibraryPageSearchUpdated(apps.map((app) => app.item)));
};
#inputListener = (event: InputEvent) => {
this.query = (event.target as HTMLInputElement).value;
if (!this.query) {
return this.reset();
}
updateURLParams({
q: this.query,
});
const apps = this.fuse.search(this.query);
if (!apps.length) {
this.dispatchEvent(new LibraryPageSearchEmpty());
return;
}
this.#dispatchSelected(apps);
};
#keyDownListener = (event: KeyboardEvent) => {
switch (event.key) {
case "Escape": {
event.preventDefault();
this.reset();
return;
}
case "Enter": {
event.preventDefault();
this.dispatchEvent(new LibraryPageSearchSelected());
return;
}
}
};
render() {
return html`<input
${ref(this.searchInput)}
part="search-input"
name="application-search"
@input=${this.#inputListener}
@keydown=${this.#keyDownListener}
type="search"
class="pf-c-form-control"
autofocus
aria-label=${msg("Application search")}
placeholder=${msg("Search for an application by name...")}
value=${ifPresent(this.query)}
/>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-library-list-search": LibraryPageApplicationSearch;
}
}

View File

@@ -16,7 +16,8 @@
flex: 1 1 auto;
}
.search-container {
search {
display: flex;
flex: 0 0 36ch;
@container (width < 650px) {
@@ -26,6 +27,10 @@
font-size: var(--pf-global--FontSize--md);
}
}
form {
flex: 1 1 auto;
}
}
}
@@ -37,3 +42,32 @@
background-color: transparent;
padding-inline: 0;
}
input[name="application-search"] {
background-color: transparent;
display: block;
width: 100%;
font-size: var(--pf-global--FontSize--xl);
border-inline: none;
border-block-start: none;
&:focus,
&:hover {
--pf-c-form-control--BorderBottomColor: var(--ak-accent);
}
/**
* Despite the misleading name, this refers to the chevron appearing next to the
* search input, which we want to hide since it overlaps with the native search reset icon.
*/
&::-webkit-calendar-picker-indicator,
&::-webkit-list-button {
display: none !important;
-webkit-appearance: none;
}
@media not (prefers-contrast: more) {
outline: none;
}
}

View File

@@ -1,17 +1,10 @@
import "#elements/EmptyState";
import "#user/LibraryApplication/index";
import "./ak-library-application-empty-list.js";
import "./ak-library-application-list.js";
import "./ak-library-application-search-empty.js";
import "./ak-library-application-search.js";
import Styles from "./ak-library-impl.css";
import {
LibraryPageSearchEmpty,
LibraryPageSearchReset,
LibraryPageSearchSelected,
LibraryPageSearchUpdated,
} from "./events.js";
import AKLibraryApplicationListStyles from "./ApplicationList.css";
import { AKLibraryApplicationList } from "./ApplicationList.js";
import { appHasLaunchUrl } from "./LibraryPageImpl.utils.js";
import type { PageUIConfig } from "./types.js";
@@ -19,20 +12,34 @@ import { groupBy } from "#common/utils";
import { AKSkipToContent } from "#elements/a11y/ak-skip-to-content";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import { intersectionObserver } from "#elements/decorators/intersection-observer";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { ifPresent } from "#elements/utils/attributes";
import { FocusTarget } from "#elements/utils/focus";
import { isInteractiveElement } from "#elements/utils/interactivity";
import type { Application } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import Fuse from "fuse.js";
import { msg, str } from "@lit/localize";
import { html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css";
/**
* List of Applications available
@@ -54,9 +61,19 @@ export class LibraryPage extends AKElement {
PFEmptyState,
PFPage,
PFContent,
PFFormControl,
PFButton,
PFCard,
PFDivider,
PFDropdown,
PFGrid,
PFSpacing,
AKLibraryApplicationListStyles,
Styles,
];
//#region Properties
/**
* Controls showing the "Switch to Admin" button.
*
@@ -65,13 +82,23 @@ export class LibraryPage extends AKElement {
@property({ type: Boolean })
public admin = false;
#applications: Application[] = [];
/**
* The *complete* list of applications for this user. Not paginated.
*
* @attr
*/
@property({ attribute: false, type: Array })
public apps: Application[] = [];
public get apps(): Application[] {
return this.#applications;
}
public set apps(value: Application[]) {
this.#applications = value;
this.fuse.setCollection(this.searchEnabled ? this.#applications : []);
}
/**
* The aggregate uiConfig, derived from user, brand, and instance data.
@@ -81,140 +108,299 @@ export class LibraryPage extends AKElement {
@property({ attribute: false })
public uiConfig!: PageUIConfig;
@state()
protected selectedApp: Application | null = null;
public get searchEnabled(): boolean {
return this.uiConfig?.searchEnabled ?? true;
}
//#endregion
//#region State
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
public override focus = () => this.autofocusTarget.focus({ preventScroll: true });
protected get selectedApp(): Application | null {
if (!this.query) return null;
return this.visibleApplications[0] || null;
}
@intersectionObserver()
public visible = false;
@state()
filteredApps: Application[] = [];
protected visibleApplications: Application[] = [];
/**
* The active element to select when the user presses Enter outside of a form.
*/
protected targetRef = createRef<HTMLElement>();
#query: string | null = null;
protected get query(): string | null {
return this.#query;
}
protected set query(nextQuery: string | null) {
this.#query = nextQuery;
if (nextQuery && this.searchEnabled) {
this.visibleApplications = this.fuse
.search(nextQuery)
.map((result) => result.item)
.filter(appHasLaunchUrl);
} else {
this.visibleApplications = this.apps.filter(appHasLaunchUrl);
}
updateURLParams({
q: this.#query,
});
}
protected fuse = new Fuse<Application>([], {
keys: [
{ name: "name", weight: 3 },
"slug",
"group",
{ name: "metaDescription", weight: 0.5 },
{ name: "metaPublisher", weight: 0.5 },
],
findAllMatches: true,
includeScore: true,
shouldSort: true,
ignoreFieldNorm: true,
useExtendedSearch: true,
threshold: 0.3,
});
public pageTitle = msg("My Applications");
connectedCallback() {
//#region Lifecycle
public override connectedCallback() {
super.connectedCallback();
this.filteredApps = this.apps;
if (this.filteredApps === undefined) {
throw new Error(
"Application.results should never be undefined when passed to the Library Page.",
);
}
this.addEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated);
this.addEventListener(LibraryPageSearchReset.eventName, this.searchReset);
this.addEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty);
this.addEventListener(LibraryPageSearchSelected.eventName, this.launchRequest);
this.query = getURLParam<string | null>("q", "");
this.addEventListener(
"focus",
this.autofocusTarget.toEventListener({
preventScroll: true,
}),
);
document.addEventListener("visibilitychange", this.#visibilityListener);
window.addEventListener("keydown", this.#rootKeyDownListener);
}
disconnectedCallback() {
this.removeEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated);
this.removeEventListener(LibraryPageSearchReset.eventName, this.searchReset);
this.removeEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty);
this.removeEventListener(LibraryPageSearchSelected.eventName, this.launchRequest);
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("visibilitychange", this.#visibilityListener);
window.removeEventListener("keydown", this.#rootKeyDownListener);
}
@bound
searchUpdated(event: LibraryPageSearchUpdated) {
event.stopPropagation();
const apps = event.apps;
if (apps.length <= 0) {
throw new Error(
"LibaryPageSearchUpdated had empty results body. This must not happen.",
);
}
this.filteredApps = apps;
this.selectedApp = apps[0];
public override firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
requestAnimationFrame(() => {
this.focus();
const { target } = this.autofocusTarget;
if (!target) return;
// Place cursor at end of input.
target.selectionEnd = target.value.length;
target.selectionStart = target.value.length;
});
}
@bound
launchRequest(event: LibraryPageSearchSelected) {
event.stopPropagation();
if (!this.selectedApp?.launchUrl) {
//#endregion
//#region Event Listeners
#inputListener = (event: KeyboardEvent) => {
const inputElement = event.target as HTMLInputElement;
this.query = inputElement.value;
};
#changeListener = () => {
if (this.targetRef.value && this.visibleApplications.length === 1) {
this.targetRef.value.focus();
this.targetRef.value.click();
return;
}
if (!this.selectedApp.openInNewTab) {
window.location.assign(this.selectedApp?.launchUrl);
} else {
window.open(this.selectedApp.launchUrl);
};
#submitListener = (event: SubmitEvent) => {
event.preventDefault();
if (this.targetRef.value) {
this.targetRef.value.focus();
this.targetRef.value.click();
return;
}
}
};
@bound
searchReset(event: LibraryPageSearchReset) {
event.stopPropagation();
this.filteredApps = this.apps;
this.selectedApp = null;
}
#rootKeyDownListener = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.key !== "Enter") {
return;
}
@bound
searchEmpty(event: LibraryPageSearchEmpty) {
event.stopPropagation();
this.filteredApps = [];
this.selectedApp = null;
}
if (this.autofocusTarget.target?.matches(":focus")) {
// Let the input handle the event.
return;
}
if (this.renderRoot instanceof ShadowRoot) {
const focusedElement = this.renderRoot.activeElement;
if (isInteractiveElement(focusedElement)) {
focusedElement.click();
}
}
};
#visibilityListener = () => {
if (document.visibilityState !== "visible") return;
if (!this.visible) return;
this.focus();
};
//#endregion
//#region Rendering
renderApps() {
const { selectedApp } = this;
const { layout, background } = this.uiConfig;
const groupedApps = groupBy(
this.filteredApps.filter(appHasLaunchUrl),
(app) => app.group || "",
).sort(([groupLabelA, groupAppsA], [groupLabelB, groupAppsB]) => {
if (selectedApp) {
if (groupAppsA.includes(selectedApp)) return -1;
if (groupAppsB.includes(selectedApp)) return 1;
}
const groupedApps = groupBy(this.visibleApplications, (app) => app.group || "").sort(
([groupLabelA, groupAppsA], [groupLabelB, groupAppsB]) => {
if (selectedApp) {
if (groupAppsA.includes(selectedApp)) return -1;
if (groupAppsB.includes(selectedApp)) return 1;
}
return groupLabelA.localeCompare(groupLabelB);
return groupLabelA.localeCompare(groupLabelB);
},
);
return AKLibraryApplicationList({
layout,
background,
selectedApp,
groupedApps,
targetRef: this.targetRef,
});
return html`<ak-library-application-list
layout=${layout}
background=${ifPresent(background)}
.selected=${ifPresent(selectedApp)}
.apps=${groupedApps}
></ak-library-application-list>`;
}
renderSearch() {
return html`<ak-library-application-search
class="search-container"
.apps=${this.apps}
></ak-library-application-search>`;
protected renderSearch() {
return html`<search title=${msg("Applications")}>
<form @submit=${this.#submitListener} id="application-search-form">
<input
${this.autofocusTarget.toRef()}
part="search-input"
name="application-search"
id="application-search-input"
@input=${this.#inputListener}
@change=${this.#changeListener}
type="search"
autocomplete="off"
spellcheck="false"
class="pf-c-form-control"
autofocus
placeholder=${msg("Search for an application by name...")}
value=${ifPresent(this.query)}
list="application-search-options"
/>
<datalist id="application-search-options">
${repeat(
this.visibleApplications,
(application) => application.pk,
(app) => {
return html`<option value=${app.name}></option>`;
},
)}
</datalist>
</form>
</search>`;
}
renderNoAppsFound() {
return html`<ak-library-application-search-empty></ak-library-application-search-empty>`;
protected renderNoAppsFound() {
return html`<div class="pf-c-empty-state pf-m-full-height" tabindex="-1">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h3 class="pf-c-title pf-m-lg" id="no-results-title">
${msg("Search returned no results.")}
</h3>
</div>
</div>`;
}
renderSearchEmpty() {
return nothing;
}
renderState() {
protected renderState() {
if (!this.apps.some(appHasLaunchUrl)) {
return html`<ak-library-application-empty-list
?admin=${this.admin}
></ak-library-application-empty-list>`;
}
return this.filteredApps.some(appHasLaunchUrl) // prettier-ignore
? this.renderApps()
: this.renderNoAppsFound();
if (this.visibleApplications.length) {
return this.renderApps();
}
return this.renderNoAppsFound();
}
render() {
public override render() {
const count = this.visibleApplications.length;
const { query } = this;
let message: string;
if (query) {
// We must present the count within the label to ensure that the screen reader
// considers the update significant enough to read on each change,
// rather than the on just the first render.
message =
count === 1
? msg(str`${count} application found for "${query}"`)
: msg(str`${count} applications found for "${query}"`);
} else {
message =
count === 1
? msg(str`${count} application available`)
: msg(str`${count} applications available`);
}
return html`<div class="pf-c-page__main">
<div class="pf-c-page__header pf-c-content">
<h1 class="pf-c-page__title">${msg("My applications")}</h1>
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
${this.searchEnabled ? this.renderSearch() : nothing}
</div>
<main
${AKSkipToContent.ref}
tabindex="-1"
id="main-content"
class="pf-c-page__main-section"
aria-label=${msg("Application list")}
>
<output
class="sr-only"
for="application-search-input"
form="application-search-form"
aria-live="polite"
>
<p>${message}</p>
</output>
${this.renderState()}
</main>
</div>`;
}
//#endregion
}

View File

@@ -34,6 +34,8 @@ const coreApi = () => new CoreApi(DEFAULT_CONFIG);
@localized()
@customElement("ak-library")
export class LibraryPage extends AKElement {
static shadowRootOptions = { ...AKElement.shadowRootOptions, delegatesFocus: true };
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}

View File

@@ -1,72 +0,0 @@
import type { Application } from "@goauthentik/api";
/**
* @class LibraryPageSearchUpdated
*
* Indicates that the user has made a query that resulted in some
* applications being filtered-for.
*
*/
export class LibraryPageSearchUpdated extends Event {
static readonly eventName = "authentik.library.search-updated";
/**
* @attr apps: The list of those entries found by the current search.
*/
constructor(public apps: Application[]) {
super(LibraryPageSearchUpdated.eventName, { composed: true, bubbles: true });
}
}
/**
* @class LibraryPageSearchReset
*
* Indicates that the user has emptied the search field. Intended to
* signal that all available apps are to be displayed.
*
*/
export class LibraryPageSearchReset extends Event {
static readonly eventName = "authentik.library.search-reset";
constructor() {
super(LibraryPageSearchReset.eventName, { composed: true, bubbles: true });
}
}
/**
* @class LibraryPageSearchEmpty
*
* Indicates that the user has made a query that resulted in an empty
* list being returned. Intended to signal that an alternative "No
* matching applications found" message be displayed.
*
*/
export class LibraryPageSearchEmpty extends Event {
static readonly eventName = "authentik.library.search-empty";
constructor() {
super(LibraryPageSearchEmpty.eventName, { composed: true, bubbles: true });
}
}
/**
* @class LibraryPageSearchEmpty
*
* Indicates that the user has pressed "Enter" while focused on the
* search box. Intended to signal that the currently highlighted search
* entry (if any) should be activated.
*
*/
export class LibraryPageSearchSelected extends Event {
static readonly eventName = "authentik.library.search-item-selected";
constructor() {
super(LibraryPageSearchSelected.eventName, { composed: true, bubbles: true });
}
}
declare global {
interface GlobalEventHandlersEventMap {
[LibraryPageSearchUpdated.eventName]: LibraryPageSearchUpdated;
[LibraryPageSearchReset.eventName]: LibraryPageSearchReset;
[LibraryPageSearchEmpty.eventName]: LibraryPageSearchEmpty;
[LibraryPageSearchSelected.eventName]: LibraryPageSearchSelected;
}
}

View File

@@ -261,6 +261,10 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
//
@customElement("ak-interface-user")
export class UserInterface extends WithBrandConfig(AuthenticatedInterface) {
public static shadowRootOptions = { ...AKElement.shadowRootOptions, delegatesFocus: true };
public override tabIndex = -1;
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);

View File

@@ -75,7 +75,7 @@ export class UserSettingsPage extends AKElement {
@media screen and (min-width: 1200px) {
:host {
width: 90rem;
width: 90rem;
max-width: 100%;
align-self: center;
}
}

View File

@@ -1,5 +1,4 @@
//go:build outpost_static_embed
// +build outpost_static_embed
package web

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