mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 19:06:39 +02:00
Compare commits
76 Commits
sdko/for-t
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fedc3d0a0 | ||
|
|
7f0b45f921 | ||
|
|
3905c281ad | ||
|
|
e6099d43f5 | ||
|
|
a91145bc7b | ||
|
|
3f38d5c7d9 | ||
|
|
c00df0573c | ||
|
|
c3a0edee00 | ||
|
|
8b81ca36ea | ||
|
|
698de68a36 | ||
|
|
db35593b24 | ||
|
|
445fa31b57 | ||
|
|
a9aa1bf2c2 | ||
|
|
d018f0381c | ||
|
|
7dd1cd5c59 | ||
|
|
c219a6804a | ||
|
|
d9310d04b0 | ||
|
|
f471ef0e2e | ||
|
|
31a010c108 | ||
|
|
96e6ab291e | ||
|
|
ebf68311c2 | ||
|
|
fd365b2a09 | ||
|
|
41104da41f | ||
|
|
7edebdec03 | ||
|
|
fb56a54eb1 | ||
|
|
31cd6eb8ce | ||
|
|
092c5eb33c | ||
|
|
3e41bba54d | ||
|
|
9f8fd6eabe | ||
|
|
35fb55da15 | ||
|
|
b1d571a5af | ||
|
|
fb589592b5 | ||
|
|
6468bb5707 | ||
|
|
70406664dc | ||
|
|
c58c194180 | ||
|
|
fad87741e7 | ||
|
|
f6679895e5 | ||
|
|
a573a72ecb | ||
|
|
b72709ebbc | ||
|
|
449742fbc0 | ||
|
|
1b02cc0dae | ||
|
|
b0945ee7e9 | ||
|
|
6682136af1 | ||
|
|
24cb5ae4c1 | ||
|
|
9e272c7121 | ||
|
|
5dc7b7cdae | ||
|
|
2e2c52e49c | ||
|
|
38f1ef0506 | ||
|
|
3517562549 | ||
|
|
cdbe40143d | ||
|
|
5816f0d17c | ||
|
|
907ea8b2e9 | ||
|
|
b38af89960 | ||
|
|
d52db187bf | ||
|
|
2093e0e63f | ||
|
|
2791d87ceb | ||
|
|
fdc3d95b59 | ||
|
|
de7a61cee0 | ||
|
|
f2805b9b8a | ||
|
|
f48a91fbf4 | ||
|
|
f056c0808d | ||
|
|
06a6d45139 | ||
|
|
0e12642f12 | ||
|
|
01406d364e | ||
|
|
b9b16dba59 | ||
|
|
1ef83f3295 | ||
|
|
343506d104 | ||
|
|
aeb4e1057e | ||
|
|
0bcd1c268c | ||
|
|
ecba1ffe94 | ||
|
|
b7d303936c | ||
|
|
c1bc2a4565 | ||
|
|
1422c3aff3 | ||
|
|
d4a77583ea | ||
|
|
78d270bf25 | ||
|
|
6d1c7f90e2 |
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -142,7 +142,9 @@ updates:
|
||||
labels:
|
||||
- dependencies
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
directories:
|
||||
- /
|
||||
- /website
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
|
||||
1
.github/workflows/api-ts-publish.yml
vendored
1
.github/workflows/api-ts-publish.yml
vendored
@@ -15,7 +15,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
1
.github/workflows/ci-docs-source.yml
vendored
1
.github/workflows/ci-docs-source.yml
vendored
@@ -13,7 +13,6 @@ env:
|
||||
|
||||
jobs:
|
||||
publish-source-docs:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
2
.github/workflows/ci-docs.yml
vendored
2
.github/workflows/ci-docs.yml
vendored
@@ -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' || '[]' }}
|
||||
|
||||
1
.github/workflows/ci-main-daily.yml
vendored
1
.github/workflows/ci-main-daily.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
|
||||
jobs:
|
||||
test-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
10
.github/workflows/ci-main.yml
vendored
10
.github/workflows/ci-main.yml
vendored
@@ -80,7 +80,15 @@ jobs:
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Previous stable tag
|
||||
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Current version family based on
|
||||
current_version_family=$(python -c "from authentik import VERSION; print(VERSION)" | grep -vE -- 'rc[0-9]+$')
|
||||
if [[ -n $current_version_family ]]; then
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout $(prev_stable)
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: Setup authentik env (stable)
|
||||
|
||||
1
.github/workflows/ci-outpost.yml
vendored
1
.github/workflows/ci-outpost.yml
vendored
@@ -67,7 +67,6 @@ jobs:
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-container:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
|
||||
@@ -13,7 +13,6 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
15
.github/workflows/gh-ghcr-retention.yml
vendored
15
.github/workflows/gh-ghcr-retention.yml
vendored
@@ -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 }}
|
||||
|
||||
1
.github/workflows/packages-npm-publish.yml
vendored
1
.github/workflows/packages-npm-publish.yml
vendored
@@ -19,7 +19,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
1
.github/workflows/release-next-branch.yml
vendored
1
.github/workflows/release-next-branch.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-next:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@@ -87,7 +87,7 @@ jobs:
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
|
||||
22
.github/workflows/repo-mirror-cleanup.yml
vendored
22
.github/workflows/repo-mirror-cleanup.yml
vendored
@@ -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 }}
|
||||
21
.github/workflows/repo-mirror.yml
vendored
21
.github/workflows/repo-mirror.yml
vendored
@@ -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 }}
|
||||
1
.github/workflows/repo-stale.yml
vendored
1
.github/workflows/repo-stale.yml
vendored
@@ -12,7 +12,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
@@ -17,7 +17,6 @@ env:
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -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" \
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.10.0-rc1"
|
||||
VERSION = "2025.10.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Test brands"""
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.brands.api import Themes
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Application
|
||||
@@ -23,6 +26,7 @@ class TestBrands(APITestCase):
|
||||
_flag = flag()
|
||||
if _flag.visibility == "public":
|
||||
self.default_flags[_flag.key] = _flag.get()
|
||||
Brand.objects.all().delete()
|
||||
|
||||
def test_current_brand(self):
|
||||
"""Test Current brand API"""
|
||||
@@ -44,7 +48,6 @@ class TestBrands(APITestCase):
|
||||
|
||||
def test_brand_subdomain(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.all().delete()
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
@@ -65,7 +68,6 @@ class TestBrands(APITestCase):
|
||||
|
||||
def test_fallback(self):
|
||||
"""Test fallback brand"""
|
||||
Brand.objects.all().delete()
|
||||
self.assertJSONEqual(
|
||||
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
|
||||
{
|
||||
@@ -81,6 +83,109 @@ class TestBrands(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
@apply_blueprint("default/default-brand.yaml")
|
||||
def test_blueprint(self):
|
||||
"""Test Current brand API"""
|
||||
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
@apply_blueprint("default/default-brand.yaml")
|
||||
def test_blueprint_with_other_brand(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_same_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom-strong",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_other_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom-weak")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom-strong")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="other.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom-weak",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_create_default_multiple(self):
|
||||
"""Test attempted creation of multiple default brands"""
|
||||
Brand.objects.create(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.db.models import Case, F, IntegerField, Q, Value, When
|
||||
from django.db.models.functions import Length
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -19,15 +19,36 @@ DEFAULT_BRAND = Brand(domain="fallback")
|
||||
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
db_brands = (
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||
.order_by("default")
|
||||
|
||||
brand = (
|
||||
Brand.objects.annotate(
|
||||
host_domain=Value(request.get_host()),
|
||||
domain_length=Length("domain"),
|
||||
match_priority=Case(
|
||||
When(
|
||||
condition=Q(host_domain__iendswith=F("domain")),
|
||||
then=F("domain_length"),
|
||||
),
|
||||
default=Value(-1),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
is_default_fallback=Case(
|
||||
When(
|
||||
condition=Q(default=True),
|
||||
then=Value(0),
|
||||
),
|
||||
default=Value(-2),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.filter(Q(match_priority__gt=-1) | Q(default=True))
|
||||
.order_by("-match_priority", "-is_default_fallback")
|
||||
.first()
|
||||
)
|
||||
brands = list(db_brands.all())
|
||||
if len(brands) < 1:
|
||||
|
||||
if brand is None:
|
||||
return DEFAULT_BRAND
|
||||
return brands[0]
|
||||
return brand
|
||||
|
||||
|
||||
def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.db import models
|
||||
from django.db.models import Q, QuerySet, options
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.http import HttpRequest
|
||||
from django.utils.functional import SimpleLazyObject, cached_property
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_cte import CTE, with_cte
|
||||
@@ -585,18 +585,16 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
if self.meta_launch_url:
|
||||
url = self.meta_launch_url
|
||||
elif provider := self.get_provider():
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
if isinstance(user, SimpleLazyObject):
|
||||
user._setup()
|
||||
user = user._wrapped
|
||||
try:
|
||||
return url % user.__dict__
|
||||
|
||||
return url % UserSerializer(instance=user).data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
49
authentik/enterprise/tests/test_metrics.py
Normal file
49
authentik/enterprise/tests/test_metrics.py
Normal 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,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
@@ -28,7 +28,7 @@ class LogEvent:
|
||||
def from_event_dict(item: EventDict) -> "LogEvent":
|
||||
event = item.pop("event")
|
||||
log_level = item.pop("level").lower()
|
||||
timestamp = datetime.fromisoformat(item.pop("timestamp"))
|
||||
timestamp = datetime.fromisoformat(item.pop("timestamp")).replace(tzinfo=UTC)
|
||||
item.pop("pid", None)
|
||||
# Sometimes log entries have both `level` and `log_level` set, but `level` is always set
|
||||
item.pop("log_level", None)
|
||||
|
||||
@@ -145,7 +145,6 @@ worker:
|
||||
consumer_listen_timeout: "seconds=30"
|
||||
task_max_retries: 5
|
||||
task_default_time_limit: "minutes=10"
|
||||
lock_purge_interval: "minutes=1"
|
||||
task_purge_interval: "days=1"
|
||||
task_expiration: "days=30"
|
||||
scheduler_interval: "seconds=60"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -126,6 +126,30 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_deactivate(self):
|
||||
"""test deactivated user"""
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"username": "sa",
|
||||
"password": self.token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": TokenError.errors["invalid_grant"],
|
||||
"request_id": response.headers["X-authentik-id"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_permission_denied(self):
|
||||
"""test permission denied"""
|
||||
group = Group.objects.create(name="foo")
|
||||
|
||||
@@ -336,7 +336,7 @@ class TokenParams:
|
||||
self, request: HttpRequest, username: str, password: str
|
||||
):
|
||||
# Authenticate user based on credentials
|
||||
user = User.objects.filter(username=username).first()
|
||||
user = User.objects.filter(username=username, is_active=True).first()
|
||||
if not user:
|
||||
raise TokenError("invalid_grant")
|
||||
token: Token = Token.filter_not_expired(
|
||||
@@ -378,9 +378,11 @@ class TokenParams:
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to parse JWT for kid lookup", exc=exc)
|
||||
raise TokenError("invalid_grant") from None
|
||||
expected_kid = decode_unvalidated["header"]["kid"]
|
||||
fallback_alg = decode_unvalidated["header"]["alg"]
|
||||
expected_kid = decode_unvalidated["header"].get("kid")
|
||||
fallback_alg = decode_unvalidated["header"].get("alg")
|
||||
token = source = None
|
||||
if not expected_kid or not fallback_alg:
|
||||
return None, None
|
||||
for source in self.provider.jwt_federation_sources.filter(
|
||||
oidc_jwks__keys__contains=[{"kid": expected_kid}]
|
||||
):
|
||||
|
||||
@@ -83,7 +83,7 @@ class EnterpriseUser(BaseModel):
|
||||
class User(BaseUser):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
model_config = ConfigDict(serialize_by_alias=True, extra="allow")
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_USER_SCHEMA]
|
||||
@@ -106,6 +106,8 @@ class User(BaseUser):
|
||||
class Group(BaseGroup):
|
||||
"""Modified Group schema with added externalId field"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str | int | None = None
|
||||
schemas: list[str] = [SCIM_GROUP_SCHEMA]
|
||||
externalId: str | None = None
|
||||
|
||||
@@ -95,7 +95,12 @@ class SCIMUserTests(TestCase):
|
||||
"""Test user creation with custom schema"""
|
||||
schema = SCIMMapping.objects.create(
|
||||
name="custom_schema",
|
||||
expression="""return {"schemas": ["foo"]}""",
|
||||
expression="""return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
|
||||
"startDate": "2024-04-10T00:00:00+0000",
|
||||
},
|
||||
}""",
|
||||
)
|
||||
self.provider.property_mappings.add(schema)
|
||||
scim_id = generate_id()
|
||||
@@ -121,7 +126,10 @@ class SCIMUserTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "foo"],
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
|
||||
],
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
@@ -138,6 +146,9 @@ class SCIMUserTests(TestCase):
|
||||
},
|
||||
"displayName": f"{uid} {uid}",
|
||||
"userName": uid,
|
||||
"urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
|
||||
"startDate": "2024-04-10T00:00:00+0000",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from hashlib import sha512
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
from django.http import response as http_response
|
||||
from sentry_sdk import set_tag
|
||||
from xmlsec import enable_debug_trace
|
||||
|
||||
@@ -379,9 +380,6 @@ DRAMATIQ = {
|
||||
"broker_class": "authentik.tasks.broker.Broker",
|
||||
"channel_prefix": "authentik",
|
||||
"task_model": "authentik.tasks.models.Task",
|
||||
"lock_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.lock_purge_interval")
|
||||
).total_seconds(),
|
||||
"task_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.task_purge_interval")
|
||||
).total_seconds(),
|
||||
@@ -429,6 +427,7 @@ DRAMATIQ = {
|
||||
},
|
||||
),
|
||||
("dramatiq.results.middleware.Results", {"store_results": True}),
|
||||
("authentik.tasks.middleware.StartupSignalsMiddleware", {}),
|
||||
("authentik.tasks.middleware.CurrentTask", {}),
|
||||
("authentik.tasks.middleware.TenantMiddleware", {}),
|
||||
("authentik.tasks.middleware.ModelDataMiddleware", {}),
|
||||
@@ -471,6 +470,12 @@ STORAGES = {
|
||||
},
|
||||
}
|
||||
|
||||
# Django 5.2.8 and CVE-2025-64458 added a strong enforcement of 2048 characters
|
||||
# as the maximum for a URL to redirect to, mostly for running on windows.
|
||||
# However our URLs can easily exceed that with OAuth/SAML Query parameters or hash values
|
||||
# 8192 should cover most cases..
|
||||
http_response.MAX_URL_LENGTH = http_response.MAX_URL_LENGTH * 4
|
||||
|
||||
|
||||
# Media files
|
||||
if CONFIG.get("storage.media.backend", "file") == "s3":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/"])
|
||||
|
||||
@@ -11,10 +11,10 @@ from authentik.stages.invitation.models import Invitation, InvitationStage
|
||||
from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
INVITATION_TOKEN_KEY_CONTEXT = "token" # nosec
|
||||
INVITATION_TOKEN_KEY = "itoken" # nosec
|
||||
INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
INVITATION = "invitation"
|
||||
QS_INVITATION_TOKEN_KEY = "itoken" # nosec
|
||||
PLAN_CONTEXT_INVITATION_TOKEN = "token" # nosec
|
||||
PLAN_CONTEXT_INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
PLAN_CONTEXT_INVITATION = "invitation"
|
||||
|
||||
|
||||
class InvitationStageView(StageView):
|
||||
@@ -23,13 +23,13 @@ class InvitationStageView(StageView):
|
||||
def get_token(self) -> str | None:
|
||||
"""Get token from saved get-arguments or prompt_data"""
|
||||
# Check for ?token= and ?itoken=
|
||||
if INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY]
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
if QS_INVITATION_TOKEN_KEY in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][QS_INVITATION_TOKEN_KEY]
|
||||
if PLAN_CONTEXT_INVITATION_TOKEN in self.request.session.get(SESSION_KEY_GET, {}):
|
||||
return self.request.session[SESSION_KEY_GET][PLAN_CONTEXT_INVITATION_TOKEN]
|
||||
# Check for {'token': ''} in the context
|
||||
if INVITATION_TOKEN_KEY_CONTEXT in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
if PLAN_CONTEXT_INVITATION_TOKEN in self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][PLAN_CONTEXT_INVITATION_TOKEN]
|
||||
return None
|
||||
|
||||
def get_invite(self) -> Invitation | None:
|
||||
@@ -38,7 +38,7 @@ class InvitationStageView(StageView):
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
invite: Invitation = Invitation.objects.filter(pk=token).first()
|
||||
invite: Invitation | None = Invitation.filter_not_expired(pk=token).first()
|
||||
except ValidationError:
|
||||
self.logger.debug("invalid invitation", token=token)
|
||||
return None
|
||||
@@ -60,8 +60,8 @@ class InvitationStageView(StageView):
|
||||
return self.executor.stage_ok()
|
||||
return self.executor.stage_invalid(_("Invalid invite/invite not found"))
|
||||
|
||||
self.executor.plan.context[INVITATION_IN_EFFECT] = True
|
||||
self.executor.plan.context[INVITATION] = invite
|
||||
self.executor.plan.context[PLAN_CONTEXT_INVITATION_IN_EFFECT] = True
|
||||
self.executor.plan.context[PLAN_CONTEXT_INVITATION] = invite
|
||||
|
||||
context = {}
|
||||
always_merger.merge(context, self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""invitation tests"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -16,9 +18,9 @@ from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.invitation.models import Invitation, InvitationStage
|
||||
from authentik.stages.invitation.stage import (
|
||||
INVITATION_TOKEN_KEY,
|
||||
INVITATION_TOKEN_KEY_CONTEXT,
|
||||
PLAN_CONTEXT_INVITATION_TOKEN,
|
||||
PLAN_CONTEXT_PROMPT,
|
||||
QS_INVITATION_TOKEN_KEY,
|
||||
)
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
@@ -77,6 +79,31 @@ class TestInvitationStage(FlowTestCase):
|
||||
self.stage.continue_flow_without_invitation = False
|
||||
self.stage.save()
|
||||
|
||||
def test_with_invitation_expired(self):
|
||||
"""Test with invitation, expired"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
data = {"foo": "bar"}
|
||||
invite = Invitation.objects.create(
|
||||
created_by=get_anonymous_user(),
|
||||
fixed_data=data,
|
||||
expires=now() - timedelta(hours=1),
|
||||
)
|
||||
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-access-denied",
|
||||
)
|
||||
|
||||
def test_with_invitation_get(self):
|
||||
"""Test with invitation, check data in session"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
@@ -89,7 +116,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
|
||||
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
session = self.client.session
|
||||
@@ -114,7 +141,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
|
||||
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
|
||||
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
args = urlencode({QS_INVITATION_TOKEN_KEY: invite.pk.hex})
|
||||
response = self.client.get(base_url + f"?query={args}")
|
||||
|
||||
session = self.client.session
|
||||
@@ -134,7 +161,7 @@ class TestInvitationStage(FlowTestCase):
|
||||
)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {INVITATION_TOKEN_KEY_CONTEXT: invite.pk.hex}
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {PLAN_CONTEXT_INVITATION_TOKEN: invite.pk.hex}
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
@@ -306,7 +306,14 @@ class Prompt(SerializerModel):
|
||||
|
||||
if self.type in CHOICE_FIELDS:
|
||||
field_class = ChoiceField
|
||||
kwargs["choices"] = choices or []
|
||||
kwargs["choices"] = []
|
||||
if choices:
|
||||
for choice in choices:
|
||||
label, value = choice, choice
|
||||
if isinstance(choice, dict):
|
||||
label = choice.get("label", "")
|
||||
value = choice.get("value", "")
|
||||
kwargs["choices"].append((value, label))
|
||||
|
||||
if default:
|
||||
kwargs["default"] = default
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -2,9 +2,10 @@ import pickle # nosec
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from dramatiq.actor import Actor
|
||||
from psqlextra.query import ConflictAction
|
||||
from psqlextra.types import ConflictAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
@@ -15,7 +16,7 @@ class ScheduleSpec:
|
||||
actor: Actor
|
||||
crontab: str
|
||||
paused: bool = False
|
||||
identifier: str | None = None
|
||||
identifier: str | UUID | None = None
|
||||
uid: str | None = None
|
||||
|
||||
args: Iterable[Any] = field(default_factory=tuple)
|
||||
@@ -41,6 +42,8 @@ class ScheduleSpec:
|
||||
return pickle.dumps(options)
|
||||
|
||||
def update_or_create(self) -> "Schedule":
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
|
||||
update_values = {
|
||||
@@ -50,10 +53,12 @@ class ScheduleSpec:
|
||||
"kwargs": self.get_kwargs(),
|
||||
"options": self.get_options(),
|
||||
}
|
||||
if self.rel_obj is not None:
|
||||
update_values["rel_obj_content_type"] = ContentType.objects.get_for_model(self.rel_obj)
|
||||
update_values["rel_obj_id"] = str(self.rel_obj.pk)
|
||||
create_values = {
|
||||
**update_values,
|
||||
"crontab": self.crontab,
|
||||
"rel_obj": self.rel_obj,
|
||||
}
|
||||
|
||||
schedule = Schedule.objects.on_conflict(
|
||||
@@ -62,7 +67,7 @@ class ScheduleSpec:
|
||||
update_values=update_values,
|
||||
).insert_and_get(
|
||||
actor_name=self.actor.actor_name,
|
||||
identifier=self.identifier,
|
||||
identifier=str(self.identifier),
|
||||
**create_values,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ def post_save_scheduled_model(sender, instance, **_):
|
||||
return
|
||||
for spec in instance.schedule_specs:
|
||||
spec.rel_obj = instance
|
||||
spec.identifier = instance.pk
|
||||
schedule = spec.update_or_create()
|
||||
if spec.send_on_save:
|
||||
schedule.send()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.10.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.10.2 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
||||
@@ -60,22 +60,6 @@ func checkServer() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitHostPort(address string) (host, port string) {
|
||||
lastColon := strings.LastIndex(address, ":")
|
||||
if lastColon == -1 {
|
||||
return address, ""
|
||||
}
|
||||
|
||||
host = address[:lastColon]
|
||||
port = address[lastColon+1:]
|
||||
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
func checkWorker() int {
|
||||
pidB, err := os.ReadFile(workerPidFile)
|
||||
if err != nil {
|
||||
@@ -98,41 +82,6 @@ func checkWorker() int {
|
||||
log.WithError(err).Warning("failed to signal worker process")
|
||||
return 1
|
||||
}
|
||||
h := &http.Client{
|
||||
Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
|
||||
}
|
||||
|
||||
host, port := splitHostPort(config.Get().Listen.HTTP)
|
||||
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "::1", port)
|
||||
_, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", "127.0.0.1", port)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).WithField("url", url).Warning("failed to send healthcheck request")
|
||||
return 1
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
url := fmt.Sprintf("http://%s:%s/-/health/ready/", host, port)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to send healthcheck request")
|
||||
return 1
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
log.WithField("status", res.StatusCode).Warning("unhealthy status code")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("successfully checked health")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.10.0-rc1
|
||||
2025.10.2
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.10.0-rc1
|
||||
Default: 2025.10.2
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -473,7 +473,7 @@ class PostgresChannelLayerReceiver:
|
||||
"""
|
||||
DELETE
|
||||
FROM {table}
|
||||
WHERE {table}.{channel} IN (%s)
|
||||
WHERE {table}.{channel} = ANY(%s)
|
||||
AND {table}.{expires} >= %s
|
||||
RETURNING {table}.{id}, {table}.{channel}, {table}.{message}
|
||||
"""
|
||||
@@ -484,7 +484,7 @@ class PostgresChannelLayerReceiver:
|
||||
expires=sql.Identifier("expires"),
|
||||
message=sql.Identifier("message"),
|
||||
),
|
||||
(tuple(self._subscribed_to), now()),
|
||||
(list(self._subscribed_to), now()),
|
||||
)
|
||||
async for row in cursor:
|
||||
message_id, channel, message = row
|
||||
|
||||
@@ -61,6 +61,7 @@ def raise_connection_error(func: Callable[P, R]) -> Callable[P, R]:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except DATABASE_ERRORS as exc:
|
||||
logger.warning("Database error encountered", exc=exc)
|
||||
raise ConnectionError(str(exc)) from exc # type: ignore[no-untyped-call]
|
||||
|
||||
return wrapper
|
||||
@@ -239,15 +240,18 @@ class _PostgresConsumer(Consumer):
|
||||
self.in_processing: set[str] = set()
|
||||
self.prefetch = prefetch
|
||||
self.misses = 0
|
||||
# We have two different connections here. One for locks and one for listening to
|
||||
# notifications. We can't use the same connection for both as the listen connection might
|
||||
# be blocked with pending notifications. We also can't use a Django connection as we can't
|
||||
# be sure we'll get the same one every time to be able to release locks from the same
|
||||
# connection.
|
||||
self._locks_connection: DatabaseWrapper | None = None
|
||||
self._listen_connection: DatabaseWrapper | None = None
|
||||
self.postgres_channel = channel_name(self.queue_name, ChannelIdentifier.ENQUEUE)
|
||||
|
||||
# Override because dramatiq doesn't allow us setting this manually
|
||||
self.timeout = Conf().worker["consumer_listen_timeout"]
|
||||
|
||||
self.lock_purge_interval = timedelta(seconds=Conf().lock_purge_interval)
|
||||
self.lock_purge_last_run = timezone.now()
|
||||
|
||||
self.task_purge_interval = timedelta(seconds=Conf().task_purge_interval)
|
||||
self.task_purge_last_run = timezone.now() - self.task_purge_interval
|
||||
|
||||
@@ -258,14 +262,17 @@ class _PostgresConsumer(Consumer):
|
||||
self.scheduler_interval = timedelta(seconds=Conf().scheduler_interval)
|
||||
self.scheduler_last_run = timezone.now() - self.scheduler_interval
|
||||
|
||||
@property
|
||||
def connection(self) -> DatabaseWrapper:
|
||||
return cast(DatabaseWrapper, connections[self.db_alias])
|
||||
|
||||
@property
|
||||
def query_set(self) -> QuerySet[TaskBase]:
|
||||
return self.broker.query_set
|
||||
|
||||
@property
|
||||
def locks_connection(self) -> DatabaseWrapper:
|
||||
if self._locks_connection is not None and self._locks_connection.is_usable():
|
||||
return self._locks_connection
|
||||
self._locks_connection = cast(DatabaseWrapper, connections.create_connection(self.db_alias))
|
||||
return self._locks_connection
|
||||
|
||||
@property
|
||||
def listen_connection(self) -> DatabaseWrapper:
|
||||
if self._listen_connection is not None and self._listen_connection.is_usable():
|
||||
@@ -320,21 +327,40 @@ class _PostgresConsumer(Consumer):
|
||||
self.logger.debug("Message already consumed by self", message_id=message_id)
|
||||
return None
|
||||
|
||||
lock_result = (
|
||||
self.query_set.filter(message_id=message_id)
|
||||
.exclude(state__in=(TaskState.DONE, TaskState.REJECTED))
|
||||
.exclude(eta__gte=timezone.now() + timedelta(seconds=self.timeout))
|
||||
.extra(
|
||||
where=["pg_try_advisory_lock(%s)"],
|
||||
params=[self._get_message_lock_id(message_id)],
|
||||
with self.locks_connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql.SQL(
|
||||
"""
|
||||
UPDATE {table}
|
||||
SET {state} = %(state)s, {mtime} = %(mtime)s
|
||||
WHERE
|
||||
{table}.{message_id} = %(message_id)s
|
||||
AND
|
||||
{table}.{state} != ALL(%(excluded_states)s)
|
||||
AND
|
||||
({table}.{eta} < %(maximum_eta)s OR {table}.{eta} IS NULL)
|
||||
AND
|
||||
pg_try_advisory_lock(%(lock_id)s)
|
||||
"""
|
||||
).format(
|
||||
table=sql.Identifier(self.query_set.model._meta.db_table),
|
||||
state=sql.Identifier("state"),
|
||||
mtime=sql.Identifier("mtime"),
|
||||
message_id=sql.Identifier("message_id"),
|
||||
eta=sql.Identifier("eta"),
|
||||
),
|
||||
{
|
||||
"state": TaskState.CONSUMED.value,
|
||||
"mtime": timezone.now(),
|
||||
"message_id": message_id,
|
||||
"excluded_states": [TaskState.DONE.value, TaskState.REJECTED.value],
|
||||
"maximum_eta": timezone.now() + timedelta(seconds=self.timeout),
|
||||
"lock_id": self._get_message_lock_id(message_id),
|
||||
},
|
||||
)
|
||||
.update(
|
||||
state=TaskState.CONSUMED,
|
||||
mtime=timezone.now(),
|
||||
)
|
||||
)
|
||||
if lock_result != 1:
|
||||
return None
|
||||
if cursor.rowcount != 1:
|
||||
self._unlock_message(message_id)
|
||||
return None
|
||||
|
||||
task: TaskBase | None = (
|
||||
self.query_set.defer(None).defer("result").filter(message_id=message_id).first()
|
||||
@@ -405,9 +431,10 @@ class _PostgresConsumer(Consumer):
|
||||
def _unlock_message(self, message_id: str) -> bool:
|
||||
self.logger.debug("Unlocking message", message_id=message_id)
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
with self.locks_connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT pg_advisory_unlock(%s)", (self._get_message_lock_id(message_id),)
|
||||
"SELECT pg_advisory_unlock(%s)",
|
||||
(self._get_message_lock_id(message_id),),
|
||||
)
|
||||
return True
|
||||
except DATABASE_ERRORS:
|
||||
@@ -420,7 +447,7 @@ class _PostgresConsumer(Consumer):
|
||||
self.in_processing.remove(str(message.message_id))
|
||||
except KeyError:
|
||||
pass
|
||||
self._unlock_message(str(message.message_id))
|
||||
self.to_unlock.add(str(message.message_id))
|
||||
task = message.options.pop("task", None)
|
||||
self.query_set.filter(
|
||||
message_id=message.message_id,
|
||||
@@ -453,7 +480,6 @@ class _PostgresConsumer(Consumer):
|
||||
for message in messages:
|
||||
self.to_unlock.add(str(message.message_id))
|
||||
self.in_processing.remove(str(message.message_id))
|
||||
self._purge_locks()
|
||||
|
||||
def _scheduler(self) -> None:
|
||||
if not self.scheduler:
|
||||
@@ -464,8 +490,6 @@ class _PostgresConsumer(Consumer):
|
||||
self.schedule_last_run = timezone.now()
|
||||
|
||||
def _purge_locks(self) -> None:
|
||||
if timezone.now() - self.lock_purge_last_run < self.lock_purge_interval:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
message_id = self.to_unlock.pop()
|
||||
@@ -473,7 +497,6 @@ class _PostgresConsumer(Consumer):
|
||||
break
|
||||
if not self._unlock_message(str(message_id)):
|
||||
return
|
||||
self.lock_purge_last_run = timezone.now()
|
||||
|
||||
def _auto_purge(self) -> None:
|
||||
if timezone.now() - self.task_purge_last_run < self.task_purge_interval:
|
||||
@@ -492,15 +515,17 @@ class _PostgresConsumer(Consumer):
|
||||
try:
|
||||
self._purge_locks()
|
||||
finally:
|
||||
try:
|
||||
self.connection.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
finally:
|
||||
if self._listen_connection is not None:
|
||||
conn = self._listen_connection
|
||||
self._listen_connection = None
|
||||
try:
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
if self._locks_connection is not None:
|
||||
conn = self._locks_connection
|
||||
self._locks_connection = None
|
||||
try:
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
if self._listen_connection is not None:
|
||||
conn = self._listen_connection
|
||||
self._listen_connection = None
|
||||
try:
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
|
||||
@@ -63,10 +63,6 @@ class Conf:
|
||||
def task_model(self) -> str:
|
||||
return cast(str, self.conf["task_model"])
|
||||
|
||||
@property
|
||||
def lock_purge_interval(self) -> int:
|
||||
return cast(int, self.conf.get("lock_purge_interval", 60))
|
||||
|
||||
@property
|
||||
def task_purge_interval(self) -> int:
|
||||
# 24 hours
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ classifiers = [
|
||||
|
||||
dependencies = [
|
||||
"django >=4.2,<6.0",
|
||||
"django-postgres-extra >=2.0,<2.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.10.0-rc1"
|
||||
version = "2025.10.2"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
@@ -11,7 +11,7 @@ dependencies = [
|
||||
"dacite==1.9.2",
|
||||
"deepmerge==2.0",
|
||||
"defusedxml==0.7.1",
|
||||
"django==5.2.7",
|
||||
"django==5.2.8",
|
||||
"django-channels-postgres",
|
||||
"django-countries==7.6.1",
|
||||
"django-cte==2.0.0",
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.10.0-rc1
|
||||
version: 2025.10.2
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
||||
@@ -30,7 +30,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
super().setUp()
|
||||
self.ssl_folder = mkdtemp()
|
||||
self.run_container(
|
||||
image="library/docker:dind",
|
||||
image="docker.io/library/docker:28.5.2-dind-alpine3.22",
|
||||
network_mode="host",
|
||||
privileged=True,
|
||||
healthcheck=Healthcheck(
|
||||
|
||||
@@ -30,7 +30,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
super().setUp()
|
||||
self.ssl_folder = mkdtemp()
|
||||
self.run_container(
|
||||
image="library/docker:dind",
|
||||
image="docker.io/library/docker:28.5.2-dind-alpine3.22",
|
||||
network_mode="host",
|
||||
privileged=True,
|
||||
healthcheck=Healthcheck(
|
||||
|
||||
20
uv.lock
generated
20
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
[manifest]
|
||||
@@ -170,7 +170,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.10.0rc1"
|
||||
version = "2025.10.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
@@ -284,7 +284,7 @@ requires-dist = [
|
||||
{ name = "dacite", specifier = "==1.9.2" },
|
||||
{ name = "deepmerge", specifier = "==2.0" },
|
||||
{ name = "defusedxml", specifier = "==0.7.1" },
|
||||
{ name = "django", specifier = "==5.2.7" },
|
||||
{ name = "django", specifier = "==5.2.8" },
|
||||
{ name = "django-channels-postgres", editable = "packages/django-channels-postgres" },
|
||||
{ name = "django-countries", specifier = "==7.6.1" },
|
||||
{ name = "django-cte", specifier = "==2.0.0" },
|
||||
@@ -977,16 +977,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.7"
|
||||
version = "5.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1141,10 +1141,14 @@ version = "0.1.0"
|
||||
source = { editable = "packages/django-postgres-cache" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-postgres-extra" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "django", specifier = ">=4.2,<6.0" }]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=4.2,<6.0" },
|
||||
{ name = "django-postgres-extra", specifier = ">=2.0,<2.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-postgres-extra"
|
||||
@@ -1628,6 +1632,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
]
|
||||
|
||||
|
||||
141
web/package-lock.json
generated
141
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
@@ -1940,6 +1940,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz",
|
||||
"integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -2317,6 +2318,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2338,6 +2340,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz",
|
||||
"integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
@@ -2350,6 +2353,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2365,6 +2369,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz",
|
||||
"integrity": "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.204.0",
|
||||
"import-in-the-middle": "^1.8.1",
|
||||
@@ -2757,6 +2762,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz",
|
||||
"integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.1.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2773,6 +2779,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz",
|
||||
"integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.1.0",
|
||||
"@opentelemetry/resources": "2.1.0",
|
||||
@@ -2790,6 +2797,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
|
||||
"integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -3118,7 +3126,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz",
|
||||
"integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
@@ -3147,17 +3154,6 @@
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/instrumentation": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.15.0.tgz",
|
||||
@@ -4517,18 +4513,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.1.tgz",
|
||||
"integrity": "sha512-gRO+jk2ljxZlIn20QRskIvpLCMtzuLl5T0BY6L9uvPYD17uUrxlxWkvYCiVqED2q2q7CVtY52Uex4WcYo2FEXw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.2.1",
|
||||
"node-gyp-build": "^4.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-reference": {
|
||||
"version": "1.0.0-beta.30",
|
||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.30.tgz",
|
||||
@@ -4644,6 +4628,7 @@
|
||||
"integrity": "sha512-V1r4wFdjaZIUIZZrV2Mb/prEeu03xvSm6oatPxsvnXKF9lNh5Jtk9QvUdiVfD9rrvi7bXrAVhg9Wpbmv/2Fl1g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.25"
|
||||
@@ -5012,6 +4997,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz",
|
||||
"integrity": "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.26.5",
|
||||
"@babel/parser": "^7.26.7",
|
||||
@@ -5481,6 +5467,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.0.tgz",
|
||||
"integrity": "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -5525,6 +5512,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -5634,6 +5622,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
|
||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
@@ -6080,6 +6069,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.11.tgz",
|
||||
"integrity": "sha512-U4iqPlHO0KQeK1mrsxCN0vZzw43/lL8POxgpzcJweopmqtoYy9nljJzWDIQS3EfjiYhfdtdk9Gtgz7MRXnz3GA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.5",
|
||||
"@vue/compiler-core": "3.3.11",
|
||||
@@ -6505,6 +6495,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7111,25 +7102,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -7393,6 +7365,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -7423,6 +7396,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||
"@chevrotain/gast": "11.0.3",
|
||||
@@ -7789,6 +7763,7 @@
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz",
|
||||
"integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
@@ -8189,6 +8164,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -8338,6 +8314,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -8565,7 +8542,6 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.1.tgz",
|
||||
"integrity": "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
}
|
||||
@@ -8575,7 +8551,6 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz",
|
||||
"integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
@@ -8937,6 +8912,7 @@
|
||||
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -9199,6 +9175,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
|
||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10689,7 +10666,6 @@
|
||||
"resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz",
|
||||
"integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/fisker/git-hooks-list?sponsor=1"
|
||||
}
|
||||
@@ -11150,6 +11126,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.9.12.tgz",
|
||||
"integrity": "sha512-SrTC0YxqPwnN7yKa8gg/giLyQ2pILCKoideIHbYbFQlWZjYt68D2A4Ae1hehO/aDQ6RmTcpqOV/O2yBtMzx/VQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -12029,7 +12006,8 @@
|
||||
"node_modules/jquery": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/js-levenshtein-esm": {
|
||||
"version": "2.0.0",
|
||||
@@ -12312,6 +12290,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
@@ -15044,6 +15023,18 @@
|
||||
"points-on-curve": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/popper.js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
@@ -15131,6 +15122,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -15427,6 +15419,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
|
||||
"integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ramda"
|
||||
@@ -15544,6 +15537,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -15553,6 +15547,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -16149,6 +16144,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
|
||||
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -16904,15 +16900,13 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz",
|
||||
"integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sort-package-json": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.4.0.tgz",
|
||||
"integrity": "sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-indent": "^7.0.1",
|
||||
"detect-newline": "^4.0.1",
|
||||
@@ -17053,6 +17047,7 @@
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
|
||||
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -17501,7 +17496,6 @@
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz",
|
||||
"integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pkgr/core": "^0.2.4"
|
||||
},
|
||||
@@ -17695,19 +17689,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz",
|
||||
"integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-json": {
|
||||
"version": "0.24.8",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz",
|
||||
@@ -17980,6 +17961,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17993,6 +17975,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
|
||||
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
@@ -18354,6 +18337,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
|
||||
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -18484,6 +18468,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -19163,6 +19148,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -19290,7 +19276,7 @@
|
||||
"@swc/core": "^1.13.19",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap": "^4.6.2",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
"jquery": "^3.7.1",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -19310,6 +19296,27 @@
|
||||
"@swc/core-win32-ia32-msvc": "^1.6.13",
|
||||
"@swc/core-win32-x64-msvc": "^1.6.13"
|
||||
}
|
||||
},
|
||||
"packages/sfe/node_modules/bootstrap": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
|
||||
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
|
||||
"deprecated": "This version of Bootstrap is no longer supported. Please upgrade to the latest version.",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jquery": "1.9.1 - 3",
|
||||
"popper.js": "^1.16.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.10.0-rc1",
|
||||
"version": "2025.10.2",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@swc/core": "^1.13.19",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap": "^4.6.2",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
"jquery": "^3.7.1",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,7 +119,10 @@ export class InvitationListPage extends TablePage<Invitation> {
|
||||
</ak-label>
|
||||
`
|
||||
: nothing}`,
|
||||
html`${item.createdBy?.username}`,
|
||||
html`<div>
|
||||
<a href="#/identity/users/${item.createdBy.pk}">${item.createdBy.username}</a>
|
||||
</div>
|
||||
<small>${item.createdBy.name}</small>`,
|
||||
html`${item.expires?.toLocaleString() || msg("-")}`,
|
||||
html` <ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
45
web/src/elements/AppIcon.css
Normal file
45
web/src/elements/AppIcon.css
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "#common/constants";
|
||||
import { CURRENT_CLASS, EVENT_REFRESH } from "#common/constants";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { getURLParams, updateURLParams } from "#elements/router/RouteMatch";
|
||||
@@ -7,8 +7,7 @@ import { isFocusable } from "#elements/utils/focus";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
|
||||
@@ -20,18 +19,6 @@ export class Tabs extends AKElement {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
#focusTargetRef = createRef<HTMLSlotElement>();
|
||||
|
||||
@property()
|
||||
pageIdentifier = "page";
|
||||
|
||||
@property()
|
||||
currentPage?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
vertical = false;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
PFGlobal,
|
||||
PFTabs,
|
||||
@@ -55,41 +42,106 @@ export class Tabs extends AKElement {
|
||||
`,
|
||||
];
|
||||
|
||||
observer: MutationObserver;
|
||||
@property({ type: String })
|
||||
public pageIdentifier = "page";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
@property({ type: Boolean, useDefault: true })
|
||||
public vertical = false;
|
||||
|
||||
@state()
|
||||
protected activeTabName: string | null = null;
|
||||
|
||||
@state()
|
||||
protected tabs: ReadonlyMap<string, Element> = new Map();
|
||||
|
||||
#focusTargetRef = createRef<HTMLSlotElement>();
|
||||
#observer: MutationObserver | null = null;
|
||||
|
||||
#updateTabs = (): void => {
|
||||
this.tabs = new Map(
|
||||
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
|
||||
return [element.getAttribute("slot") || "", element];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#observer = new MutationObserver(this.#updateTabs);
|
||||
|
||||
this.addEventListener("focus", this.#delegateFocusListener);
|
||||
|
||||
if (!this.activeTabName) {
|
||||
const params = getURLParams();
|
||||
const tabParam = params[this.pageIdentifier];
|
||||
|
||||
if (
|
||||
tabParam &&
|
||||
typeof tabParam === "string" &&
|
||||
this.querySelector(`[slot='${tabParam}']`)
|
||||
) {
|
||||
this.activeTabName = tabParam;
|
||||
} else {
|
||||
this.#updateTabs();
|
||||
this.activeTabName = this.tabs.keys().next().value || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.observer.observe(this, {
|
||||
public override firstUpdated(): void {
|
||||
this.#observer?.observe(this, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
this.addEventListener("focus", this.#delegateFocusListener);
|
||||
this.dispatchActivateEvent();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.observer.disconnect();
|
||||
public override disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
onClick(slot?: string): void {
|
||||
this.currentPage = slot;
|
||||
const params: { [key: string]: string | undefined } = {};
|
||||
params[this.pageIdentifier] = slot;
|
||||
updateURLParams(params);
|
||||
const page = this.querySelector(`[slot='${this.currentPage}']`);
|
||||
if (!page) return;
|
||||
public findActiveTabPanel(): Element | null {
|
||||
return this.querySelector(`[slot='${this.activeTabName}']`);
|
||||
}
|
||||
|
||||
page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
|
||||
page.dispatchEvent(new CustomEvent("activate"));
|
||||
public activateTab(nextTabName: string): void {
|
||||
if (!nextTabName) {
|
||||
console.warn("Cannot activate falsey tab name:", nextTabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.tabs.has(nextTabName)) {
|
||||
console.warn("Cannot activate unknown tab name:", nextTabName, this.tabs);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTab = this.tabs.keys().next().value || null;
|
||||
|
||||
// We avoid adding the tab parameter to the URL if it's the first tab
|
||||
// to both reduce URL length and ensure that tests do not have to deal with
|
||||
// unnecessary URL parameters.
|
||||
|
||||
updateURLParams({
|
||||
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
|
||||
});
|
||||
|
||||
this.activeTabName = nextTabName;
|
||||
|
||||
this.dispatchActivateEvent();
|
||||
}
|
||||
|
||||
public dispatchActivateEvent(tabPanel = this.findActiveTabPanel()): void {
|
||||
if (!tabPanel) {
|
||||
console.warn("Cannot dispatch activate event, no tab panel found");
|
||||
return;
|
||||
}
|
||||
|
||||
tabPanel.dispatchEvent(new CustomEvent(EVENT_REFRESH));
|
||||
tabPanel.dispatchEvent(new CustomEvent("activate"));
|
||||
}
|
||||
|
||||
#delegateFocusListener = (event: FocusEvent) => {
|
||||
@@ -103,47 +155,35 @@ export class Tabs extends AKElement {
|
||||
|
||||
// We don't want to refocus if the user is tabbing between elements inside the tabpanel.
|
||||
if (focusableElement && event.relatedTarget !== focusableElement) {
|
||||
focusableElement.focus();
|
||||
focusableElement.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderTab(page: Element): TemplateResult {
|
||||
const slot = page.attributes.getNamedItem("slot")?.value;
|
||||
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
|
||||
renderTab(slotName: string, tabPanel: Element): TemplateResult {
|
||||
return html` <li
|
||||
class="pf-c-tabs__item ${slotName === this.activeTabName ? CURRENT_CLASS : ""}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id=${`${slot}-tab`}
|
||||
aria-selected=${slot === this.currentPage ? "true" : "false"}
|
||||
aria-controls=${ifPresent(slot)}
|
||||
id=${`${slotName}-tab`}
|
||||
aria-selected=${slotName === this.activeTabName ? "true" : "false"}
|
||||
aria-controls=${ifPresent(slotName)}
|
||||
class="pf-c-tabs__link"
|
||||
@click=${() => this.onClick(slot)}
|
||||
@click=${() => this.activateTab(slotName)}
|
||||
>
|
||||
<span class="pf-c-tabs__item-text"> ${page.getAttribute("aria-label")}</span>
|
||||
<span class="pf-c-tabs__item-text"> ${tabPanel.getAttribute("aria-label")}</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
|
||||
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
|
||||
const params = getURLParams();
|
||||
if (
|
||||
this.pageIdentifier in params &&
|
||||
!this.currentPage &&
|
||||
this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
|
||||
) {
|
||||
// To update the URL to match with the current slot
|
||||
this.onClick(params[this.pageIdentifier] as string);
|
||||
}
|
||||
}
|
||||
if (!this.currentPage) {
|
||||
if (pages.length < 1) {
|
||||
return html`<h1>${msg("no tabs defined")}</h1>`;
|
||||
}
|
||||
const wantedPage = pages[0].attributes.getNamedItem("slot")?.value;
|
||||
this.onClick(wantedPage);
|
||||
if (!this.tabs.size) {
|
||||
return html`<h1>${msg("no tabs defined")}</h1>`;
|
||||
}
|
||||
|
||||
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
|
||||
<ul
|
||||
class="pf-c-tabs__list"
|
||||
@@ -151,11 +191,13 @@ export class Tabs extends AKElement {
|
||||
aria-orientation=${this.vertical ? "vertical" : "horizontal"}
|
||||
aria-label=${ifPresent(this.ariaLabel)}
|
||||
>
|
||||
${pages.map((page) => this.renderTab(page))}
|
||||
${Array.from(this.tabs, ([slotName, tabPanel]) =>
|
||||
this.renderTab(slotName, tabPanel),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
<slot ${ref(this.#focusTargetRef)} name="${ifDefined(this.currentPage)}"></slot>`;
|
||||
<slot ${ref(this.#focusTargetRef)} name=${ifPresent(this.activeTabName)}></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
6
web/src/elements/router/shared.ts
Normal file
6
web/src/elements/router/shared.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file Common types for routing.
|
||||
*/
|
||||
|
||||
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
|
||||
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user