Compare commits

...

22 Commits

Author SHA1 Message Date
ee6b8b596e web/NPM Workspaces: Prep SFE package. 2025-05-20 02:53:50 +02:00
1c5e906a3e web/NPM Workspaces: ESbuild version cleanup (#14541)
* web: Check JS files. Add types.

* web: Fix issues surrounding Vite/ESBuild types.

* web: Clean up version constants. Tidy types

* web: Clean up docs, types.

* web: Clean up package paths.

* web: (ESLint) no-lonely-if

* web: Render slot before navbar.

* web: Fix line-height alignment.

* web: Truncate long headers.

* web: Clean up page header declarations. Add story. Update paths.

* web: Ignore out directory.

* web: Lint Lit.

* web: Use private alias.

* web: Fix implicit CJS mode.

* web: Update deps.

* web: await all imports.
2025-05-20 02:11:18 +02:00
c133ba9bd3 enterprise/stages/mtls: update go & web client, fix py client generation (#14576)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-19 23:27:02 +02:00
65517f3b7f enterprise/stages: Add MTLS stage (#14296)
* prepare client auth with inbuilt server

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

* introduce better IPC auth

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

* init

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

* start stage

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

* only allow trusted proxies to set MTLS headers

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

* more stage progress

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

* dont fail if ipc_key doesn't exist

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

* actually install app

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

* fix

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

* add some tests

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

* update API

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

* fix unquote

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

* fix int serial number not jsonable

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

* init ui

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

* add UI

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

* unrelated: fix git pull in makefile

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

* fix parse helper

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

* add test for outpost

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

* more tests and improvements

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

* improve labels

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

* add support for multiple CAs on brand

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

* add support for multiple CAs to MTLS stage

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

* dont log ipcuser secret views

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

* fix go mod

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-19 22:48:17 +02:00
b361dd3b59 lib/sync/outgoing: reduce number of db queries made (#14177) 2025-05-19 22:41:09 +02:00
40f598f3f1 web: (ESLint) No else return (#14558)
web: (ESLint) no-else-return.
2025-05-19 19:34:51 +02:00
b72d0e84c9 web: (ESLint) Use dot notation. (#14557) 2025-05-19 19:33:52 +02:00
d97297e0ce web: (ESLint) Consistent use of triple-equals. (#14554)
web: Consistent use of triple-equals.
2025-05-19 13:25:11 -04:00
1a80353bc0 web: fix description for signing responses in SAML provider (#14573) 2025-05-19 14:56:19 +00:00
beece507fd website/integrations: update paperless ngx instructions to include additional scopes (#14486) 2025-05-19 14:07:06 +02:00
e2bec88403 translate: Updates for file web/xliff/en.xlf in fr (#14570)
Translate web/xliff/en.xlf in fr

100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-05-19 12:04:12 +00:00
26b6c2e130 ci: add dependencies label to generated PRs (#14569)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-19 13:52:28 +02:00
1a38679ecf translate: Updates for file web/xliff/en.xlf in it (#14538)
Translate web/xliff/en.xlf in it

100% translated source file: 'web/xliff/en.xlf'
on 'it'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-05-19 13:47:15 +02:00
b2334c3680 core: bump astral-sh/uv from 0.7.4 to 0.7.5 (#14560)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.7.4 to 0.7.5.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.7.4...0.7.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 13:38:30 +02:00
13251bb8c4 website: bump @types/postman-collection from 3.5.10 to 3.5.11 in /website (#14561)
website: bump @types/postman-collection in /website

Bumps [@types/postman-collection](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/postman-collection) from 3.5.10 to 3.5.11.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/postman-collection)

---
updated-dependencies:
- dependency-name: "@types/postman-collection"
  dependency-version: 3.5.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 13:38:16 +02:00
9fe6bac99d lifecycle/aws: bump aws-cdk from 2.1015.0 to 2.1016.0 in /lifecycle/aws (#14563)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1015.0 to 2.1016.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1016.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1016.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 13:38:06 +02:00
7c9fe53b47 core: bump axllent/mailpit from v1.24.2 to v1.25.0 in /tests/e2e (#14564)
Bumps axllent/mailpit from v1.24.2 to v1.25.0.

---
updated-dependencies:
- dependency-name: axllent/mailpit
  dependency-version: v1.25.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 13:37:57 +02:00
b20c4eab29 translate: Updates for file web/xliff/en.xlf in zh_CN (#14565)
Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-05-19 13:37:45 +02:00
8ca09a9ece web: Fix issue where Storybook cannot resolve styles. (#14553)
* web: Fix issue where Storybook cannot resolve styles.

* separate sentry config and middleware to prevent circular import

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-05-19 13:37:30 +02:00
856598fc54 translate: Updates for file web/xliff/en.xlf in zh-Hans (#14566)
Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-05-19 13:36:58 +02:00
fdb7b29d9a root: replace raw.githubusercontent.com by checking out repo (#14567)
* root: replace raw.githubusercontent.com by checking out repo

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

* use make from client-go

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

* update instead of delete

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

* unrelated: fix py client install

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

* unrelated: use all absolute paths

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-19 13:18:44 +02:00
3748781368 sources/kerberos: resolve logger warnings (#14540)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-05-18 01:31:41 +02:00
260 changed files with 4172 additions and 1718 deletions

View File

@ -53,6 +53,7 @@ jobs:
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -37,6 +37,7 @@ jobs:
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -53,6 +53,7 @@ jobs:
body: ${{ steps.compress.outputs.markdown }}
delete-branch: true
signoff: true
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
with:

View File

@ -52,3 +52,6 @@ jobs:
body: "core, web: update translations"
delete-branch: true
signoff: true
labels: dependencies
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>

View File

@ -25,23 +25,13 @@ jobs:
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
title=$(curl -q -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }} | jq -r .title)
title=$(gh pr view ${{ github.event.pull_request.number }} --json "title" -q ".title")
echo "title=${title}" >> "$GITHUB_OUTPUT"
- name: Rename
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
curl -L \
-X PATCH \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }} \
-d "{\"title\":\"translate: ${{ steps.title.outputs.title }}\"}"
gh pr edit -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -94,7 +94,7 @@ 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 5: Download uv
FROM ghcr.io/astral-sh/uv:0.7.4 AS uv
FROM ghcr.io/astral-sh/uv:0.7.5 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base

View File

@ -1,6 +1,7 @@
.PHONY: gen dev-reset all clean test web website
.SHELLFLAGS += ${SHELLFLAGS} -e
SHELL := /bin/bash
.SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
@ -8,9 +9,9 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
GEN_API_TS = gen-ts-api
GEN_API_PY = gen-py-api
GEN_API_GO = gen-go-api
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
@ -117,14 +118,19 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for Typescript
rm -rf ./${GEN_API_TS}/
rm -rf ./web/node_modules/@goauthentik/api/
rm -rf ${PWD}/${GEN_API_TS}/
rm -rf ${PWD}/web/node_modules/@goauthentik/api/
gen-clean-go: ## Remove generated API client for Go
rm -rf ./${GEN_API_GO}/
mkdir -p ${PWD}/${GEN_API_GO}
ifneq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
make -C ${PWD}/${GEN_API_GO} clean
else
rm -rf ${PWD}/${GEN_API_GO}
endif
gen-clean-py: ## Remove generated API client for Python
rm -rf ./${GEN_API_PY}/
rm -rf ${PWD}/${GEN_API_PY}/
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
@ -141,8 +147,8 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
--git-repo-id authentik \
--git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
cd ./${GEN_API_TS} && npm i
\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api
cd ${PWD}/${GEN_API_TS} && npm i
\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \
@ -156,24 +162,17 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python
--additional-properties=packageVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
pip install ./${GEN_API_PY}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache
cp schema.yml ./${GEN_API_GO}/
docker run \
--rm -v ${PWD}/${GEN_API_GO}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
-i /local/schema.yml \
-g go \
-o /local/ \
-c /local/config.yaml
mkdir -p ${PWD}/${GEN_API_GO}
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
else
cd ${PWD}/${GEN_API_GO} && git pull
endif
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
make -C ${PWD}/${GEN_API_GO} build
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
gen-dev-config: ## Generate a local development config file
uv run scripts/generate_config.py
@ -244,7 +243,7 @@ docker: ## Build a docker image of the current source tree
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ./scripts/test_docker.sh
BUILD=true ${PWD}/scripts/test_docker.sh
#########################
## CI

View File

@ -1,9 +1,12 @@
"""API Authentication"""
from hmac import compare_digest
from pathlib import Path
from tempfile import gettempdir
from typing import Any
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
@ -11,11 +14,17 @@ from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
LOGGER = get_logger()
_tmp = Path(gettempdir())
try:
with open(_tmp / "authentik-core-ipc.key") as _f:
ipc_key = _f.read()
except OSError:
ipc_key = None
def validate_auth(header: bytes) -> str | None:
@ -73,6 +82,11 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
if user:
CTX_AUTH_VIA.set("secret_key")
return user
# then try to auth via secret key (for embedded outpost/etc)
user = token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
@ -90,6 +104,43 @@ def token_secret_key(value: str) -> User | None:
return outpost.user
class IPCUser(AnonymousUser):
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
username = "authentik:system"
is_active = True
is_superuser = True
@property
def type(self):
return UserTypes.INTERNAL_SERVICE_ACCOUNT
def has_perm(self, perm, obj=None):
return True
def has_perms(self, perm_list, obj=None):
return True
def has_module_perms(self, module):
return True
@property
def is_anonymous(self):
return False
@property
def is_authenticated(self):
return True
def token_ipc(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser()
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""

View File

@ -59,6 +59,7 @@ class BrandSerializer(ModelSerializer):
"flow_device_code",
"default_application",
"web_certificate",
"client_certificates",
"attributes",
]
extra_kwargs = {
@ -120,6 +121,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"domain",
"branding_title",
"web_certificate__name",
"client_certificates__name",
]
filterset_fields = [
"brand_uuid",
@ -136,6 +138,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_user_settings",
"flow_device_code",
"web_certificate",
"client_certificates",
]
ordering = ["domain"]

View File

@ -0,0 +1,37 @@
# Generated by Django 5.1.9 on 2025-05-19 15:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0009_brand_branding_default_flow_background"),
("authentik_crypto", "0004_alter_certificatekeypair_name"),
]
operations = [
migrations.AddField(
model_name="brand",
name="client_certificates",
field=models.ManyToManyField(
blank=True,
default=None,
help_text="Certificates used for client authentication.",
to="authentik_crypto.certificatekeypair",
),
),
migrations.AlterField(
model_name="brand",
name="web_certificate",
field=models.ForeignKey(
default=None,
help_text="Web Certificate used by the authentik Core webserver.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="+",
to="authentik_crypto.certificatekeypair",
),
),
]

View File

@ -73,6 +73,13 @@ class Brand(SerializerModel):
default=None,
on_delete=models.SET_DEFAULT,
help_text=_("Web Certificate used by the authentik Core webserver."),
related_name="+",
)
client_certificates = models.ManyToManyField(
CertificateKeyPair,
default=None,
blank=True,
help_text=_("Certificates used for client authentication."),
)
attributes = models.JSONField(default=dict, blank=True)

View File

@ -30,6 +30,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import UserTypes
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
from authentik.crypto.models import CertificateKeyPair
@ -272,11 +273,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def view_certificate(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs certificate and log access"""
certificate: CertificateKeyPair = self.get_object()
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="certificate",
).from_http(request)
if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT:
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="certificate",
).from_http(request)
if "download" in request.query_params:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(
@ -302,11 +304,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def view_private_key(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs private key and log access"""
certificate: CertificateKeyPair = self.get_object()
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="private_key",
).from_http(request)
if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT:
Event.new( # noqa # nosec
EventAction.SECRET_VIEW,
secret=certificate,
type="private_key",
).from_http(request)
if "download" in request.query_params:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")

View File

@ -25,7 +25,7 @@ class GoogleWorkspaceGroupClient(
"""Google client for groups"""
connection_type = GoogleWorkspaceProviderGroup
connection_type_query = "group"
connection_attr = "googleworkspaceprovidergroup_set"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:

View File

@ -20,7 +20,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
"""Sync authentik users into google workspace"""
connection_type = GoogleWorkspaceProviderUser
connection_type_query = "user"
connection_attr = "googleworkspaceprovideruser_set"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:

View File

@ -132,7 +132,11 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = User.objects.all().exclude_anonymous()
base = (
User.objects.prefetch_related("googleworkspaceprovideruser_set")
.all()
.exclude_anonymous()
)
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
@ -142,7 +146,11 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return Group.objects.all().order_by("pk")
return (
Group.objects.prefetch_related("googleworkspaceprovidergroup_set")
.all()
.order_by("pk")
)
raise ValueError(f"Invalid type {type}")
def google_credentials(self):

View File

@ -29,7 +29,7 @@ class MicrosoftEntraGroupClient(
"""Microsoft client for groups"""
connection_type = MicrosoftEntraProviderGroup
connection_type_query = "group"
connection_attr = "microsoftentraprovidergroup_set"
can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:

View File

@ -24,7 +24,7 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
"""Sync authentik users into microsoft entra"""
connection_type = MicrosoftEntraProviderUser
connection_type_query = "user"
connection_attr = "microsoftentraprovideruser_set"
can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:

View File

@ -121,7 +121,11 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = User.objects.all().exclude_anonymous()
base = (
User.objects.prefetch_related("microsoftentraprovideruser_set")
.all()
.exclude_anonymous()
)
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
@ -131,7 +135,11 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return Group.objects.all().order_by("pk")
return (
Group.objects.prefetch_related("microsoftentraprovidergroup_set")
.all()
.order_by("pk")
)
raise ValueError(f"Invalid type {type}")
def microsoft_credentials(self):

View File

@ -19,6 +19,7 @@ TENANT_APPS = [
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",
]

View File

@ -0,0 +1,31 @@
"""Mutual TLS Stage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.mtls.models import MutualTLSStage
from authentik.flows.api.stages import StageSerializer
class MutualTLSStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""MutualTLSStage Serializer"""
class Meta:
model = MutualTLSStage
fields = StageSerializer.Meta.fields + [
"mode",
"certificate_authorities",
"cert_attribute",
"user_attribute",
]
class MutualTLSStageViewSet(UsedByMixin, ModelViewSet):
"""MutualTLSStage Viewset"""
queryset = MutualTLSStage.objects.all()
serializer_class = MutualTLSStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]

View File

@ -0,0 +1,12 @@
"""authentik stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageMTLSConfig(EnterpriseConfig):
"""authentik MTLS stage config"""
name = "authentik.enterprise.stages.mtls"
label = "authentik_stages_mtls"
verbose_name = "authentik Enterprise.Stages.MTLS"
default = True

View File

@ -0,0 +1,68 @@
# Generated by Django 5.1.9 on 2025-05-19 18:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.CreateModel(
name="MutualTLSStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"mode",
models.TextField(choices=[("optional", "Optional"), ("required", "Required")]),
),
(
"cert_attribute",
models.TextField(
choices=[
("subject", "Subject"),
("common_name", "Common Name"),
("email", "Email"),
]
),
),
(
"user_attribute",
models.TextField(choices=[("username", "Username"), ("email", "Email")]),
),
(
"certificate_authorities",
models.ManyToManyField(
blank=True,
default=None,
help_text="Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`.",
to="authentik_crypto.certificatekeypair",
),
),
],
options={
"verbose_name": "Mutual TLS Stage",
"verbose_name_plural": "Mutual TLS Stages",
"permissions": [
("pass_outpost_certificate", "Permissions to pass Certificates for outposts.")
],
},
bases=("authentik_flows.stage",),
),
]

View File

@ -0,0 +1,71 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
class TLSMode(models.TextChoices):
"""Modes the TLS Stage can operate in"""
OPTIONAL = "optional"
REQUIRED = "required"
class CertAttributes(models.TextChoices):
"""Certificate attribute used for user matching"""
SUBJECT = "subject"
COMMON_NAME = "common_name"
EMAIL = "email"
class UserAttributes(models.TextChoices):
"""User attribute for user matching"""
USERNAME = "username"
EMAIL = "email"
class MutualTLSStage(Stage):
"""Authenticate/enroll users using a client-certificate."""
mode = models.TextField(choices=TLSMode.choices)
certificate_authorities = models.ManyToManyField(
CertificateKeyPair,
default=None,
blank=True,
help_text=_(
"Configure certificate authorities to validate the certificate against. "
"This option has a higher priority than the `client_certificate` option on `Brand`."
),
)
cert_attribute = models.TextField(choices=CertAttributes.choices)
user_attribute = models.TextField(choices=UserAttributes.choices)
@property
def view(self) -> type[StageView]:
from authentik.enterprise.stages.mtls.stage import MTLSStageView
return MTLSStageView
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.stages.mtls.api import MutualTLSStageSerializer
return MutualTLSStageSerializer
@property
def component(self) -> str:
return "ak-stage-mtls-form"
class Meta:
verbose_name = _("Mutual TLS Stage")
verbose_name_plural = _("Mutual TLS Stages")
permissions = [
("pass_outpost_certificate", _("Permissions to pass Certificates for outposts.")),
]

View File

@ -0,0 +1,215 @@
from binascii import hexlify
from urllib.parse import unquote_plus
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.x509 import Certificate, NameOID, ObjectIdentifier, load_pem_x509_certificate
from django.utils.translation import gettext_lazy as _
from authentik.brands.models import Brand
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.stages.mtls.models import (
CertAttributes,
MutualTLSStage,
TLSMode,
UserAttributes,
)
from authentik.flows.challenge import AccessDeniedChallenge
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
# All of these headers must only be accepted from "trusted" reverse proxies
# See internal/web/proxy.go:39
HEADER_PROXY_FORWARDED = "X-Forwarded-Client-Cert"
HEADER_NGINX_FORWARDED = "SSL-Client-Cert"
HEADER_TRAEFIK_FORWARDED = "X-Forwarded-TLS-Client-Cert"
HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate"
PLAN_CONTEXT_CERTIFICATE = "certificate"
class MTLSStageView(ChallengeStageView):
def __parse_single_cert(self, raw: str | None) -> list[Certificate]:
"""Helper to parse a single certificate"""
if not raw:
return []
try:
cert = load_pem_x509_certificate(unquote_plus(raw).encode())
return [cert]
except ValueError as exc:
self.logger.info("Failed to parse certificate", exc=exc)
return []
def _parse_cert_xfcc(self) -> list[Certificate]:
"""Parse certificates in the format given to us in
the format of the authentik router/envoy"""
xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED)
if not xfcc_raw:
return []
certs = []
for r_cert in xfcc_raw.split(","):
el = r_cert.split(";")
raw_cert = {k.split("=")[0]: k.split("=")[1] for k in el}
if "Cert" not in raw_cert:
continue
certs.extend(self.__parse_single_cert(raw_cert["Cert"]))
return certs
def _parse_cert_nginx(self) -> list[Certificate]:
"""Parse certificates in the format nginx-ingress gives to us"""
sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED)
return self.__parse_single_cert(sslcc_raw)
def _parse_cert_traefik(self) -> list[Certificate]:
"""Parse certificates in the format traefik gives to us"""
ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED)
return self.__parse_single_cert(ftcc_raw)
def _parse_cert_outpost(self) -> list[Certificate]:
"""Parse certificates in the format outposts give to us. Also authenticates
the outpost to ensure it has the permission to do so"""
user = ClientIPMiddleware.get_outpost_user(self.request)
if not user:
return []
if not user.has_perm(
"pass_outpost_certificate", self.executor.current_stage
) and not user.has_perm("authentik_stages_mtls.pass_outpost_certificate"):
return []
outpost_raw = self.request.headers.get(HEADER_OUTPOST_FORWARDED)
return self.__parse_single_cert(outpost_raw)
def get_authorities(self) -> list[CertificateKeyPair] | None:
# We can't access `certificate_authorities` on `self.executor.current_stage`, as that would
# load the certificate into the directly referenced foreign key, which we have to pickle
# as part of the flow plan, and cryptography certs can't be pickled
stage: MutualTLSStage = (
MutualTLSStage.objects.filter(pk=self.executor.current_stage.pk)
.prefetch_related("certificate_authorities")
.first()
)
if stage.certificate_authorities.exists():
return stage.certificate_authorities.order_by("name")
brand: Brand = self.request.brand
if brand.client_certificates.exists():
return brand.client_certificates.order_by("name")
return None
def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]):
for _cert in certs:
for ca in authorities:
try:
_cert.verify_directly_issued_by(ca.certificate)
return _cert
except (InvalidSignature, TypeError, ValueError) as exc:
self.logger.warning(
"Discarding cert not issued by authority", cert=_cert, authority=ca, exc=exc
)
continue
return None
def check_if_user(self, cert: Certificate):
stage: MutualTLSStage = self.executor.current_stage
cert_attr = None
user_attr = None
match stage.cert_attribute:
case CertAttributes.SUBJECT:
cert_attr = cert.subject.rfc4514_string()
case CertAttributes.COMMON_NAME:
cert_attr = self.get_cert_attribute(cert, NameOID.COMMON_NAME)
case CertAttributes.EMAIL:
cert_attr = self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS)
match stage.user_attribute:
case UserAttributes.USERNAME:
user_attr = "username"
case UserAttributes.EMAIL:
user_attr = "email"
if not user_attr or not cert_attr:
return None
return User.objects.filter(**{user_attr: cert_attr}).first()
def _cert_to_dict(self, cert: Certificate) -> dict:
"""Represent a certificate in a dictionary, as certificate objects cannot be pickled"""
return {
"serial_number": str(cert.serial_number),
"subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(),
"fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"),
"fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"),
}
def auth_user(self, user: User, cert: Certificate):
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "mtls")
self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].update(
{"certificate": self._cert_to_dict(cert)}
)
def enroll_prepare_user(self, cert: Certificate):
self.executor.plan.context.setdefault(PLAN_CONTEXT_PROMPT, {})
self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(
{
"email": self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS),
"name": self.get_cert_attribute(cert, NameOID.COMMON_NAME),
}
)
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
def get_cert_attribute(self, cert: Certificate, oid: ObjectIdentifier) -> str | None:
attr = cert.subject.get_attributes_for_oid(oid)
if len(attr) < 1:
return None
return str(attr[0].value)
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
certs = [
*self._parse_cert_xfcc(),
*self._parse_cert_nginx(),
*self._parse_cert_traefik(),
*self._parse_cert_outpost(),
]
authorities = self.get_authorities()
if not authorities:
self.logger.warning("No Certificate authority found")
if stage.mode == TLSMode.OPTIONAL:
return self.executor.stage_ok()
if stage.mode == TLSMode.REQUIRED:
return super().dispatch(request, *args, **kwargs)
cert = self.validate_cert(authorities, certs)
if not cert and stage.mode == TLSMode.REQUIRED:
self.logger.warning("Client certificate required but no certificates given")
return super().dispatch(
request,
*args,
error_message=_("Certificate required but no certificate was given."),
**kwargs,
)
if not cert and stage.mode == TLSMode.OPTIONAL:
self.logger.info("No certificate given, continuing")
return self.executor.stage_ok()
existing_user = self.check_if_user(cert)
if self.executor.flow.designation == FlowDesignation.ENROLLMENT:
self.enroll_prepare_user(cert)
elif existing_user:
self.auth_user(existing_user, cert)
else:
return super().dispatch(
request, *args, error_message=_("No user found for certificate."), **kwargs
)
return self.executor.stage_ok()
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
return AccessDeniedChallenge(
data={
"component": "ak-stage-access-denied",
"error_message": str(error_message or "Unknown error"),
}
)

View File

@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFXDCCA0SgAwIBAgIUBmV7zREyC1SPr72/75/L9zpwV18wDQYJKoZIhvcNAQEL
BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl
bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMDUwWhcNMzUw
MzA3MTgzMDUwWjBGMRowGAYDVQQDDBFhdXRoZW50aWsgVGVzdCBDQTESMBAGA1UE
CgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCAiIwDQYJKoZIhvcN
AQEBBQADggIPADCCAgoCggIBAMc0NxZj7j1mPu0aRToo8oMPdC3T99xgxnqdr18x
LV4pWyi/YLghgZHqNQY2xNP6JIlSeUZD6KFUYT2sPL4Av/zSg5zO8bl+/lf7ckje
O1/Bt5A8xtL0CpmpMDGiI6ibdDElaywM6AohisbxrV29pygSKGq2wugF/urqGtE+
5z4y5Kt6qMdKkd0iXT+WagbQTIUlykFKgB0+qqTLzDl01lVDa/DoLl8Hqp45mVx2
pqrGsSa3TCErLIv9hUlZklF7A8UV4ZB4JL20UKcP8dKzQClviNie17tpsUpOuy3A
SQ6+guWTHTLJNCSdLn1xIqc5q+f5wd2dIDf8zXCTHj+Xp0bJE3Vgaq5R31K9+b+1
2dDWz1KcNJaLEnw2+b0O8M64wTMLxhqOv7QfLUr6Pmg1ZymghjLcZ6bnU9e31Vza
hlPKhxjqYQUC4Kq+oaYF6qdUeJy+dsYf0iDv5tTC+eReZDWIjxTPrNpwA773ZwT7
WVmL7ULGpuP2g9rNvFBcZiN+i6d7CUoN+jd/iRdo79lrI0dfXiyy4bYgW/2HeZfF
HaOsc1xsoqnJdWbWkX/ooyaCjAfm07kS3HiOzz4q3QW4wgGrwV8lEraLPxYYeOQu
YcGMOM8NfnVkjc8gmyXUxedCje5Vz/Tu5fKrQEInnCmXxVsWbwr/LzEjMKAM/ivY
0TXxAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0G
A1UdDgQWBBTa+Ns6QzqlNvnTGszkouQQtZnVJDANBgkqhkiG9w0BAQsFAAOCAgEA
NpJEDMXjuEIzSzafkxSshvjnt5sMYmzmvjNoRlkxgN2YcWvPoxbalGAYzcpyggT2
6xZY8R4tvB1oNTCArqwf860kkofUoJCr88D/pU3Cv4JhjCWs4pmXTsvSqlBSlJbo
+jPBZwbn6it/6jcit6Be3rW2PtHe8tASd9Lf8/2r1ZvupXwPzcR84R4Z10ve2lqV
xxcWlMmBh51CaYI0b1/WTe9Ua+wgkCVkxbf9zNcDQXjxw2ICWK+nR/4ld4nmqVm2
C7nhvXwU8FAHl7ZgR2Z3PLrwPuhd+kd6NXQqNkS9A+n+1vSRLbRjmV8pwIPpdPEq
nslUAGJJBHDUBArxC3gOJSB+WtmaCfzDu2gepMf9Ng1H2ZhwSF/FH3v3fsJqZkzz
NBstT9KuNGQRYiCmAPJaoVAc9BoLa+BFML1govtWtpdmbFk8PZEcuUsP7iAZqFF1
uuldPyZ8huGpQSR6Oq2bILRHowfGY0npTZAyxg0Vs8UMy1HTwNOp9OuRtArMZmsJ
jFIx1QzRf9S1i6bYpOzOudoXj4ARkS1KmVExGjJFcIT0xlFSSERie2fEKSeEYOyG
G+PA2qRt/F51FGOMm1ZscjPXqk2kt3C4BFbz6Vvxsq7D3lmhvFLn4jVA8+OidsM0
YUrVMtWET/RkjEIbADbgRXxNUNo+jtQZDU9C1IiAdfk=
-----END CERTIFICATE-----

View File

@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFOzCCAyOgAwIBAgIUbnIMy+Ewi5RvK7OBDxWMCk7wi08wDQYJKoZIhvcNAQEL
BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl
bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMTE3WhcNMjYw
NDIzMTgzMTE3WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQCdV+GEa7+7ito1i/z637OZW+0azv1kuF2aDwSzv+FJd+4L
6hCroRbVYTUFS3I3YwanOOZfau64xH0+pFM5Js8aREG68eqKBayx8vT27hyAOFhd
giEVmSQJfla4ogvPie1rJ0HVOL7CiR72HDPQvz+9k1iDX3xQ/4sdAb3XurN13e+M
Gtavhjiyqxmoo/H4WRd8BhD/BZQFWtaxWODDY8aKk5R7omw6Xf7aRv1BlHdE4Ucy
Wozvpsj2Kz0l61rRUhiMlE0D9dpijgaRYFB+M7R2casH3CdhGQbBHTRiqBkZa6iq
SDkTiTwNJQQJov8yPTsR+9P8OOuV6QN+DGm/FXJJFaPnsHw/HDy7EAbA1PcdbSyK
XvJ8nVjdNhCEGbLGVSwAQLO+78hChVIN5YH+QSrP84YBSxKZYArnf4z2e9drqAN3
KmC26TkaUzkXnndnxOXBEIOSmyCdD4Dutg1XPE/bs8rA6rVGIR3pKXbCr29Z8hZn
Cn9jbxwDwTX865ljR1Oc3dnIeCWa9AS/uHaSMdGlbGbDrt4Bj/nyyfu8xc034K/0
uPh3hF3FLWNAomRVZCvtuh/v7IEIQEgUbvQMWBhZJ8hu3HdtV8V9TIAryVKzEzGy
Q72UHuQyK0njRDTmA/T+jn7P8GWOuf9eNdzd0gH0gcEuhCZFxPPRvUAeDuC7DQID
AQABo1YwVDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwFAYDVR0RAQH/
BAowCIIGY2xpZW50MB0GA1UdDgQWBBQ5KZwTD8+4CqLnbM/cBSXg8XeLXTANBgkq
hkiG9w0BAQsFAAOCAgEABDkb3iyEOl1xKq1wxyRzf2L8qfPXAQw71FxMbgHArM+a
e44wJGO3mZgPH0trOaJ+tuN6erB5YbZfsoX+xFacwskj9pKyb4QJLr/ENmJZRgyL
wp5P6PB6IUJhvryvy/GxrG938YGmFtYQ+ikeJw5PWhB6218C1aZ9hsi94wZ1Zzrc
Ry0q0D4QvIEZ0X2HL1Harc7gerE3VqhgQ7EWyImM+lCRtNDduwDQnZauwhr2r6cW
XG4VTe1RCNsDA0xinXQE2Xf9voCd0Zf6wOOXJseQtrXpf+tG4N13cy5heF5ihed1
hDxSeki0KjTM+18kVVfVm4fzxf1Zg0gm54UlzWceIWh9EtnWMUV08H0D1M9YNmW8
hWTupk7M+jAw8Y+suHOe6/RLi0+fb9NSJpIpq4GqJ5UF2kerXHX0SvuAavoXyB0j
CQrUXkRScEKOO2KAbVExSG56Ff7Ee8cRUAQ6rLC5pQRACq/R0sa6RcUsFPXul3Yv
vbO2rTuArAUPkNVFknwkndheN4lOslRd1If02HunZETmsnal6p+nmuMWt2pQ2fDA
vIguG54FyQ1T1IbF/QhfTEY62CQAebcgutnqqJHt9qe7Jr6ev57hMrJDEjotSzkY
OhOVrcYqgLldr1nBqNVlIK/4VrDaWH8H5dNJ72gA9aMNVH4/bSTJhuO7cJkLnHw=
-----END CERTIFICATE-----

View File

@ -0,0 +1,213 @@
from unittest.mock import MagicMock, patch
from urllib.parse import quote_plus
from django.urls import reverse
from guardian.shortcuts import assign_perm
from authentik.core.models import User
from authentik.core.tests.utils import create_test_brand, create_test_flow, create_test_user
from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.stages.mtls.models import (
CertAttributes,
MutualTLSStage,
TLSMode,
UserAttributes,
)
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.outposts.models import Outpost, OutpostType
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
class MTLSStageTests(FlowTestCase):
def setUp(self):
super().setUp()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.ca = CertificateKeyPair.objects.create(
name=generate_id(),
certificate_data=load_fixture("fixtures/ca.pem"),
)
self.stage = MutualTLSStage.objects.create(
name=generate_id(),
mode=TLSMode.REQUIRED,
cert_attribute=CertAttributes.COMMON_NAME,
user_attribute=UserAttributes.USERNAME,
)
self.stage.certificate_authorities.add(self.ca)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.client_cert = load_fixture("fixtures/cert_client.pem")
# User matching the certificate
User.objects.filter(username="client").delete()
self.cert_user = create_test_user(username="client")
def test_parse_xfcc(self):
"""Test authentik Proxy/Envoy's XFCC format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-Client-Cert": f"Cert={quote_plus(self.client_cert)}"},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_nginx(self):
"""Test nginx's format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"SSL-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_traefik(self):
"""Test traefik's format"""
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_object(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("pass_outpost_certificate", outpost.user, self.stage)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_global(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("authentik_stages_mtls.pass_outpost_certificate", outpost.user)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_parse_outpost_no_perm(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
):
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_auth_no_user(self):
"""Test auth with no user"""
User.objects.filter(username="client").delete()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_brand_ca(self):
"""Test using a CA from the brand"""
self.stage.certificate_authorities.clear()
brand = create_test_brand()
brand.client_certificates.add(self.ca)
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user)
def test_no_ca_optional(self):
"""Test using no CA Set"""
self.stage.mode = TLSMode.OPTIONAL
self.stage.certificate_authorities.clear()
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
def test_no_ca_required(self):
"""Test using no CA Set"""
self.stage.certificate_authorities.clear()
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_no_cert_optional(self):
"""Test using no cert Set"""
self.stage.mode = TLSMode.OPTIONAL
self.stage.save()
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
def test_enroll(self):
"""Test Enrollment flow"""
self.flow.designation = FlowDesignation.ENROLLMENT
self.flow.save()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
)
self.assertEqual(res.status_code, 200)
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
self.assertEqual(plan().context[PLAN_CONTEXT_PROMPT], {"email": None, "name": "client"})
self.assertEqual(
plan().context[PLAN_CONTEXT_CERTIFICATE],
{
"fingerprint_sha1": (
"08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc"
),
"fingerprint_sha256": (
"08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc"
),
"issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA",
"serial_number": "630532384467334865093173111400266136879266564943",
"subject": "CN=client",
},
)

View File

@ -0,0 +1,5 @@
"""API URLs"""
from authentik.enterprise.stages.mtls.api import MutualTLSStageViewSet
api_urlpatterns = [("stages/mtls", MutualTLSStageViewSet)]

View File

@ -1,7 +1,10 @@
"""Test helpers"""
from collections.abc import Callable, Generator
from contextlib import contextmanager
from json import loads
from typing import Any
from unittest.mock import MagicMock, patch
from django.http.response import HttpResponse
from django.urls.base import reverse
@ -9,6 +12,8 @@ from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
class FlowTestCase(APITestCase):
@ -44,3 +49,12 @@ class FlowTestCase(APITestCase):
def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
"""Wrapper around assertStageResponse that checks for a redirect"""
return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
@contextmanager
def assertFlowFinishes(self) -> Generator[Callable[[], FlowPlan]]:
"""Capture the flow plan before the flow finishes and return it"""
try:
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
yield lambda: self.client.session.get(SESSION_KEY_PLAN)
finally:
pass

View File

@ -23,7 +23,6 @@ if TYPE_CHECKING:
class Direction(StrEnum):
add = "add"
remove = "remove"
@ -37,13 +36,16 @@ SAFE_METHODS = [
class BaseOutgoingSyncClient[
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
TModel: "Model",
TConnection: "Model",
TSchema: dict,
TProvider: "OutgoingSyncProvider",
]:
"""Basic Outgoing sync client Client"""
provider: TProvider
connection_type: type[TConnection]
connection_type_query: str
connection_attr: str
mapper: PropertyMappingManager
can_discover = False
@ -63,9 +65,7 @@ class BaseOutgoingSyncClient[
def write(self, obj: TModel) -> tuple[TConnection, bool]:
"""Write object to destination. Uses self.create and self.update, but
can be overwritten for further logic"""
connection = self.connection_type.objects.filter(
provider=self.provider, **{self.connection_type_query: obj}
).first()
connection = getattr(obj, self.connection_attr).filter(provider=self.provider).first()
try:
if not connection:
connection = self.create(obj)

View File

@ -34,7 +34,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
"""SCIM client for groups"""
connection_type = SCIMProviderGroup
connection_type_query = "group"
connection_attr = "scimprovidergroup_set"
mapper: PropertyMappingManager
def __init__(self, provider: SCIMProvider):

View File

@ -18,7 +18,7 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
"""SCIM client for users"""
connection_type = SCIMProviderUser
connection_type_query = "user"
connection_attr = "scimprovideruser_set"
mapper: PropertyMappingManager
def __init__(self, provider: SCIMProvider):

View File

@ -116,7 +116,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = User.objects.all().exclude_anonymous()
base = User.objects.prefetch_related("scimprovideruser_set").all().exclude_anonymous()
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
@ -126,7 +126,7 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return Group.objects.all().order_by("pk")
return Group.objects.prefetch_related("scimprovidergroup_set").all().order_by("pk")
raise ValueError(f"Invalid type {type}")
@property

View File

@ -317,7 +317,7 @@ class KerberosSource(Source):
usage="accept", name=name, store=self.get_gssapi_store()
)
except gssapi.exceptions.GSSError as exc:
LOGGER.warn("GSSAPI credentials failure", exc=exc)
LOGGER.warning("GSSAPI credentials failure", exc=exc)
return None

View File

@ -3921,6 +3921,46 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_mtls.mutualtlsstage"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage"
}
}
},
{
"type": "object",
"required": [
@ -4867,6 +4907,7 @@
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",
"authentik.events"
],
@ -4977,6 +5018,7 @@
"authentik_providers_microsoft_entra.microsoftentraprovidermapping",
"authentik_providers_ssf.ssfprovider",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
"authentik_stages_source.sourcestage",
"authentik_events.event",
"authentik_events.notificationtransport",
@ -7477,6 +7519,11 @@
"authentik_stages_invitation.delete_invitationstage",
"authentik_stages_invitation.view_invitation",
"authentik_stages_invitation.view_invitationstage",
"authentik_stages_mtls.add_mutualtlsstage",
"authentik_stages_mtls.change_mutualtlsstage",
"authentik_stages_mtls.delete_mutualtlsstage",
"authentik_stages_mtls.pass_outpost_certificate",
"authentik_stages_mtls.view_mutualtlsstage",
"authentik_stages_password.add_passwordstage",
"authentik_stages_password.change_passwordstage",
"authentik_stages_password.delete_passwordstage",
@ -13422,6 +13469,16 @@
"title": "Web certificate",
"description": "Web Certificate used by the authentik Core webserver."
},
"client_certificates": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"description": "Certificates used for client authentication."
},
"title": "Client certificates",
"description": "Certificates used for client authentication."
},
"attributes": {
"type": "object",
"additionalProperties": true,
@ -14185,6 +14242,11 @@
"authentik_stages_invitation.delete_invitationstage",
"authentik_stages_invitation.view_invitation",
"authentik_stages_invitation.view_invitationstage",
"authentik_stages_mtls.add_mutualtlsstage",
"authentik_stages_mtls.change_mutualtlsstage",
"authentik_stages_mtls.delete_mutualtlsstage",
"authentik_stages_mtls.pass_outpost_certificate",
"authentik_stages_mtls.view_mutualtlsstage",
"authentik_stages_password.add_passwordstage",
"authentik_stages_password.change_passwordstage",
"authentik_stages_password.delete_passwordstage",
@ -15088,6 +15150,161 @@
}
}
},
"model_authentik_stages_mtls.mutualtlsstage": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"flow_set": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"slug": {
"type": "string",
"maxLength": 50,
"minLength": 1,
"pattern": "^[-a-zA-Z0-9_]+$",
"title": "Slug",
"description": "Visible in the URL."
},
"title": {
"type": "string",
"minLength": 1,
"title": "Title",
"description": "Shown as the Title in Flow pages."
},
"designation": {
"type": "string",
"enum": [
"authentication",
"authorization",
"invalidation",
"enrollment",
"unenrollment",
"recovery",
"stage_configuration"
],
"title": "Designation",
"description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
},
"policy_engine_mode": {
"type": "string",
"enum": [
"all",
"any"
],
"title": "Policy engine mode"
},
"compatibility_mode": {
"type": "boolean",
"title": "Compatibility mode",
"description": "Enable compatibility mode, increases compatibility with password managers on mobile devices."
},
"layout": {
"type": "string",
"enum": [
"stacked",
"content_left",
"content_right",
"sidebar_left",
"sidebar_right"
],
"title": "Layout"
},
"denied_action": {
"type": "string",
"enum": [
"message_continue",
"message",
"continue"
],
"title": "Denied action",
"description": "Configure what should happen when a flow denies access to a user."
}
},
"required": [
"name",
"slug",
"title",
"designation"
]
},
"title": "Flow set"
},
"mode": {
"type": "string",
"enum": [
"optional",
"required"
],
"title": "Mode"
},
"certificate_authorities": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`."
},
"title": "Certificate authorities",
"description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`."
},
"cert_attribute": {
"type": "string",
"enum": [
"subject",
"common_name",
"email"
],
"title": "Cert attribute"
},
"user_attribute": {
"type": "string",
"enum": [
"username",
"email"
],
"title": "User attribute"
}
},
"required": []
},
"model_authentik_stages_mtls.mutualtlsstage_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"pass_outpost_certificate",
"add_mutualtlsstage",
"change_mutualtlsstage",
"delete_mutualtlsstage",
"view_mutualtlsstage"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_stages_source.sourcestage": {
"type": "object",
"properties": {

View File

@ -19,7 +19,6 @@ import (
sentryutils "goauthentik.io/internal/utils/sentry"
webutils "goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web"
"goauthentik.io/internal/web/brand_tls"
)
var rootCmd = &cobra.Command{
@ -67,12 +66,12 @@ var rootCmd = &cobra.Command{
}
ws := web.NewWebServer()
ws.Core().HealthyCallback = func() {
ws.Core().AddHealthyCallback(func() {
if config.Get().Outposts.DisableEmbeddedOutpost {
return
}
go attemptProxyStart(ws, u)
}
})
ws.Start()
<-ex
l.Info("shutting down webserver")
@ -95,13 +94,8 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
}
continue
}
// Init brand_tls here too since it requires an API Client,
// so we just reuse the same one as the outpost uses
tw := brand_tls.NewWatcher(ac.Client)
go tw.Start()
ws.BrandTLS = tw
ac.AddRefreshHandler(func() {
tw.Check()
ws.BrandTLS.Check()
})
srv := proxyv2.NewProxyServer(ac)

2
go.mod
View File

@ -27,7 +27,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025041.1
goauthentik.io/api/v3 v3.2025041.2
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.14.0

4
go.sum
View File

@ -290,8 +290,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8=
goauthentik.io/api/v3 v3.2025041.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025041.2 h1:vFYYnhcDcxL95RczZwhzt3i4LptFXMvIRN+vgf8sQYg=
goauthentik.io/api/v3 v3.2025041.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -21,10 +21,14 @@ func FullVersion() string {
return ver
}
func OutpostUserAgent() string {
func UserAgentOutpost() string {
return fmt.Sprintf("goauthentik.io/outpost/%s", FullVersion())
}
func UserAgentIPC() string {
return fmt.Sprintf("goauthentik.io/ipc/%s", FullVersion())
}
func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion())
}

View File

@ -18,8 +18,8 @@ import (
)
type GoUnicorn struct {
Healthcheck func() bool
HealthyCallback func()
Healthcheck func() bool
healthyCallbacks []func()
log *log.Entry
p *exec.Cmd
@ -32,12 +32,12 @@ type GoUnicorn struct {
func New(healthcheck func() bool) *GoUnicorn {
logger := log.WithField("logger", "authentik.router.unicorn")
g := &GoUnicorn{
Healthcheck: healthcheck,
log: logger,
started: false,
killed: false,
alive: false,
HealthyCallback: func() {},
Healthcheck: healthcheck,
log: logger,
started: false,
killed: false,
alive: false,
healthyCallbacks: []func(){},
}
g.initCmd()
c := make(chan os.Signal, 1)
@ -79,6 +79,10 @@ func (g *GoUnicorn) initCmd() {
g.p.Stderr = os.Stderr
}
func (g *GoUnicorn) AddHealthyCallback(cb func()) {
g.healthyCallbacks = append(g.healthyCallbacks, cb)
}
func (g *GoUnicorn) IsRunning() bool {
return g.alive
}
@ -101,7 +105,9 @@ func (g *GoUnicorn) healthcheck() {
if g.Healthcheck() {
g.alive = true
g.log.Debug("backend is alive, backing off with healthchecks")
g.HealthyCallback()
for _, cb := range g.healthyCallbacks {
cb()
}
break
}
g.log.Debug("backend not alive yet")

View File

@ -62,7 +62,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
apiConfig.Scheme = akURL.Scheme
apiConfig.HTTPClient = &http.Client{
Transport: web.NewUserAgentTransport(
constants.OutpostUserAgent(),
constants.UserAgentOutpost(),
web.NewTracingTransport(
rsp.Context(),
GetTLSTransport(),

View File

@ -38,7 +38,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
header := http.Header{
"Authorization": []string{authHeader},
"User-Agent": []string{constants.OutpostUserAgent()},
"User-Agent": []string{constants.UserAgentOutpost()},
}
dialer := websocket.Dialer{

View File

@ -3,6 +3,8 @@ package ak
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
log "github.com/sirupsen/logrus"
"goauthentik.io/api/v3"
@ -67,16 +69,34 @@ func (cs *CryptoStore) Fetch(uuid string) error {
return err
}
x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data))
if err != nil {
return err
var tcert tls.Certificate
if key.Data != "" {
x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data))
if err != nil {
return err
}
tcert = x509cert
} else {
p, _ := pem.Decode([]byte(cert.Data))
x509cert, err := x509.ParseCertificate(p.Bytes)
if err != nil {
return err
}
tcert = tls.Certificate{
Certificate: [][]byte{x509cert.Raw},
Leaf: x509cert,
}
}
cs.certificates[uuid] = &x509cert
cs.certificates[uuid] = &tcert
cs.fingerprints[uuid] = cfp
return nil
}
func (cs *CryptoStore) Get(uuid string) *tls.Certificate {
c, ok := cs.certificates[uuid]
if ok {
return c
}
err := cs.Fetch(uuid)
if err != nil {
cs.log.WithError(err).Warning("failed to fetch certificate")

View File

@ -55,7 +55,7 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) {
EnableTracing: true,
TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)),
Release: fmt.Sprintf("authentik@%s", constants.VERSION),
HTTPTransport: webutils.NewUserAgentTransport(constants.OutpostUserAgent(), http.DefaultTransport),
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport),
IgnoreErrors: []string{
http.ErrAbortHandler.Error(),
},

View File

@ -61,7 +61,7 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config
l.WithError(err).Warning("Failed to create cookiejar")
panic(err)
}
transport := web.NewUserAgentTransport(constants.OutpostUserAgent(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport()))
transport := web.NewUserAgentTransport(constants.UserAgentOutpost(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport()))
fe := &FlowExecutor{
Params: url.Values{},
Answers: make(map[StageComponent]string),

View File

@ -52,7 +52,7 @@ func (a *Application) addHeaders(headers http.Header, c *Claims) {
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.OutpostUserAgent())
headers.Set("X-authentik-meta-version", constants.UserAgentOutpost())
if c.Proxy == nil {
return

View File

@ -31,7 +31,7 @@ func (ps *ProxyServer) Refresh() error {
ua := fmt.Sprintf(" (provider=%s)", provider.Name)
hc := &http.Client{
Transport: web.NewUserAgentTransport(
constants.OutpostUserAgent()+ua,
constants.UserAgentOutpost()+ua,
web.NewTracingTransport(
rsp.Context(),
ak.GetTLSTransport(),

View File

@ -61,7 +61,7 @@ func (c *Connection) initSocket(forChannel string) error {
header := http.Header{
"Authorization": []string{authHeader},
"User-Agent": []string{constants.OutpostUserAgent()},
"User-Agent": []string{constants.UserAgentOutpost()},
}
dialer := websocket.Dialer{

View File

@ -1,6 +1,7 @@
package web
import (
"context"
"net"
"net/http"
@ -9,6 +10,14 @@ import (
"goauthentik.io/internal/config"
)
type allowedProxyRequestContext string
const allowedProxyRequest allowedProxyRequestContext = ""
func IsRequestFromTrustedProxy(r *http.Request) bool {
return r.Context().Value(allowedProxyRequest) != nil
}
// ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection
// comes from a client that's in a list of trusted CIDRs
func ProxyHeaders() func(http.Handler) http.Handler {
@ -20,7 +29,6 @@ func ProxyHeaders() func(http.Handler) http.Handler {
}
nets = append(nets, cidr)
}
ph := handlers.ProxyHeaders
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
@ -30,7 +38,8 @@ func ProxyHeaders() func(http.Handler) http.Handler {
for _, allowedCidr := range nets {
if remoteAddr != nil && allowedCidr.Contains(remoteAddr) {
log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers")
ph(h).ServeHTTP(w, r)
rr := r.WithContext(context.WithValue(r.Context(), allowedProxyRequest, true))
handlers.ProxyHeaders(h).ServeHTTP(w, rr)
return
}
}

View File

@ -3,6 +3,7 @@ package brand_tls
import (
"context"
"crypto/tls"
"crypto/x509"
"strings"
"time"
@ -56,22 +57,37 @@ func (w *Watcher) Check() {
return
}
for _, b := range brands {
kp := b.WebCertificate.Get()
if kp == nil {
continue
kp := b.GetWebCertificate()
if kp != "" {
err := w.cs.AddKeypair(kp)
if err != nil {
w.log.WithError(err).WithField("kp", kp).Warning("failed to add web certificate")
}
}
err := w.cs.AddKeypair(*kp)
if err != nil {
w.log.WithError(err).Warning("failed to add certificate")
for _, crt := range b.GetClientCertificates() {
if crt != "" {
err := w.cs.AddKeypair(crt)
if err != nil {
w.log.WithError(err).WithField("kp", kp).Warning("failed to add client certificate")
}
}
}
}
w.brands = brands
}
func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
type CertificateConfig struct {
Web *tls.Certificate
Client *x509.CertPool
}
func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) *CertificateConfig {
var bestSelection *api.Brand
config := CertificateConfig{
Web: w.fallback,
}
for _, t := range w.brands {
if t.WebCertificate.Get() == nil {
if !t.WebCertificate.IsSet() && len(t.GetClientCertificates()) < 1 {
continue
}
if *t.Default {
@ -82,11 +98,20 @@ func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, err
}
}
if bestSelection == nil {
return w.fallback, nil
return &config
}
cert := w.cs.Get(bestSelection.GetWebCertificate())
if cert == nil {
return w.fallback, nil
if bestSelection.GetWebCertificate() != "" {
if cert := w.cs.Get(bestSelection.GetWebCertificate()); cert != nil {
config.Web = cert
}
}
return cert, nil
if len(bestSelection.GetClientCertificates()) > 0 {
config.Client = x509.NewCertPool()
for _, kp := range bestSelection.GetClientCertificates() {
if cert := w.cs.Get(kp); cert != nil {
config.Client.AddCert(cert.Leaf)
}
}
}
return &config
}

View File

@ -1,15 +1,11 @@
package web
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"path"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
@ -18,8 +14,6 @@ import (
"goauthentik.io/internal/utils/sentry"
)
const MetricsKeyFile = "authentik-core-metrics.key"
var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "authentik_main_request_duration_seconds",
Help: "API request latencies in seconds",
@ -27,14 +21,6 @@ var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
func (ws *WebServer) runMetricsServer() {
l := log.WithField("logger", "authentik.router.metrics")
tmp := os.TempDir()
key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64))
keyPath := path.Join(tmp, MetricsKeyFile)
err := os.WriteFile(keyPath, []byte(key), 0o600)
if err != nil {
l.WithError(err).Warning("failed to save metrics key")
return
}
m := mux.NewRouter()
m.Use(sentry.SentryNoSampleMiddleware)
@ -51,7 +37,7 @@ func (ws *WebServer) runMetricsServer() {
l.WithError(err).Warning("failed to get upstream metrics")
return
}
re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ws.metricsKey))
res, err := ws.upstreamHttpClient().Do(re)
if err != nil {
l.WithError(err).Warning("failed to get upstream metrics")
@ -64,13 +50,9 @@ func (ws *WebServer) runMetricsServer() {
}
})
l.WithField("listen", config.Get().Listen.Metrics).Info("Starting Metrics server")
err = http.ListenAndServe(config.Get().Listen.Metrics, m)
err := http.ListenAndServe(config.Get().Listen.Metrics, m)
if err != nil {
l.WithError(err).Warning("Failed to start metrics server")
}
l.WithField("listen", config.Get().Listen.Metrics).Info("Stopping Metrics server")
err = os.Remove(keyPath)
if err != nil {
l.WithError(err).Warning("failed to remove metrics key file")
}
}

View File

@ -2,21 +2,29 @@ package web
import (
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"goauthentik.io/internal/config"
"goauthentik.io/internal/utils/sentry"
"goauthentik.io/internal/utils/web"
)
var (
ErrAuthentikStarting = errors.New("authentik starting")
)
const (
maxBodyBytes = 32 * 1024 * 1024
)
func (ws *WebServer) configureProxy() {
// Reverse proxy to the application server
director := func(req *http.Request) {
@ -26,8 +34,25 @@ func (ws *WebServer) configureProxy() {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
if !web.IsRequestFromTrustedProxy(req) {
// If the request isn't coming from a trusted proxy, delete MTLS headers
req.Header.Del("SSL-Client-Cert") // nginx-ingress
req.Header.Del("X-Forwarded-TLS-Client-Cert") // traefik
req.Header.Del("X-Forwarded-Client-Cert") // envoy
}
if req.TLS != nil {
req.Header.Set("X-Forwarded-Proto", "https")
if len(req.TLS.PeerCertificates) > 0 {
pems := make([]string, len(req.TLS.PeerCertificates))
for i, crt := range req.TLS.PeerCertificates {
pem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: crt.Raw,
})
pems[i] = "Cert=" + url.QueryEscape(string(pem))
}
req.Header.Set("X-Forwarded-Client-Cert", strings.Join(pems, ","))
}
}
ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend")
}
@ -57,7 +82,7 @@ func (ws *WebServer) configureProxy() {
Requests.With(prometheus.Labels{
"dest": "core",
}).Observe(float64(elapsed) / float64(time.Second))
r.Body = http.MaxBytesReader(rw, r.Body, 32*1024*1024)
r.Body = http.MaxBytesReader(rw, r.Body, maxBodyBytes)
rp.ServeHTTP(rw, r)
}))
}

View File

@ -2,6 +2,7 @@ package web
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net"
@ -13,17 +14,27 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus"
"goauthentik.io/api/v3"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/gounicorn"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2"
"goauthentik.io/internal/utils"
"goauthentik.io/internal/utils/web"
"goauthentik.io/internal/web/brand_tls"
)
const (
IPCKeyFile = "authentik-core-ipc.key"
MetricsKeyFile = "authentik-core-metrics.key"
UnixSocketName = "authentik-core.sock"
)
type WebServer struct {
Bind string
BindTLS bool
@ -40,9 +51,10 @@ type WebServer struct {
log *log.Entry
upstreamClient *http.Client
upstreamURL *url.URL
}
const UnixSocketName = "authentik-core.sock"
metricsKey string
ipcKey string
}
func NewWebServer() *WebServer {
l := log.WithField("logger", "authentik.router")
@ -76,7 +88,7 @@ func NewWebServer() *WebServer {
mainRouter: mainHandler,
loggingRouter: loggingHandler,
log: l,
gunicornReady: true,
gunicornReady: false,
upstreamClient: upstreamClient,
upstreamURL: u,
}
@ -103,7 +115,59 @@ func NewWebServer() *WebServer {
return ws
}
func (ws *WebServer) prepareKeys() {
tmp := os.TempDir()
key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64))
err := os.WriteFile(path.Join(tmp, MetricsKeyFile), []byte(key), 0o600)
if err != nil {
ws.log.WithError(err).Warning("failed to save metrics key")
return
}
ws.metricsKey = key
key = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64))
err = os.WriteFile(path.Join(tmp, IPCKeyFile), []byte(key), 0o600)
if err != nil {
ws.log.WithError(err).Warning("failed to save ipc key")
return
}
ws.ipcKey = key
}
func (ws *WebServer) Start() {
ws.prepareKeys()
u, err := url.Parse(fmt.Sprintf("http://%s%s", config.Get().Listen.HTTP, config.Get().Web.Path))
if err != nil {
panic(err)
}
apiConfig := api.NewConfiguration()
apiConfig.Host = u.Host
apiConfig.Scheme = u.Scheme
apiConfig.HTTPClient = &http.Client{
Transport: web.NewUserAgentTransport(
constants.UserAgentIPC(),
ak.GetTLSTransport(),
),
}
apiConfig.Servers = api.ServerConfigurations{
{
URL: fmt.Sprintf("%sapi/v3", u.Path),
},
}
apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", ws.ipcKey))
// create the API client, with the transport
apiClient := api.NewAPIClient(apiConfig)
// Init brand_tls here too since it requires an API Client,
// so we just reuse the same one as the outpost uses
tw := brand_tls.NewWatcher(apiClient)
ws.BrandTLS = tw
ws.g.AddHealthyCallback(func() {
go tw.Start()
})
go ws.runMetricsServer()
go ws.attemptStartBackend()
go ws.listenPlain()
@ -112,23 +176,23 @@ func (ws *WebServer) Start() {
func (ws *WebServer) attemptStartBackend() {
for {
if !ws.gunicornReady {
if ws.gunicornReady {
return
}
err := ws.g.Start()
log.WithField("logger", "authentik.router").WithError(err).Warning("gunicorn process died, restarting")
ws.log.WithError(err).Warning("gunicorn process died, restarting")
if err != nil {
log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn failed to start, restarting")
ws.log.WithError(err).Error("gunicorn failed to start, restarting")
continue
}
failedChecks := 0
for range time.NewTicker(30 * time.Second).C {
if !ws.g.IsRunning() {
log.WithField("logger", "authentik.router").Warningf("gunicorn process failed healthcheck %d times", failedChecks)
ws.log.Warningf("gunicorn process failed healthcheck %d times", failedChecks)
failedChecks += 1
}
if failedChecks >= 3 {
log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn process failed healthcheck three times, restarting")
ws.log.WithError(err).Error("gunicorn process failed healthcheck three times, restarting")
break
}
}
@ -146,6 +210,15 @@ func (ws *WebServer) upstreamHttpClient() *http.Client {
func (ws *WebServer) Shutdown() {
ws.log.Info("shutting down gunicorn")
ws.g.Kill()
tmp := os.TempDir()
err := os.Remove(path.Join(tmp, MetricsKeyFile))
if err != nil {
ws.log.WithError(err).Warning("failed to remove metrics key file")
}
err = os.Remove(path.Join(tmp, IPCKeyFile))
if err != nil {
ws.log.WithError(err).Warning("failed to remove ipc key file")
}
ws.stop <- struct{}{}
}

View File

@ -12,40 +12,57 @@ import (
"goauthentik.io/internal/utils/web"
)
func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := crypto.GenerateSelfSignedCert()
func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Config, error) {
fallback, err := crypto.GenerateSelfSignedCert()
if err != nil {
ws.log.WithError(err).Error("failed to generate default cert")
}
return func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(ch *tls.ClientHelloInfo) (*tls.Config, error) {
cfg := utils.GetTLSConfig()
if ch.ServerName == "" {
return &cert, nil
cfg.Certificates = []tls.Certificate{fallback}
return cfg, nil
}
if ws.ProxyServer != nil {
appCert := ws.ProxyServer.GetCertificate(ch.ServerName)
if appCert != nil {
return appCert, nil
cfg.Certificates = []tls.Certificate{*appCert}
return cfg, nil
}
}
if ws.BrandTLS != nil {
return ws.BrandTLS.GetCertificate(ch)
bcert := ws.BrandTLS.GetCertificate(ch)
cfg.Certificates = []tls.Certificate{*bcert.Web}
ws.log.Trace("using brand web Certificate")
if bcert.Client != nil {
cfg.ClientCAs = bcert.Client
cfg.ClientAuth = tls.RequestClientCert
ws.log.Trace("using brand client Certificate")
}
return cfg, nil
}
ws.log.Trace("using default, self-signed certificate")
return &cert, nil
cfg.Certificates = []tls.Certificate{fallback}
return cfg, nil
}
}
// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests
func (ws *WebServer) listenTLS() {
tlsConfig := utils.GetTLSConfig()
tlsConfig.GetCertificate = ws.GetCertificate()
tlsConfig.GetConfigForClient = ws.GetCertificate()
ln, err := net.Listen("tcp", config.Get().Listen.HTTPS)
if err != nil {
ws.log.WithError(err).Warning("failed to listen (TLS)")
return
}
proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()}
proxyListener := &proxyproto.Listener{
Listener: web.TCPKeepAliveListener{
TCPListener: ln.(*net.TCPListener),
},
ConnPolicy: utils.GetProxyConnectionPolicy(),
}
defer func() {
err := proxyListener.Close()
if err != nil {

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1015.0",
"aws-cdk": "^2.1016.0",
"cross-env": "^7.0.3"
},
"engines": {
@ -17,9 +17,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1015.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1015.0.tgz",
"integrity": "sha512-txd+yMVVybtLfiwT409+fahbP0SkiwhmQvQf6PVVYnWzDPSknxYlUNJHisHV4tJEcbHWn1QPsLmqqMT0bw8hBg==",
"version": "2.1016.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1016.0.tgz",
"integrity": "sha512-zdJ/tQp0iE/s8l8zLQPgdUJUHpS6KblkzdP5nOYC/NbD5OCdhS8QS7vLBkT8M7mNyZh3Ep3C+/m6NsxrurRe0A==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@ -10,7 +10,7 @@
"node": ">=20"
},
"devDependencies": {
"aws-cdk": "^2.1015.0",
"aws-cdk": "^2.1016.0",
"cross-env": "^7.0.3"
}
}

View File

@ -4460,6 +4460,15 @@ paths:
name: branding_title
schema:
type: string
- in: query
name: client_certificates
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- in: query
name: default
schema:
@ -24978,6 +24987,7 @@ paths:
- authentik_stages_identification.identificationstage
- authentik_stages_invitation.invitation
- authentik_stages_invitation.invitationstage
- authentik_stages_mtls.mutualtlsstage
- authentik_stages_password.passwordstage
- authentik_stages_prompt.prompt
- authentik_stages_prompt.promptstage
@ -25226,6 +25236,7 @@ paths:
- authentik_stages_identification.identificationstage
- authentik_stages_invitation.invitation
- authentik_stages_invitation.invitationstage
- authentik_stages_mtls.mutualtlsstage
- authentik_stages_password.passwordstage
- authentik_stages_prompt.prompt
- authentik_stages_prompt.promptstage
@ -37718,6 +37729,311 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/mtls/:
get:
operationId: stages_mtls_list
description: MutualTLSStage Viewset
parameters:
- in: query
name: cert_attribute
schema:
type: string
enum:
- common_name
- email
- subject
- in: query
name: certificate_authorities
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- in: query
name: mode
schema:
type: string
enum:
- optional
- required
- in: query
name: name
schema:
type: string
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: stage_uuid
schema:
type: string
format: uuid
- in: query
name: user_attribute
schema:
type: string
enum:
- email
- username
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedMutualTLSStageList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: stages_mtls_create
description: MutualTLSStage Viewset
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStageRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/mtls/{stage_uuid}/:
get:
operationId: stages_mtls_retrieve
description: MutualTLSStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Mutual TLS Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: stages_mtls_update
description: MutualTLSStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Mutual TLS Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStageRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: stages_mtls_partial_update
description: MutualTLSStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Mutual TLS Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedMutualTLSStageRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/MutualTLSStage'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: stages_mtls_destroy
description: MutualTLSStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Mutual TLS Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/mtls/{stage_uuid}/used_by/:
get:
operationId: stages_mtls_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Mutual TLS Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/password/:
get:
operationId: stages_password_list
@ -40946,6 +41262,7 @@ components:
- authentik.enterprise.providers.microsoft_entra
- authentik.enterprise.providers.ssf
- authentik.enterprise.stages.authenticator_endpoint_gdtc
- authentik.enterprise.stages.mtls
- authentik.enterprise.stages.source
- authentik.events
type: string
@ -42609,6 +42926,12 @@ components:
format: uuid
nullable: true
description: Web Certificate used by the authentik Core webserver.
client_certificates:
type: array
items:
type: string
format: uuid
description: Certificates used for client authentication.
attributes: {}
required:
- brand_uuid
@ -42673,6 +42996,12 @@ components:
format: uuid
nullable: true
description: Web Certificate used by the authentik Core webserver.
client_certificates:
type: array
items:
type: string
format: uuid
description: Certificates used for client authentication.
attributes: {}
required:
- domain
@ -42842,6 +43171,12 @@ components:
- name
- private_key
- public_key
CertAttributeEnum:
enum:
- subject
- common_name
- email
type: string
CertificateData:
type: object
description: Get CertificateKeyPair's data
@ -48368,6 +48703,7 @@ components:
- authentik_providers_microsoft_entra.microsoftentraprovidermapping
- authentik_providers_ssf.ssfprovider
- authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage
- authentik_stages_mtls.mutualtlsstage
- authentik_stages_source.sourcestage
- authentik_events.event
- authentik_events.notificationtransport
@ -48375,6 +48711,96 @@ components:
- authentik_events.notificationrule
- authentik_events.notificationwebhookmapping
type: string
MutualTLSStage:
type: object
description: MutualTLSStage Serializer
properties:
pk:
type: string
format: uuid
readOnly: true
title: Stage uuid
name:
type: string
component:
type: string
description: Get object type so that we know how to edit the object
readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
readOnly: true
verbose_name_plural:
type: string
description: Return object's plural verbose_name
readOnly: true
meta_model_name:
type: string
description: Return internal model name
readOnly: true
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSet'
mode:
$ref: '#/components/schemas/MutualTLSStageModeEnum'
certificate_authorities:
type: array
items:
type: string
format: uuid
description: Configure certificate authorities to validate the certificate
against. This option has a higher priority than the `client_certificate`
option on `Brand`.
cert_attribute:
$ref: '#/components/schemas/CertAttributeEnum'
user_attribute:
$ref: '#/components/schemas/UserAttributeEnum'
required:
- cert_attribute
- component
- meta_model_name
- mode
- name
- pk
- user_attribute
- verbose_name
- verbose_name_plural
MutualTLSStageModeEnum:
enum:
- optional
- required
type: string
MutualTLSStageRequest:
type: object
description: MutualTLSStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
mode:
$ref: '#/components/schemas/MutualTLSStageModeEnum'
certificate_authorities:
type: array
items:
type: string
format: uuid
description: Configure certificate authorities to validate the certificate
against. This option has a higher priority than the `client_certificate`
option on `Brand`.
cert_attribute:
$ref: '#/components/schemas/CertAttributeEnum'
user_attribute:
$ref: '#/components/schemas/UserAttributeEnum'
required:
- cert_attribute
- mode
- name
- user_attribute
NameIdPolicyEnum:
enum:
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
@ -50220,6 +50646,18 @@ components:
required:
- pagination
- results
PaginatedMutualTLSStageList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/MutualTLSStage'
required:
- pagination
- results
PaginatedNotificationList:
type: object
properties:
@ -51896,6 +52334,12 @@ components:
format: uuid
nullable: true
description: Web Certificate used by the authentik Core webserver.
client_certificates:
type: array
items:
type: string
format: uuid
description: Certificates used for client authentication.
attributes: {}
PatchedCaptchaStageRequest:
type: object
@ -53079,6 +53523,31 @@ components:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
PatchedMutualTLSStageRequest:
type: object
description: MutualTLSStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
mode:
$ref: '#/components/schemas/MutualTLSStageModeEnum'
certificate_authorities:
type: array
items:
type: string
format: uuid
description: Configure certificate authorities to validate the certificate
against. This option has a higher priority than the `client_certificate`
option on `Brand`.
cert_attribute:
$ref: '#/components/schemas/CertAttributeEnum'
user_attribute:
$ref: '#/components/schemas/UserAttributeEnum'
PatchedNotificationRequest:
type: object
description: Notification Serializer
@ -59793,6 +60262,11 @@ components:
- pk
- uid
- username
UserAttributeEnum:
enum:
- username
- email
type: string
UserConsent:
type: object
description: UserConsent Serializer

View File

@ -6,7 +6,7 @@ services:
network_mode: host
restart: always
mailpit:
image: docker.io/axllent/mailpit:v1.24.2
image: docker.io/axllent/mailpit:v1.25.0
ports:
- 1025:1025
- 8025:8025

View File

@ -3,13 +3,10 @@
* @import { StorybookConfig } from "@storybook/web-components-vite";
* @import { InlineConfig, Plugin } from "vite";
*/
import { cwd } from "process";
import postcssLit from "rollup-plugin-postcss-lit";
import tsconfigPaths from "vite-tsconfig-paths";
const NODE_ENV = process.env.NODE_ENV || "development";
const CSSImportPattern = /import [\w\$]+ from .+\.(css)/g;
const CSSImportPattern = /import [\w$]+ from .+\.(css)/g;
const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/;
/**
@ -30,6 +27,11 @@ const inlineCSSPlugin = {
},
};
/**
* @satisfies {InlineConfig}
*/
// const viteFinal = ;
/**
* @satisfies {StorybookConfig}
*/
@ -48,22 +50,21 @@ const config = {
docs: {
autodocs: "tag",
},
viteFinal({ plugins = [], ...config }) {
async viteFinal(config) {
const [{ mergeConfig }, { createBundleDefinitions }] = await Promise.all([
import("vite"),
import("@goauthentik/web/bundler/utils/node"),
]);
/**
* @satisfies {InlineConfig}
*/
const mergedConfig = {
...config,
define: {
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
},
plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()],
const overrides = {
define: createBundleDefinitions(),
plugins: [inlineCSSPlugin, postcssLit(), tsconfigPaths()],
};
return mergedConfig;
return mergeConfig(config, overrides);
},
};
export default config;

View File

@ -1,10 +1,12 @@
/**
* @file MDX plugin for ESBuild.
*
* @import {
OnLoadArgs,
OnLoadResult,
Plugin,
PluginBuild
* } from 'esbuild'
* OnLoadArgs,
* OnLoadResult,
* Plugin,
* PluginBuild
* } from "esbuild"
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";

29
web/bundler/utils/node.js Normal file
View File

@ -0,0 +1,29 @@
/**
* @file Bundler utilities.
*/
import { NodeEnvironment, serializeEnvironmentVars } from "@goauthentik/core/environment/node";
import { AuthentikVersion } from "@goauthentik/core/version/node";
/**
* Creates a mapping of environment variables to their respective runtime constants.
*/
export function createBundleDefinitions() {
const SerializedNodeEnvironment = /** @type {`"development"` | `"production"`} */ (
JSON.stringify(NodeEnvironment)
);
/**
* @satisfies {Record<ESBuildImportEnvKey, string>}
*/
const envRecord = {
AK_VERSION: AuthentikVersion,
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "",
};
return {
...serializeEnvironmentVars(envRecord),
// We need to explicitly set this for NPM packages that use `process`
// to determine their environment.
"process.env.NODE_ENV": SerializedNodeEnvironment,
};
}

View File

@ -12,6 +12,7 @@ export default [
{
ignores: [
"dist/",
"out/",
// don't lint the cache
".wireit/",
// let packages have their own configurations
@ -71,7 +72,7 @@ export default [
...globals.node,
},
},
files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
files: ["scripts/**/*.mjs", "*.ts", "*.mjs", "**/node.js"],
rules: {
"no-unused-vars": "off",
// We WANT our scripts to output to the console!

981
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,15 +35,52 @@
"type": "module",
"exports": {
"./package.json": "./package.json",
"./paths": "./paths.js",
"./scripts/*": "./scripts/*.mjs",
"./elements/*": "./src/elements/*",
"./common/*": "./src/common/*",
"./components/*": "./src/components/*",
"./flow/*": "./src/flow/*",
"./locales/*": "./src/locales/*",
"./user/*": "./src/user/*",
"./admin/*": "./src/admin/*"
"./admin/*": "./src/admin/*",
"./*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
},
"./*/node": {
"types": "./out/*/node.d.ts",
"import": "./*/node.js"
},
"./*": {
"types": "./out/*/index.d.ts",
"import": "./*/index.js"
}
},
"imports": {
"#common/*.css": "./src/common/*.css",
"#common/*": "./src/common/*.js",
"#elements/*.css": "./src/elements/*.css",
"#elements/*": "./src/elements/*.js",
"#components/*.css": "./src/components/*.css",
"#components/*": "./src/components/*.js",
"#user/*.css": "./src/user/*.css",
"#user/*": "./src/user/*.js",
"#admin/*.css": "./src/admin/*.css",
"#admin/*": "./src/admin/*.js",
"#flow/*.css": "./src/flow/*.css",
"#flow/*": "./src/flow/*.js",
"#stories/*": "./src/stories/*.js",
"#*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
},
"#*/node": {
"types": "./out/*/node.d.ts",
"import": "./*/node.js"
},
"#*": {
"types": "./out/*/index.d.ts",
"import": "./*/index.js"
}
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@ -56,7 +93,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.4.1-1747332783",
"@goauthentik/api": "^2025.4.1-1747687715",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -105,21 +142,21 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@goauthentik/core": "^1.0.0",
"@goauthentik/esbuild-plugin-live-reload": "^1.0.4",
"@goauthentik/monorepo": "^1.0.0",
"@goauthentik/prettier-config": "^1.0.4",
"@goauthentik/tsconfig": "^1.0.4",
"@hcaptcha/types": "^1.0.4",
"@lit/localize-tools": "^0.8.0",
"@rollup/plugin-replace": "^6.0.1",
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-links": "^8.6.12",
"@storybook/blocks": "^8.6.12",
"@storybook/experimental-addon-test": "^8.6.12",
"@storybook/manager-api": "^8.6.12",
"@storybook/test": "^8.6.12",
"@storybook/web-components": "^8.6.12",
"@storybook/web-components-vite": "^8.6.12",
"@storybook/experimental-addon-test": "^8.6.14",
"@storybook/manager-api": "^8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/web-components": "^8.6.14",
"@storybook/web-components-vite": "^8.6.14",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/chart.js": "^2.9.41",
"@types/codemirror": "^5.60.15",
@ -152,10 +189,10 @@
"prettier": "^3.3.3",
"pseudolocale": "^2.1.0",
"rollup-plugin-postcss-lit": "^2.1.0",
"storybook": "^8.6.12",
"storybook": "^8.6.14",
"storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3",
"typescript": "^5.6.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.8.0",
"vite-plugin-lit-css": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1",
@ -382,16 +419,15 @@
"node": ">=20"
},
"workspaces": [
".",
"./packages/*"
],
"prettier": "@goauthentik/prettier-config",
"overrides": {
"rapidoc": {
"@apitools/openapi-parser@": "0.0.37"
},
"chromedriver": {
"axios": "^1.8.4"
},
"rapidoc": {
"@apitools/openapi-parser@": "0.0.37"
}
}
}

View File

@ -1,4 +1,4 @@
# `@goauthentik/monorepo`
# `@goauthentik/core`
This package contains utility scripts common to all TypeScript and JavaScript packages in the
`@goauthentik` monorepo.

View File

@ -0,0 +1,66 @@
/**
* @file Utility functions for working with environment variables.
*/
/// <reference types="../types/node.js" />
//#region Constants
/**
* The current Node.js environment, defaulting to "development" when not set.
*
* Note, this should only be used during the build process.
*
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
* ensure that module tree-shaking works correctly.
*
* @category Environment
* @runtime node
*/
export const NodeEnvironment = process.env.NODE_ENV || "development";
/**
* A source environment variable, which can be a string, number, boolean, null, or undefined.
* @typedef {string | number | boolean | null | undefined} EnvironmentVariable
*/
/**
* A type helper for serializing environment variables.
*
* @category Environment
* @template {EnvironmentVariable} T
* @typedef {T extends string ? `"${T}"` : T} JSONify
*/
//#endregion
//#region Utilities
/**
* Given an object of environment variables, serializes them into a mapping of
* environment variable names to their respective runtime constants.
*
* This is useful for defining environment variables while bundling with ESBuild, Vite, etc.
*
* @category Environment
* @runtime node
*
* @template {Record<string, EnvironmentVariable>} EnvRecord
* @template {string} [Prefix='import.meta.env.']
*
* @param {EnvRecord} input
* @param {Prefix} [prefix='import.meta.env.']
*
* @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify<EnvRecord[K]>}}
*/
export function serializeEnvironmentVars(
input,
prefix = /** @type {Prefix} */ ("import.meta.env."),
) {
const env = Object.fromEntries(
Object.entries(input).map(([key, value]) => [prefix + key, JSON.stringify(value ?? "")]),
);
return /** @type {any} */ (env);
}
//#endregion

View File

@ -0,0 +1,12 @@
/**
* @file Dummy entry point for the `core` package.
*
* This exists to make TypeScript's module resolution more predictable.
*
* @internal
* @ignore
*/
export {};
export default {};

View File

@ -0,0 +1,59 @@
{
"name": "@goauthentik/core",
"version": "1.0.0",
"description": "Core functionality for the authentik monorepo",
"license": "MIT",
"private": true,
"scripts": {
"build": "tsc -p .",
"prettier": "prettier --write .",
"prettier-check": "prettier --check ."
},
"main": "index.js",
"type": "module",
"exports": {
"./package.json": "./package.json",
"./*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
},
"./*/node": {
"types": "./out/*/node.d.ts",
"import": "./*/node.js"
},
"./*": {
"types": "./out/*/index.d.ts",
"import": "./*/index.js"
},
".": {
"import": "./index.js",
"types": "./out/index.d.ts"
}
},
"imports": {
"#*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
},
"#*/node": {
"types": "./out/*/node.d.ts",
"import": "./*/node.js"
},
"#*": {
"types": "./out/*/index.d.ts",
"import": "./*/index.js"
}
},
"devDependencies": {
"@goauthentik/prettier-config": "^1.0.4",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^22.14.1",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
},
"engines": {
"node": ">=20.11"
},
"types": "./out/index.d.ts",
"prettier": "@goauthentik/prettier-config"
}

View File

@ -10,22 +10,26 @@ const relativeDirname = dirname(fileURLToPath(import.meta.url));
/**
* The root of the authentik monorepo.
*
* @runtime node
*/
// TODO: Revise when this package is moved to the monorepo's `packages/monorepo` directory.
export const MonoRepoRoot = /** @type {MonoRepoRoot} */ (
resolve(relativeDirname, "..", "..", "..")
resolve(relativeDirname, "..", "..", "..", "..")
);
const require = createRequire(import.meta.url);
/**
* Resolve a package name to its location in the monorepo to the single node_modules directory.
* @param {string} packageName
*
* @param {string} packageName
* @param {ImportMeta} [meta] The `import.meta` object of the module.
*
* @runtime node
* @returns {string} The resolved path to the package.
* @throws {Error} If the package cannot be resolved.
*/
export function resolvePackage(packageName) {
export function resolvePackage(packageName, meta) {
const require = createRequire(meta ? meta.url : import.meta.url);
const relativePackageJSONPath = join(packageName, "package.json");
/** @type {string} */
@ -34,7 +38,7 @@ export function resolvePackage(packageName) {
try {
absolutePackageJSONPath = require.resolve(relativePackageJSONPath);
} catch (cause) {
const error = new Error(`Failed to resolve package "${packageName}"`);
const error = new Error(`🚫 Failed to resolve package "${packageName}"`);
error.cause = cause;

View File

@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
* @param {ImportMeta} meta The `import.meta` object of the module.
*
* @return {boolean} Whether the module was run directly.
* @runtime node
*/
export function isMain(meta) {
// Are we not in a module context?

View File

@ -1,9 +1,11 @@
{
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"resolveJsonModule": true,
"baseUrl": ".",
"checkJs": true,
"allowJs": true,
"emitDeclarationOnly": true
}
}

54
web/packages/core/types/node.d.ts vendored Normal file
View File

@ -0,0 +1,54 @@
/**
* @file Global variables provided by Node.js
*/
declare module "module" {
global {
/**
* @deprecated This is not present in ESM files.
*
* ```js
* import { dirname } from "node:path";
* import { fileURLToPath } from "node:url";
*
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ```
*/
// eslint-disable-next-line no-var
var __dirname: string;
}
}
declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
/**
* The port used by the Docker Compose HTTP service.
*/
COMPOSE_PORT_HTTP?: string;
/**
* The port used by the Docker Compose HTTPS service.
*/
COMPOSE_PORT_HTTPS?: string;
/**
* An environment variable used to determine
* whether Node.js is running in production mode.
*
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
* @runtime node
*/
readonly NODE_ENV?: "development" | "production";
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.
* @deprecated
* @runtime node
*/
readonly AK_API_BASE_PATH?: string;
}
}
}
}

View File

@ -0,0 +1,21 @@
/**
* @file Utility functions for working with semantic versions.
*
* @runtime common
*/
/**
* Creates a URL to the release notes for the given version.
*
* @param {string} semver
* @returns {URL}
* @runtime common
*/
export function createReleaseNotesURL(semver) {
const segments = semver.split(".");
const versionFamily = segments.slice(0, -1).join(".");
const release = `${versionFamily}#fixed-in-${segments.join("")}`;
return new URL(`/docs/releases/${release}`, "https://goauthentik.io");
}

View File

@ -1,16 +1,24 @@
/**
* @file Utility functions for working with semantic versions.
*
* @runtime node
*/
import { MonoRepoRoot } from "#paths/node";
import { execSync } from "node:child_process";
import PackageJSON from "../../../package.json" with { type: "json" };
import { MonoRepoRoot } from "./paths.js";
import PackageJSON from "../../../../package.json" with { type: "json" };
/**
* The current version of authentik in SemVer format.
*
* @runtime node
*/
export const AuthentikVersion = /**@type {`${number}.${number}.${number}`} */ (PackageJSON.version);
/**
* Reads the last commit hash from the current git repository.
*
* @runtime node
*/
export function readGitBuildHash() {
try {
@ -34,6 +42,7 @@ export function readGitBuildHash() {
*
* This must match the behavior defined in authentik's server-side `get_full_version` function.
*
* @runtime node
* @see {@link "authentik\_\_init\_\_.py"}
*/
export function readBuildIdentifier() {

View File

@ -21,7 +21,7 @@ const log = console.debug.bind(console, logPrefix);
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development") {
* if (process.env.NODE_ENV=== "development") {
* await import("@goauthentik/esbuild-plugin-live-reload/client")
* .catch(() => console.warn("Failed to import watcher"))
* }

View File

@ -4,15 +4,20 @@
export {};
declare global {
/**
* Environment variables injected by ESBuild.
*/
interface ImportMetaEnv {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
readonly ESBUILD_WATCHER_URL?: string;
}
interface ImportMeta {
readonly env: {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
ESBUILD_WATCHER_URL: string;
};
readonly env: ImportMetaEnv;
}
}

View File

@ -1,42 +0,0 @@
/**
* @file Utility functions for building and copying files.
*/
/**
* A source environment variable, which can be a string, number, boolean, null, or undefined.
* @typedef {string | number | boolean | null | undefined} EnvironmentVariable
*/
/**
* A type helper for serializing environment variables.
*
* @template {EnvironmentVariable} T
* @typedef {T extends string ? `"${T}"` : T} JSONify
*/
/**
* Given an object of environment variables, returns a new object with the same keys and values, but
* with the values serialized as strings.
*
* @template {Record<string, EnvironmentVariable>} EnvRecord
* @template {string} [Prefix='process.env.']
*
* @param {EnvRecord} input
* @param {Prefix} [prefix='process.env.']
*
* @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify<EnvRecord[K]>}}
*/
export function serializeEnvironmentVars(input, prefix = /** @type {Prefix} */ ("process.env.")) {
/**
* @type {Record<string, string>}
*/
const env = {};
for (const [key, value] of Object.entries(input)) {
const namespaceKey = prefix + key;
env[namespaceKey] = JSON.stringify(value || "");
}
return /** @type {any} */ (env);
}

View File

@ -1,16 +0,0 @@
/**
* @file Constants for JavaScript and TypeScript files.
*/
/// <reference types="../../types/global.js" />
/**
* The current Node.js environment, defaulting to "development" when not set.
*
* Note, this should only be used during the build process.
*
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
* ensure that module tree-shaking works correctly.
*
*/
export const NodeEnvironment = process.env.NODE_ENV || "development";

View File

@ -1,7 +0,0 @@
/// <reference types="./types/global.js" />
export * from "./paths.js";
export * from "./constants.js";
export * from "./build.js";
export * from "./version.js";
export * from "./scripting.js";

View File

@ -1,28 +0,0 @@
{
"name": "@goauthentik/monorepo",
"version": "1.0.0",
"description": "Utilities for the authentik monorepo.",
"license": "MIT",
"private": true,
"main": "index.js",
"type": "module",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./out/index.d.ts",
"import": "./index.js"
}
},
"devDependencies": {
"@goauthentik/prettier-config": "^1.0.4",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^22.14.1",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
},
"engines": {
"node": ">=20.11"
},
"types": "./out/index.d.ts",
"prettier": "@goauthentik/prettier-config"
}

View File

@ -1,15 +0,0 @@
declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
/**
* An environment variable used to determine
* whether Node.js is running in production mode.
*
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
NODE_ENV?: "production" | "development";
}
}
}
}

View File

@ -1,23 +0,0 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"]
}

View File

@ -1,6 +1,15 @@
{
"name": "@goauthentik/web-sfe",
"name": "@goauthentik/sfe",
"version": "0.0.0",
"license": "MIT",
"private": true,
"scripts": {
"build": "rollup -c rollup.config.mjs --bundleConfigAsCjs",
"prettier": "prettier --cache --write -u .",
"prettier-check": "prettier --cache --check -u .",
"watch": "rollup -w -c rollup.config.mjs --bundleConfigAsCjs"
},
"type": "module",
"dependencies": {
"@goauthentik/api": "^2024.6.0-1719577139",
"base64-js": "^1.5.1",
@ -10,20 +19,22 @@
"weakmap-polyfill": "^2.0.4"
},
"devDependencies": {
"@goauthentik/core": "^1.0.0",
"@goauthentik/prettier-config": "^1.0.1",
"@goauthentik/tsconfig": "^1.0.1",
"@goauthentik/web": "file:../web",
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-swc": "^0.4.0",
"@swc/cli": "^0.4.0",
"@swc/core": "^1.7.28",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/jquery": "^3.5.31",
"lockfile-lint": "^4.14.0",
"prettier": "^3.3.2",
"rollup": "^4.23.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"prettier": "^3.5.3",
"prettier-plugin-packagejson": "^2.5.13",
"rollup": "^4.40.2",
"rollup-plugin-copy": "^3.5.0",
"wireit": "^0.14.9"
"typescript": "^5.8.3"
},
"license": "MIT",
"optionalDependencies": {
"@swc/core": "^1.7.28",
"@swc/core-darwin-arm64": "^1.6.13",
@ -37,32 +48,8 @@
"@swc/core-win32-ia32-msvc": "^1.6.13",
"@swc/core-win32-x64-msvc": "^1.6.13"
},
"private": true,
"scripts": {
"build": "wireit",
"lint:lockfile": "wireit",
"prettier": "prettier --write ./src ./tsconfig.json ./rollup.config.js ./package.json",
"watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
"engines": {
"node": ">=22.15.1"
},
"wireit": {
"build:sfe": {
"command": "rollup -c rollup.config.js --bundleConfigAsCjs",
"files": [
"../../node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/index.ts"
],
"output": [
"./dist/sfe/*"
]
},
"build": {
"command": "mkdir -p ../../dist/sfe && cp -r dist/sfe/* ../../dist/sfe",
"dependencies": [
"build:sfe"
]
},
"lint:lockfile": {
"command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
}
}
"prettier": "@goauthentik/prettier-config"
}

View File

@ -1,12 +1,26 @@
/**
* @file Rollup configuration for the SFE package.
*
* @import { RollupOptions } from "rollup";
*/
import { resolvePackage } from "@goauthentik/core/paths/node";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import swc from "@rollup/plugin-swc";
import { resolve as resolvePath } from "node:path";
import copy from "rollup-plugin-copy";
export default {
import { DistDirectory } from "../../paths/node.js";
const distDirectory = resolvePath(DistDirectory, "sfe");
/**
* @type {RollupOptions}
*/
const config = {
input: "src/index.ts",
output: {
dir: "./dist/sfe",
dir: distDirectory,
format: "cjs",
},
context: "window",
@ -14,8 +28,14 @@ export default {
copy({
targets: [
{
src: "../../node_modules/bootstrap/dist/css/bootstrap.min.css",
dest: "./dist/sfe",
src: resolvePackage(
"bootstrap",
"dist",
"css",
"bootstrap.min.css",
import.meta,
),
dest: distDirectory,
},
],
}),
@ -41,3 +61,5 @@ export default {
}),
],
};
export default config;

View File

@ -1,6 +1,8 @@
{
"compilerOptions": {
"types": ["jquery"],
"noEmit": true,
"declaration": false,
"types": [],
"esModuleInterop": true,
"lib": ["DOM", "ES2015", "ES2017"]
}

42
web/packages/sfe/types/jquery.d.ts vendored Normal file
View File

@ -0,0 +1,42 @@
/**
* @file Minimal typings for jQuery.
*
* This file provides a small subset of the jQuery typings used in authentik's
* Simplified Flow Executor.
*
* Unlike the `@types/jquery` package, this package does not expose `$` as a global
* which causes issues with WebdriverIO.
*/
declare module "jquery" {
export function JQuery<TElement = HTMLElement>(selector: string): JQuery<TElement>;
export namespace JQuery {
function ajax(options: JQueryAjaxSettings): void;
}
export interface JQuery<TElement = HTMLElement> extends ArrayLike<TElement> {
getElements(): TElement[];
getElement(): TElement;
getText(): string;
submit(): void;
html(html: string): JQuery<TElement>;
addClass(className: string): JQuery<TElement>;
removeClass(className: string): JQuery<TElement>;
on(eventName: string, handler: (event: Event) => void): JQuery<TElement>;
trigger(eventName: string): void;
ajax(options: JQueryAjaxSettings): void;
}
export interface JQueryAjaxSettings {
url: string;
type?: string;
data?: string;
contentType?: string;
dataType?: string;
success?: (data: unknown) => void;
error?: (data: unknown) => void;
}
export default JQuery;
}

View File

@ -12,7 +12,7 @@ const relativeDirname = dirname(fileURLToPath(import.meta.url));
/**
* The root of the web package.
*/
export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(relativeDirname));
export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(relativeDirname, ".."));
/**
* The name of the distribution directory.
@ -25,7 +25,7 @@ export const DistDirectoryName = "dist";
* This is where the built files are located after running the build process.
*/
export const DistDirectory = /** @type {`${WebPackageIdentifier}/${DistDirectoryName}`} */ (
resolve(relativeDirname, DistDirectoryName)
resolve(PackageRoot, DistDirectoryName)
);
//#endregion

View File

@ -1,17 +1,33 @@
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import process from "process";
/**
* @file Lit Localize build script.
*
* @remarks
* Determines if all the Xliff translation source files are present and
* if the Typescript source files generated from those sources are up-to-date.
*
* If they are not, it runs the locale building script, intercepting the
* long spew of "this string is not translated" and replacing it with a
* summary of how many strings are missing with respect to the source locale.
*
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config"
*/
import { PackageRoot } from "#paths/node";
import { spawnSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import path from "node:path";
/**
* Determines if all the Xliff translation source files are present and if the Typescript source
* files generated from those sources are up-to-date. If they are not, it runs the locale building
* script, intercepting the long spew of "this string is not translated" and replacing it with a
* summary of how many strings are missing with respect to the source locale.
* @type {ConfigFile}
*/
const localizeRules = JSON.parse(
readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"),
);
const localizeRules = JSON.parse(fs.readFileSync("./lit-localize.json", "utf-8"));
/**
*
* @param {string} loc
* @returns {boolean}
*/
function generatedFileIsUpToDateWithXliffSource(loc) {
const xliff = path.join("./xliff", `${loc}.xlf`);
const gened = path.join("./src/locales", `${loc}.ts`);
@ -22,7 +38,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
// generates a unique error message and halts the build.
try {
var xlfStat = fs.statSync(xliff);
var xlfStat = statSync(xliff);
} catch (_error) {
console.error(`lit-localize expected '${loc}.xlf', but XLF file is not present`);
process.exit(1);
@ -30,7 +46,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
// If the generated file doesn't exist, of course it's not up to date.
try {
var genedStat = fs.statSync(gened);
var genedStat = statSync(gened);
} catch (_error) {
return false;
}

View File

@ -1,17 +1,16 @@
/// <reference types="../types/esbuild.js" />
/**
* @file ESBuild script for building the authentik web UI.
*
* @import { BuildOptions } from "esbuild";
*/
import { mdxPlugin } from "#bundler/mdx-plugin/node";
import { createBundleDefinitions } from "#bundler/utils/node";
import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "#paths/node";
import { NodeEnvironment } from "@goauthentik/core/environment/node";
import { MonoRepoRoot, resolvePackage } from "@goauthentik/core/paths/node";
import { readBuildIdentifier } from "@goauthentik/core/version/node";
import { liveReloadPlugin } from "@goauthentik/esbuild-plugin-live-reload/plugin";
import {
MonoRepoRoot,
NodeEnvironment,
readBuildIdentifier,
resolvePackage,
serializeEnvironmentVars,
} from "@goauthentik/monorepo";
import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "@goauthentik/web/paths";
import { deepmerge } from "deepmerge-ts";
import esbuild from "esbuild";
import copy from "esbuild-plugin-copy";
@ -19,17 +18,9 @@ import { polyfillNode } from "esbuild-plugin-polyfill-node";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs";
const logPrefix = "[Build]";
const definitions = serializeEnvironmentVars({
NODE_ENV: NodeEnvironment,
CWD: process.cwd(),
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH,
});
const patternflyPath = resolvePackage("@patternfly/patternfly");
const patternflyPath = resolvePackage("@patternfly/patternfly", import.meta);
/**
* @type {Readonly<BuildOptions>}
@ -86,7 +77,7 @@ const BASE_ESBUILD_OPTIONS = {
root: MonoRepoRoot,
}),
],
define: definitions,
define: createBundleDefinitions(),
format: "esm",
logOverride: {
/**
@ -168,6 +159,19 @@ async function doWatch() {
await buildContext.rebuild();
await buildContext.watch();
const httpURL = new URL("http://localhost");
httpURL.port = process.env.COMPOSE_PORT_HTTP ?? "9000";
const httpsURL = new URL("http://localhost");
httpsURL.port = process.env.COMPOSE_PORT_HTTPS ?? "9443";
console.log(`\n${logPrefix} 🚀 Server running\n\n`);
console.log(` 🔓 ${httpURL.href}`);
console.log(` 🔒 ${httpsURL.href}`);
console.log(`\n---`);
return /** @type {Promise<void>} */ (
new Promise((resolve) => {
process.on("SIGINT", () => {

View File

@ -35,6 +35,11 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
const projectRoot = path.join(__dirname, "..");
process.chdir(projectRoot);
/**
*
* @param {string[]} flags
* @returns
*/
const hasFlag = (flags) => process.argv.length > 1 && flags.includes(process.argv[2]);
const [configFile, files] = hasFlag(["-n", "--nightmare"])

View File

@ -152,6 +152,7 @@ export default [
{
ignores: [
"dist/",
"out/",
".wireit/",
"packages/",
// don't ever lint node_modules

View File

@ -1,22 +1,36 @@
import { readFileSync } from "fs";
import path from "path";
/**
* @file Pseudo-localization script.
*
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config.js"
* @import { Config } from '@lit/localize-tools/lib/types/config.js';
* @import { ProgramMessage } from "@lit/localize-tools/src/messages.js"
* @import { Locale } from "@lit/localize-tools/src/types/locale.js"
*/
import { PackageRoot } from "#paths/node";
import { readFileSync } from "node:fs";
import path from "node:path";
import pseudolocale from "pseudolocale";
import { fileURLToPath } from "url";
import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const pseudoLocale = "pseudo-LOCALE";
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
const targetLocales = [pseudoLocale];
const baseConfig = JSON.parse(readFileSync(path.join(__dirname, "../lit-localize.json"), "utf-8"));
/**
* @type {ConfigFile}
*/
const baseConfig = JSON.parse(readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"));
// Need to make some internal specifications to satisfy the transformer. It doesn't actually matter
// which Localizer we use (transformer or runtime), because all of the functionality we care about
// is in their common parent class, but I had to pick one. Everything else here is just pure
// exploitation of the lit/localize-tools internals.
/**
* @satisfies {Config}
*/
const config = {
...baseConfig,
baseDir: path.join(__dirname, ".."),
@ -28,6 +42,11 @@ const config = {
resolve: (path) => path,
};
/**
*
* @param {ProgramMessage} message
* @returns
*/
const pseudoMessagify = (message) => ({
name: message.name,
contents: message.contents.map((content) =>
@ -36,7 +55,7 @@ const pseudoMessagify = (message) => ({
});
const localizer = new TransformLitLocalizer(config);
const messages = localizer.extractSourceMessages().messages;
const { messages } = localizer.extractSourceMessages();
const translations = messages.map(pseudoMessagify);
const sorted = sortProgramMessages([...messages]);
const formatter = makeFormatter(config);

View File

@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { DefaultBrand } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/EmptyState";
@ -45,7 +44,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
}
return [
[msg("Version"), version.versionCurrent],
[msg("UI Version"), VERSION],
[msg("UI Version"), import.meta.env.AK_VERSION],
[msg("Build"), build],
[msg("Python version"), status.runtime.pythonVersion],
[msg("Platform"), status.runtime.platform],
@ -59,7 +58,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
renderModal() {
let product = globalAK().brand.brandingTitle || DefaultBrand.brandingTitle;
if (this.licenseSummary.status != LicenseSummaryStatusEnum.Unlicensed) {
if (this.licenseSummary.status !== LicenseSummaryStatusEnum.Unlicensed) {
product += ` ${msg("Enterprise")}`;
}
return html`<div

View File

@ -1,29 +1,26 @@
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { configureSentry } from "#common/sentry/index";
import { me } from "#common/users";
import { WebsocketClient } from "#common/ws";
import { AuthenticatedInterface } from "#elements/Interface/Interface";
import { WithLicenseSummary } from "#elements/Interface/licenseSummaryProvider";
import "#elements/ak-locale-context/ak-locale-context";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/banner/VersionBanner";
import "#elements/messages/MessageContainer";
import "#elements/messages/MessageContainer";
import "#elements/notifications/APIDrawer";
import "#elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import "#elements/router/RouterOutlet";
import "#elements/sidebar/Sidebar";
import "#elements/sidebar/SidebarItem";
import "@goauthentik/admin/AdminInterface/AboutModal";
import type { AboutModal } from "@goauthentik/admin/AdminInterface/AboutModal";
import { ROUTES } from "@goauthentik/admin/Routes";
import {
EVENT_API_DRAWER_TOGGLE,
EVENT_NOTIFICATION_DRAWER_TOGGLE,
} from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet";
import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
import { SidebarToggleEventDetail } from "@goauthentik/components/ak-page-header.js";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, eventOptions, property, query } from "lit/decorators.js";

View File

@ -1,9 +1,9 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { DEFAULT_CONFIG } from "#common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import "#components/ak-page-header";
import { AKElement } from "#elements/Base";
import { showMessage } from "#elements/messages/MessageContainer";
import * as Sentry from "@sentry/browser";
import { CSSResult, TemplateResult, html } from "lit";

View File

@ -1,29 +1,27 @@
import "@goauthentik/admin/admin-overview/TopApplicationsTable";
import "@goauthentik/admin/admin-overview/cards/AdminStatusCard";
import "@goauthentik/admin/admin-overview/cards/FipsStatusCard";
import "@goauthentik/admin/admin-overview/cards/RecentEventsCard";
import "@goauthentik/admin/admin-overview/cards/SystemStatusCard";
import "@goauthentik/admin/admin-overview/cards/VersionStatusCard";
import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard";
import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart";
import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart";
import "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { VERSION } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import "#admin/admin-overview/TopApplicationsTable";
import "#admin/admin-overview/cards/AdminStatusCard";
import "#admin/admin-overview/cards/FipsStatusCard";
import "#admin/admin-overview/cards/RecentEventsCard";
import "#admin/admin-overview/cards/SystemStatusCard";
import "#admin/admin-overview/cards/VersionStatusCard";
import "#admin/admin-overview/cards/WorkerStatusCard";
import "#admin/admin-overview/charts/AdminLoginAuthorizeChart";
import "#admin/admin-overview/charts/OutpostStatusChart";
import "#admin/admin-overview/charts/SyncStatusChart";
import { me } from "#common/users";
import "#components/ak-page-header";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/Interface/licenseSummaryProvider";
import "#elements/cards/AggregatePromiseCard";
import type { QuickAction } from "#elements/cards/QuickActionsCard";
import "#elements/cards/QuickActionsCard";
import { paramURL } from "#elements/router/RouterOutlet";
import { createReleaseNotesURL } from "@goauthentik/core/version";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { when } from "lit/directives/when.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
@ -33,21 +31,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SessionUser } from "@goauthentik/api";
export function versionFamily(): string {
const parts = VERSION.split(".");
parts.pop();
return parts.join(".");
}
const RELEASE = `${VERSION.split(".").slice(0, -1).join(".")}#fixed-in-${VERSION.replaceAll(
".",
"",
)}`;
const AdminOverviewBase = WithLicenseSummary(AKElement);
type Renderer = () => TemplateResult | typeof nothing;
@customElement("ak-admin-overview")
export class AdminOverviewPage extends AdminOverviewBase {
static get styles(): CSSResult[] {
@ -83,7 +68,11 @@ export class AdminOverviewPage extends AdminOverviewBase {
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
[
msg("Check the release notes"),
createReleaseNotesURL(import.meta.env.AK_VERSION).href,
true,
],
];
@state()
@ -193,45 +182,6 @@ export class AdminOverviewPage extends AdminOverviewBase {
</div>`
: nothing} `;
}
renderActions() {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
];
const action = ([label, url]: [string, string]) => {
const isExternal = url.startsWith("https://");
const ex = (truecase: Renderer, falsecase: Renderer) =>
when(isExternal, truecase, falsecase);
const content = html`${label}${ex(
() => html`<i class="fas fa-external-link-alt ak-external-link"></i>`,
() => nothing,
)}`;
return html`<li>
${ex(
() =>
html`<a
href="${url}"
class="pf-u-mb-xl"
rel="noopener noreferrer"
target="_blank"
>${content}</a
>`,
() => html`<a href="${url}" class="pf-u-mb-xl" )>${content}</a>`,
)}
</li>`;
};
return html`${map(quickActions, action)}`;
}
}
declare global {

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