Compare commits

...

34 Commits

Author SHA1 Message Date
Dewi Roberts
548aef46d2 Cherry-pick #22059 to version-2026.2 (with conflicts)
This cherry-pick has conflicts that need manual resolution.

Original PR: #22059
Original commit: 716bc6e136
2026-05-05 13:04:54 +00:00
authentik-automation[bot]
7af9e98079 rbac: ensure migration 0056 runs before 0010 removes group field (cherry-pick #21964 to version-2026.2) (#22033)
fix(rbac): ensure migration 0056 runs before 0010 removes group field (#21964)

fix(rbac): ensure migration 0056 runs before group field is removed

Migration 0010 removes the `group` FK from the Role model, but
migration 0056 (authentik_core) queries `group_id` on Role as part of
a data migration to move guardian permissions to RBAC roles.

When upgrading from 2025.x, Django's migration executor can schedule
0010 before 0056 because neither depends on the other — only 0056
depends on 0008. This causes a FieldError at runtime:

  Cannot resolve keyword 'group_id' into field.

Adding 0056 as a dependency of 0010 enforces the correct ordering:
the data migration that reads `group_id` must complete before the
schema migration that removes it.

Co-authored-by: Chris <cxm6467@gmail.com>
2026-05-04 18:06:55 +02:00
authentik-automation[bot]
51901c82ba core: fix search for app entitlements failing (cherry-pick #21944 to version-2026.2) (#21988)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix search for app entitlements failing (#21944)
2026-04-30 11:59:01 +00:00
authentik-automation[bot]
ff653005e4 web/packages: Rework SFE rendering (cherry-pick #21833 to version-2026.2) (#21850)
* Cherry-pick #21833 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21833
Original commit: b66024f26f

* fix conflict

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-27 14:41:38 +02:00
authentik-automation[bot]
9b64d05e35 providers/radius: fix message authenticator validation (cherry-pick #21824 to version-2026.2) (#21828)
providers/radius: fix message authenticator validation (#21824)

* providers/radius: fix message authenticator validation



* fix panic



* send message auth



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-25 21:13:53 +02:00
authentik-automation[bot]
99a93fa8a2 website/docs: improve social login docs titles (cherry-pick #21816 to version-2026.2) (#21818)
website/docs: improve social login docs titles (#21816)

* website/docs: improve social login docs titles



* sigh twitter



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-24 15:58:48 +00:00
authentik-automation[bot]
bd2a0e1d7d providers/oauth2: clip device authorization scope against the provider's ScopeMapping set (cherry-pick #21701 to version-2026.2) (#21799)
providers/oauth2: clip device authorization scope against the provider's ScopeMapping set (#21701)

* providers/oauth2: clip device authorization scope against the provider's ScopeMapping set

DeviceView.parse_request stored the raw request scope straight onto the
DeviceToken:

	self.scopes = self.request.POST.get("scope", "").split(" ")
	...
	token = DeviceToken.objects.create(..., _scope=" ".join(self.scopes))

The token-exchange side then reads those scopes back directly:

	if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
		refresh_token = RefreshToken(...)
		...

so a caller that adds offline_access to the device authorization
request body gets a refresh_token at the exchange, even when the
provider has no offline_access ScopeMapping configured. Every other
grant type clips scope against ScopeMapping for the provider inside
TokenParams.__check_scopes, but the device authorization endpoint
runs before TokenParams is ever constructed, so the clip never
happens for the device flow.

Combined with #20828 (missing client_secret verification on device
code exchange for confidential clients, now being fixed separately)
and the lack of per-app opt-out for the device flow, this gives any
caller that knows the client_id a path to an offline refresh token
against any OIDC application the deployment exposes.

Intersect the requested scope set with the provider's ScopeMapping
names before we ever persist the DeviceToken. offline_access that is
not configured is silently dropped, matching __check_scopes on the
other grant types. Configured offline_access still flows through
unchanged.

Fixes #20825



* rework and add tests



---------

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Sai Asish Y <say.apm35@gmail.com>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-23 15:25:14 +02:00
authentik-automation[bot]
c4d455dd3a website/docs: add authorization header info to all proxy configs (cherry-pick #21664 to version-2026.2) (#21786)
website/docs: add authorization header info to all proxy configs (#21664)

Add authorization header info to all proxy configs

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-23 11:52:05 +00:00
Jens L.
508dba6a04 ci: fix postgres path for postgres 18 tests (2026.2) (#21767) (#21789)
ci: fix postgres path for postgres 18 tests (#21767)

* ci: test migrations-from-stable failing



* fix postgres path



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-04-23 10:40:38 +02:00
authentik-automation[bot]
aa921dcdca providers/oauth2: don't auto-set redirect_uri (cherry-pick #21746 to version-2026.2) (#21750)
Cherry-pick #21746 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21746
Original commit: 189056e19a

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-21 18:20:15 +02:00
authentik-automation[bot]
e5d873c129 providers/oauth2: allow cross provider token introspection for federated providers (cherry-pick #21513 to version-2026.2) (#21748)
Cherry-pick #21513 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21513
Original commit: c84c8d86f8

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-21 17:20:01 +02:00
authentik-automation[bot]
f0a14d380f web/flows: prevent leader tab deadlock in continuous login flow (cherry-pick #21583 to version-2026.2) (#21627)
web/flows: prevent leader tab deadlock in continuous login flow (#21583)

* prevent leader tab deadlock in continuous login flow

* web: Continuous login tidy.

---------

Co-authored-by: Ryan Pesek <44002516+ryanpesek@users.noreply.github.com>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-04-16 13:22:30 +00:00
authentik-automation[bot]
1da15a549e website/docs: remove broken version tag from oauth doc (cherry-pick #21628 to version-2026.2) (#21629)
website/docs: remove broken version tag from oauth doc (#21628)

Remove broken tag

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-15 19:44:28 +00:00
authentik-automation[bot]
eaf1c45ea6 website/docs: add a single page about our user interface, document Consent stage (cherry-pick #20533 to version-2026.2) (#21619)
* Cherry-pick #20533 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #20533
Original commit: a6c5540369

* Update inspector.md

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* fix

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

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-15 10:08:42 +00:00
authentik-automation[bot]
f0f42668c4 blueprints: fix reconcile calling @property (cherry-pick #21576 to version-2026.2) (#21616)
blueprints: fix reconcile calling @property (#21576)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: João C. Fernandes <jfernandes@cloudflare.com>
2026-04-15 11:35:37 +02:00
authentik-automation[bot]
123fbd26bb providers/oauth2: fix time logic in refresh_token_threshold (cherry-pick #21537 to version-2026.2) (#21598)
* providers/oauth2: fix time logic in refresh_token_threshold (#21537)

* providers/oauth2: fix time logic in refresh_token_threshold

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

* format

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

---------

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

* fix flaky tests

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-15 11:07:17 +02:00
authentik-automation[bot]
b94d93b6c4 packages/django-dramatiq-postgres: reset db connections in raise_connection_error (cherry-pick #21577 to version-2026.2) (#21599)
Co-authored-by: João C. Fernandes <jfernandes@cloudflare.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-14 15:26:17 +02:00
authentik-automation[bot]
d0b25bf648 lib/sync/outgoing: avoid expensive query to get number of sync pages (cherry-pick #21575 to version-2026.2) (#21581)
lib/sync/outgoing: avoid expensive query to get number of sync pages (#21575)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: João C. Fernandes <jfernandes@cloudflare.com>
2026-04-14 00:51:31 +02:00
authentik-automation[bot]
d4db4e50b4 website/docs: add another sentence to First Steps about restricting access to apps (cherry-pick #21517 to version-2026.2) (#21542)
website/docs: add another sentence to First Steps about restricting access to apps (#21517)

* add another sentence about restricting access to apps

* tweaks

* Update website/docs/install-config/first-steps/index.mdx




* Lint fix

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-13 04:42:33 -05:00
authentik-automation[bot]
c5e726d7eb endpoints: fix tasks failing (cherry-pick #20904 to version-2026.2) (#21538)
endpoints: fix tasks failing (#20904)

* endpoints: fix tasks failing



* fix



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-10 16:15:55 +02:00
authentik-automation[bot]
203a7e0c61 core: bump django from v5.2.12 to 5.2.13 (cherry-pick #21520 to version-2026.2) (#21526)
Cherry-pick #21520 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21520
Original commit: 76a5e62405

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2026-04-10 14:56:12 +02:00
authentik-automation[bot]
2feaeff5db release: 2026.2.3-rc1 2026-04-10 12:03:32 +00:00
authentik-automation[bot]
8fcc47e047 ci: always run apt update (cherry-pick #21516 to version-2026.2) (#21519)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-09 17:52:46 +02:00
authentik-automation[bot]
7a6408cc67 website/docs: Password stage docs, explain four checkboxes (cherry-pick #21013 to version-2026.2) (#21276)
* Cherry-pick #21013 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21013
Original commit: cdbfde840e

* removed the cspell file from the PR

---------

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2026-04-09 08:31:45 -05:00
authentik-automation[bot]
2da88028da core: fix policy binding objects not being nullable (cherry-pick #21421 to version-2026.2) (#21481)
* Cherry-pick #21421 to version-2026.2 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #21421
Original commit: 2b8313ee91

* remove `packages` changes

* fix conflicts

---------

Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <gergo@goauthentik.io>
2026-04-08 18:05:18 +02:00
authentik-automation[bot]
fa91404895 ci: cache apt install (cherry-pick #21480 to version-2026.2) (#21485)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-04-08 17:56:34 +02:00
authentik-automation[bot]
460fce7279 web: Fix duplicate Turnstile widgets after extended idle (cherry-pick #21380 to version-2026.2) (#21473)
web: Fix duplicate Turnstile widgets after extended idle (#21380)

* Flesh out turnstile fixes.

* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-08 15:18:16 +02:00
authentik-automation[bot]
995128955c website/docs: fix typo (cherry-pick #21446 to version-2026.2) (#21447)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
fix typo (#21446)
2026-04-07 19:14:42 +00:00
authentik-automation[bot]
85536abbcf website/docs: add release notes for 2026.2.2 (cherry-pick #21442 to version-2026.2) (#21444)
website/docs: add release notes for `2026.2.2` (#21442)

* add release notes for `2026.2.2`

* remove further items

thank you @rissson




---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-07 18:16:04 +02:00
authentik-automation[bot]
5249546862 release: 2026.2.2 2026-04-07 14:47:38 +00:00
authentik-automation[bot]
bf91348c05 tasks: allow retry for rejected tasks only (cherry-pick #21433 to version-2026.2) (#21436)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-07 14:46:46 +02:00
authentik-automation[bot]
63136f0180 security: add item to intended behavior section of security policy (cherry-pick #21430 to version-2026.2) (#21432)
security: add item to intended behavior section of security policy (#21430)

Add section

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-07 13:50:40 +02:00
Marc 'risson' Schmitt
faffabf938 website/docs: fix merge conflict (#21435) 2026-04-07 13:42:58 +02:00
authentik-automation[bot]
0b180b15a2 website/docs: clarify file upload troubleshooting (cherry-pick #21361 to version-2026.2) (#21434)
Co-authored-by: Dominic R <dominic@sdko.org>
2026-04-07 13:41:41 +02:00
95 changed files with 1407 additions and 425 deletions

View File

@@ -17,14 +17,24 @@ inputs:
runs:
using: "composite"
steps:
- name: Install apt deps & cleanup
- name: Cleanup apt
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: sudo apt-get remove --purge man-db
- name: Install apt deps
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
with:
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
update: true
upgrade: false
install-recommends: false
- name: Make space on disk
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo apt-get remove --purge man-db
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
sudo rm -rf /usr/local/lib/android
sudo mkdir -p /tmp/empty/
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v5

View File

@@ -2,7 +2,7 @@ services:
postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16}
volumes:
- db-data:/var/lib/postgresql/data
- db-data:/var/lib/postgresql
command: "-c log_statement=all"
environment:
POSTGRES_USER: authentik

View File

@@ -95,7 +95,10 @@ jobs:
with:
postgresql_version: ${{ matrix.psql }}
- name: run migrations to stable
run: uv run python -m lifecycle.migrate
run: |
docker ps
docker logs setup-postgresql-1
uv run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x

View File

@@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version)
| Version | Supported |
| ---------- | ---------- |
| 2025.12.x | ✅ |
| 2026.2.x | ✅ |
| Version | Supported |
| --------- | --------- |
| 2025.12.x | ✅ |
| 2026.2.x | ✅ |
## Reporting a Vulnerability
@@ -90,6 +90,10 @@ Prompts intentionally allow raw HTML, including script tags, so they can be used
Redirects that only change navigation flow and do not expose session tokens, API keys, or other confidential data are considered acceptable and do not require reporting.
- Outgoing network requests are not filtered.
The destinations of outgoing network requests (HTTP, TCP, etc.) made by authentik to configurable endpoints through objects such as OAuth Sources, SSO Providers, and others are not validated. Depending on your threat model, these requests should be restricted at the network level using appropriate firewall or network policies.
## Disclosure process
1. Report from Github or Issue is reported via Email as listed above.

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2026.2.2-rc3"
VERSION = "2026.2.3-rc1"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -1,31 +1,73 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from urllib.parse import urlencode
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.api.v3.urls import router
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.tenants.api.domains import DomainViewSet
from authentik.tenants.api.tenants import TenantViewSet
from authentik.tenants.utils import get_current_tenant
class TestModelViewSets(TestCase):
"""Test Viewset"""
def setUp(self):
self.user = create_test_admin_user()
self.factory = RequestFactory()
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
"""Test Viewset"""
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
def test_attrs(self: TestModelViewSets) -> None:
"""Test attributes we require on all viewsets"""
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
return tester
def test_ordering(self: TestModelViewSets) -> None:
"""Test that all ordering fields are correct"""
view = test_viewset.as_view({"get": "list"})
for ordering_field in test_viewset.ordering:
with self.subTest(ordering_field):
req = self.factory.get(
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
def test_search(self: TestModelViewSets) -> None:
"""Test that search fields are correct"""
view = test_viewset.as_view({"get": "list"})
req = self.factory.get(
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
cases = {
"attrs": test_attrs,
}
if full:
cases["ordering"] = test_ordering
cases["search"] = test_search
return cases
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
continue
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
for test, case in viewset_tester_factory(viewset, full=full).items():
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)

View File

@@ -3,7 +3,6 @@
import traceback
from collections.abc import Callable
from importlib import import_module
from inspect import ismethod
from django.apps import AppConfig
from django.conf import settings
@@ -72,12 +71,19 @@ class ManagedAppConfig(AppConfig):
def _reconcile(self, prefix: str) -> None:
for meth_name in dir(self):
meth = getattr(self, meth_name)
if not ismethod(meth):
# Check the attribute on the class to avoid evaluating @property descriptors.
# Using getattr(self, ...) on a @property would evaluate it, which can trigger
# expensive side effects (e.g. tenant_schedule_specs iterating all providers
# and running PolicyEngine queries for every user).
class_attr = getattr(type(self), meth_name, None)
if class_attr is None or isinstance(class_attr, property):
continue
category = getattr(meth, "_authentik_managed_reconcile", None)
if not callable(class_attr):
continue
category = getattr(class_attr, "_authentik_managed_reconcile", None)
if category != prefix:
continue
meth = getattr(self, meth_name)
name = meth_name.replace(prefix, "")
try:
self.logger.debug("Starting reconciler", name=name)

View File

@@ -47,7 +47,8 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
search_fields = [
"pbm_uuid",
"name",
"app",
"app__name",
"app__slug",
"attributes",
]
filterset_fields = [

View File

@@ -32,19 +32,19 @@ from authentik.rbac.decorators import permission_required
class UserAgentDeviceDict(TypedDict):
"""User agent device"""
brand: str
brand: str | None = None
family: str
model: str
model: str | None = None
class UserAgentOSDict(TypedDict):
"""User agent os"""
family: str
major: str
minor: str
patch: str
patch_minor: str
major: str | None = None
minor: str | None = None
patch: str | None = None
patch_minor: str | None = None
class UserAgentBrowserDict(TypedDict):

View File

@@ -44,3 +44,6 @@ class BaseController[T: "Connector"]:
def stage_view_authentication(self) -> StageView | None:
return None
def sync_endpoints(self):
raise NotImplementedError

View File

@@ -162,8 +162,11 @@ class Connector(ScheduledModel, SerializerModel):
@property
def schedule_specs(self) -> list[ScheduleSpec]:
from authentik.endpoints.controller import Capabilities
from authentik.endpoints.tasks import endpoints_sync
if Capabilities.ENROLL_AUTOMATIC_API not in self.controller(self).capabilities():
return []
return [
ScheduleSpec(
actor=endpoints_sync,

View File

@@ -21,7 +21,7 @@ def endpoints_sync(connector_pk: Any):
return
controller = connector.controller
ctrl = controller(connector)
if Capabilities.AUTOMATIC_API not in ctrl.capabilities():
if Capabilities.ENROLL_AUTOMATIC_API not in ctrl.capabilities():
return
LOGGER.info("Syncing connector", connector=connector.name)
ctrl.sync_endpoints()

View File

@@ -0,0 +1,35 @@
from unittest.mock import PropertyMock, patch
from rest_framework.test import APITestCase
from authentik.endpoints.controller import BaseController, Capabilities
from authentik.endpoints.models import Connector
from authentik.endpoints.tasks import endpoints_sync
from authentik.lib.generators import generate_id
class TestEndpointTasks(APITestCase):
def test_agent_sync(self):
class controller(BaseController):
def capabilities(self):
return [Capabilities.ENROLL_AUTOMATIC_API]
def sync_endpoints(self):
pass
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
connector = Connector.objects.create(name=generate_id())
self.assertEqual(len(connector.schedule_specs), 1)
endpoints_sync.send(connector.pk).get_result(block=True)
def test_agent_no_sync(self):
class controller(BaseController):
def capabilities(self):
return []
with patch.object(Connector, "controller", PropertyMock(return_value=controller)):
connector = Connector.objects.create(name=generate_id())
self.assertEqual(len(connector.schedule_specs), 0)
endpoints_sync.send(connector.pk).get_result(block=True)

View File

@@ -1,3 +1,4 @@
import math
from typing import Any, Self
import pglock
@@ -68,7 +69,12 @@ class OutgoingSyncProvider(ScheduledModel, Model):
return Paginator(self.get_object_qs(type), self.sync_page_size)
def get_object_sync_time_limit_ms[T: User | Group](self, type: type[T]) -> int:
num_pages: int = self.get_paginator(type).num_pages
# Use a simple COUNT(*) on the model instead of materializing get_object_qs(),
# which for some providers (e.g. SCIM) runs PolicyEngine per-user and is
# extremely expensive. The time limit is an upper-bound estimate, so using
# the total count (without policy filtering) is a safe overestimate.
total_count = type.objects.count()
num_pages = math.ceil(total_count / self.sync_page_size) if total_count > 0 else 1
page_timeout_ms = timedelta_from_string(self.sync_page_timeout).total_seconds() * 1000
return int(num_pages * page_timeout_ms * 1.5)

View File

@@ -57,9 +57,11 @@ class PolicyBindingSerializer(ModelSerializer):
required=True,
)
policy_obj = PolicySerializer(required=False, read_only=True, source="policy")
group_obj = PartialGroupSerializer(required=False, read_only=True, source="group")
user_obj = PartialUserSerializer(required=False, read_only=True, source="user")
policy_obj = PolicySerializer(required=False, allow_null=True, read_only=True, source="policy")
group_obj = PartialGroupSerializer(
required=False, allow_null=True, read_only=True, source="group"
)
user_obj = PartialUserSerializer(required=False, allow_null=True, read_only=True, source="user")
class Meta:
model = PolicyBinding

View File

@@ -141,26 +141,6 @@ class TestAuthorize(OAuthTestCase):
OAuthAuthorizationParams.from_request(request)
self.assertEqual(cm.exception.cause, "redirect_uri_forbidden_scheme")
def test_invalid_redirect_uri_empty(self):
"""test missing/invalid redirect URI"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[],
)
request = self.factory.get(
"/",
data={
"response_type": "code",
"client_id": "test",
"redirect_uri": "+",
},
)
OAuthAuthorizationParams.from_request(request)
provider.refresh_from_db()
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
def test_invalid_redirect_uri_regex(self):
"""test missing/invalid redirect URI"""
OAuth2Provider.objects.create(
@@ -394,7 +374,7 @@ class TestAuthorize(OAuthTestCase):
"nonce": generate_id(),
},
)
token: AccessToken = AccessToken.objects.filter(user=user).first()
token = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds()
self.assertEqual(
response.url,
@@ -466,7 +446,7 @@ class TestAuthorize(OAuthTestCase):
},
)
self.assertEqual(response.status_code, 302)
token: AccessToken = AccessToken.objects.filter(user=user).first()
token = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds()
jwt = self.validate_jwe(token, provider)
self.assertEqual(jwt["amr"], ["pwd"])
@@ -565,7 +545,7 @@ class TestAuthorize(OAuthTestCase):
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
token: AccessToken = AccessToken.objects.filter(user=user).first()
token = AccessToken.objects.filter(user=user).first()
self.assertIsNotNone(token)
self.assertJSONEqual(
response.content.decode(),

View File

@@ -4,22 +4,19 @@ from unittest.mock import Mock, patch
import jwt
from django.test import RequestFactory
from django.utils import timezone
from dramatiq.results.errors import ResultFailure
from requests import Response
from requests.exceptions import HTTPError, Timeout
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2LogoutMethod,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.oauth2.tasks import send_backchannel_logout_request
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@@ -45,52 +42,6 @@ class TestBackChannelLogout(OAuthTestCase):
self.app.provider = self.provider
self.app.save()
def _create_session(self, session_key=None):
"""Create a session with the given key or a generated one"""
session_key = session_key or f"session-{generate_id()}"
session = Session.objects.create(
session_key=session_key,
expires=timezone.now() + timezone.timedelta(hours=1),
last_ip="255.255.255.255",
)
auth_session = AuthenticatedSession.objects.create(
session=session,
user=self.user,
)
return auth_session
def _create_token(
self, provider, user, session=None, token_type="access", token_id=None
): # nosec
"""Create a token of the specified type"""
token_id = token_id or f"{token_type}-token-{generate_id()}"
kwargs = {
"provider": provider,
"user": user,
"session": session,
"token": token_id,
"_id_token": "{}",
"auth_time": timezone.now(),
}
if token_type == "access": # nosec
return AccessToken.objects.create(**kwargs)
else: # refresh
return RefreshToken.objects.create(**kwargs)
def _create_provider(self, name=None):
"""Create an OAuth2 provider"""
name = name or f"provider-{generate_id()}"
provider = OAuth2Provider.objects.create(
name=name,
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, f"http://{name}/callback"),
],
signing_key=self.keypair,
)
return provider
def _create_logout_token(
self,
provider: OAuth2Provider | None = None,

View File

@@ -6,10 +6,11 @@ from urllib.parse import quote
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@@ -110,3 +111,57 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes_extra(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email foo"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)

View File

@@ -14,6 +14,7 @@ from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
@@ -43,7 +44,7 @@ class TesOAuth2Introspection(OAuthTestCase):
def test_introspect_refresh(self):
"""Test introspect"""
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -75,7 +76,7 @@ class TesOAuth2Introspection(OAuthTestCase):
def test_introspect_access(self):
"""Test introspect"""
token: AccessToken = AccessToken.objects.create(
token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -130,7 +131,7 @@ class TesOAuth2Introspection(OAuthTestCase):
)
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
token: AccessToken = AccessToken.objects.create(
token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -169,3 +170,76 @@ class TesOAuth2Introspection(OAuthTestCase):
"active": False,
},
)
def test_introspect_provider_public(self):
"""Test introspect"""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token-introspection"),
HTTP_AUTHORIZATION=f"Basic {self.auth}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"active": False,
},
)
def test_introspect_provider_fed(self):
"""Test introspect with federation. self.provider is a confidential
client and other_provider is a public client."""
other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
other_provider.jwt_federation_providers.add(self.provider)
token = AccessToken.objects.create(
provider=other_provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token-introspection"),
HTTP_AUTHORIZATION=f"Basic {self.auth}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"acr": ACR_AUTHENTIK_DEFAULT,
"sub": "bar",
"iss": "foo",
"active": True,
"client_id": other_provider.client_id,
"scope": " ".join(token.scope),
},
)

View File

@@ -46,7 +46,7 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_refresh(self):
"""Test revoke"""
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -69,7 +69,7 @@ class TesOAuth2Revoke(OAuthTestCase):
def test_revoke_access(self):
"""Test revoke"""
token: AccessToken = AccessToken.objects.create(
token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -105,7 +105,19 @@ class TesOAuth2Revoke(OAuthTestCase):
"""Test revoke (invalid auth)"""
res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION="Basic fqewr",
HTTP_AUTHORIZATION="Basic aaa",
data={
"token": generate_id(),
},
)
self.assertEqual(res.status_code, 401)
def test_revoke_invalid_auth_secret(self):
"""Test revoke (invalid secret)"""
invalid_auth = b64encode(f"{self.provider.client_id}:aaa".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {invalid_auth}",
data={
"token": generate_id(),
},
@@ -116,7 +128,7 @@ class TesOAuth2Revoke(OAuthTestCase):
"""Test revoke public client"""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
token: AccessToken = AccessToken.objects.create(
token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
@@ -220,3 +232,74 @@ class TesOAuth2Revoke(OAuthTestCase):
self.assertEqual(AccessToken.objects.all().count(), 0)
self.assertEqual(RefreshToken.objects.all().count(), 0)
self.assertEqual(DeviceToken.objects.all().count(), 0)
def test_revoke_provider_fed(self):
"""Test revoke with federation. self.provider is a confidential
client and other_provider is a public client."""
other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
other_provider.jwt_federation_providers.add(self.provider)
token = AccessToken.objects.create(
provider=other_provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {self.auth}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(res.content.decode(), {})
def test_revoke_provider_fed_public(self):
"""Test revoke with federation. self.provider is a public
client and other_provider is a public client."""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
signing_key=create_test_cert(),
client_type=ClientTypes.PUBLIC,
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
other_provider.jwt_federation_providers.add(self.provider)
token = AccessToken.objects.create(
provider=other_provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
auth_public = b64encode(f"{self.provider.client_id}:{generate_id()}".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {auth_public}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(AccessToken.objects.filter(token=token.token).exists())

View File

@@ -1,12 +1,15 @@
"""Test token view"""
from base64 import b64encode
from datetime import timedelta
from json import dumps
from urllib.parse import quote
from django.test import RequestFactory
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import now
from freezegun import freeze_time
from authentik.blueprints.tests import apply_blueprint
from authentik.common.oauth.constants import (
@@ -99,7 +102,7 @@ class TestToken(OAuthTestCase):
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=provider,
user=user,
token=generate_id(),
@@ -156,7 +159,7 @@ class TestToken(OAuthTestCase):
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
self.assertJSONEqual(
response.content.decode(),
{
@@ -198,7 +201,7 @@ class TestToken(OAuthTestCase):
HTTP_AUTHORIZATION=f"Basic {header}",
)
self.assertEqual(response.status_code, 200)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
self.validate_jwe(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
@@ -225,7 +228,7 @@ class TestToken(OAuthTestCase):
self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=provider,
user=user,
token=generate_id(),
@@ -245,10 +248,8 @@ class TestToken(OAuthTestCase):
)
self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid")
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
refresh: RefreshToken = RefreshToken.objects.filter(
user=user, provider=provider, revoked=False
).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
refresh = RefreshToken.objects.filter(user=user, provider=provider, revoked=False).first()
self.assertJSONEqual(
response.content.decode(),
{
@@ -285,7 +286,7 @@ class TestToken(OAuthTestCase):
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=provider,
user=user,
token=generate_id(),
@@ -303,10 +304,8 @@ class TestToken(OAuthTestCase):
HTTP_AUTHORIZATION=f"Basic {header}",
HTTP_ORIGIN="http://another.invalid",
)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
refresh: RefreshToken = RefreshToken.objects.filter(
user=user, provider=provider, revoked=False
).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
refresh = RefreshToken.objects.filter(user=user, provider=provider, revoked=False).first()
self.assertNotIn("Access-Control-Allow-Credentials", response)
self.assertNotIn("Access-Control-Allow-Origin", response)
self.assertJSONEqual(
@@ -347,7 +346,7 @@ class TestToken(OAuthTestCase):
self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=provider,
user=user,
token=generate_id(),
@@ -365,9 +364,7 @@ class TestToken(OAuthTestCase):
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
new_token: RefreshToken = (
RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first()
)
new_token = RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first()
# Post again with initial token -> get new refresh token
# and revoke old one
response = self.client.post(
@@ -395,7 +392,11 @@ class TestToken(OAuthTestCase):
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view_threshold(self):
"""test request param"""
"""refresh token threshold
threshold set to 1 hour, refresh token expires in 2 hours.
First request should not return a new refresh token, second request
has a fake time 1 hours in the future which should return a new access token"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
@@ -418,13 +419,14 @@ class TestToken(OAuthTestCase):
self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create(
token = RefreshToken.objects.create(
provider=provider,
user=user,
token=generate_id(),
_id_token=dumps({}),
auth_time=timezone.now(),
_scope="offline_access",
expires=now() + timedelta(hours=2),
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
@@ -436,9 +438,7 @@ class TestToken(OAuthTestCase):
HTTP_AUTHORIZATION=f"Basic {header}",
HTTP_ORIGIN="http://local.invalid",
)
self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid")
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
self.assertJSONEqual(
response.content.decode(),
{
@@ -453,6 +453,42 @@ class TestToken(OAuthTestCase):
)
self.validate_jwt(access, provider)
with freeze_time(now() + timedelta(hours=1, minutes=10)):
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": token.token,
"redirect_uri": "http://local.invalid",
},
HTTP_AUTHORIZATION=f"Basic {header}",
HTTP_ORIGIN="http://local.invalid",
)
access = (
AccessToken.objects.filter(user=user, provider=provider)
.exclude(pk=access.pk)
.first()
)
refresh = (
RefreshToken.objects.filter(user=user, provider=provider)
.exclude(pk=token.pk)
.first()
)
self.assertJSONEqual(
response.content.decode(),
{
"access_token": access.token,
"token_type": TOKEN_TYPE,
"expires_in": 3600,
"id_token": provider.encode(
access.id_token.to_dict(),
),
"scope": "offline_access",
"refresh_token": refresh.token,
},
)
self.validate_jwt(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_scope_claim_override_via_property_mapping(self):
"""Test that property mappings can override the scope claim in access tokens.
@@ -500,7 +536,7 @@ class TestToken(OAuthTestCase):
)
self.assertEqual(response.status_code, 200)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
access = AccessToken.objects.filter(user=user, provider=provider).first()
jwt_data = self.validate_jwt(access, provider)
# The scope should be the custom value from the property mapping,

View File

@@ -40,7 +40,7 @@ class TestUserinfo(OAuthTestCase):
self.app.provider = self.provider
self.app.save()
self.user = create_test_admin_user()
self.token: AccessToken = AccessToken.objects.create(
self.token = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),

View File

@@ -58,7 +58,6 @@ from authentik.providers.oauth2.models import (
AuthorizationCode,
GrantTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ResponseMode,
ResponseTypes,
@@ -196,14 +195,6 @@ class OAuthAuthorizationParams:
LOGGER.warning("Missing redirect uri.")
raise RedirectUriError("", allowed_redirect_urls).with_cause("redirect_uri_missing")
if len(allowed_redirect_urls) < 1:
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
self.provider.redirect_uris = [
RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri)
]
self.provider.save()
allowed_redirect_urls = self.provider.redirect_uris
match_found = False
for allowed in allowed_redirect_urls:
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:

View File

@@ -15,7 +15,7 @@ from authentik.core.models import Application
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.errors import DeviceCodeError
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -28,7 +28,7 @@ class DeviceView(View):
client_id: str
provider: OAuth2Provider
scopes: list[str] = []
scopes: set[str] = []
def parse_request(self):
"""Parse incoming request"""
@@ -44,7 +44,21 @@ class DeviceView(View):
raise DeviceCodeError("invalid_client") from None
self.provider = provider
self.client_id = client_id
self.scopes = self.request.POST.get("scope", "").split(" ")
scopes_to_check = set(self.request.POST.get("scope", "").split())
default_scope_names = set(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
"scope_name", flat=True
)
)
self.scopes = scopes_to_check
if not scopes_to_check.issubset(default_scope_names):
LOGGER.info(
"Application requested scopes not configured, setting to overlap",
scope_allowed=default_scope_names,
scope_given=self.scopes,
)
self.scopes = self.scopes.intersection(default_scope_names)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
throttle = AnonRateThrottle()

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass, field
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
@@ -10,7 +11,7 @@ from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenIntrospectionError
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger()
@@ -33,10 +34,7 @@ class TokenIntrospectionParams:
self.id_token = self.token.id_token
if not self.token.id_token:
LOGGER.debug(
"token not an authentication token",
token=self.token,
)
LOGGER.debug("token not an authentication token", token=self.token)
raise TokenIntrospectionError()
@staticmethod
@@ -45,14 +43,23 @@ class TokenIntrospectionParams:
raw_token = request.POST.get("token")
provider = authenticate_provider(request)
if not provider:
LOGGER.info("Failed to authenticate introspection request")
raise TokenIntrospectionError
if provider.client_type != ClientTypes.CONFIDENTIAL:
LOGGER.info("Introspection request from public provider, denying.")
raise TokenIntrospectionError
access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first()
query = Q(
Q(provider=provider) | Q(provider__jwt_federation_providers__in=[provider]),
token=raw_token,
)
access_token = AccessToken.objects.filter(query).first()
if access_token:
return TokenIntrospectionParams(access_token, provider)
refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first()
return TokenIntrospectionParams(access_token, access_token.provider)
refresh_token = RefreshToken.objects.filter(query).first()
if refresh_token:
return TokenIntrospectionParams(refresh_token, provider)
return TokenIntrospectionParams(refresh_token, refresh_token.provider)
LOGGER.debug("Token does not exist", token=raw_token)
raise TokenIntrospectionError()

View File

@@ -704,7 +704,7 @@ class TokenView(View):
refresh_token_threshold = timedelta_from_string(self.provider.refresh_token_threshold)
if (
refresh_token_threshold.total_seconds() == 0
or (now - self.params.refresh_token.expires) > refresh_token_threshold
or (self.params.refresh_token.expires - now) < refresh_token_threshold
):
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
refresh_token = RefreshToken(

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from django.db.models import Q
from django.http import Http404, HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
@@ -32,15 +33,25 @@ class TokenRevocationParams:
raw_token = request.POST.get("token")
provider, _, _ = provider_from_request(request)
if provider and provider.client_type == ClientTypes.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")
# By default clients can only revoke their own tokens
query = Q(provider=provider, token=raw_token)
if provider.client_type == ClientTypes.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider:
raise TokenRevocationError("invalid_client")
# If the request is authenticated by a confidential provider, it can also
# revoke federated tokens
query = Q(
Q(provider=provider) | Q(provider__jwt_federation_providers__in=[provider]),
token=raw_token,
)
access_token = AccessToken.objects.filter(token=raw_token).first()
access_token = AccessToken.objects.filter(query).first()
if access_token:
return TokenRevocationParams(access_token, provider)
refresh_token = RefreshToken.objects.filter(token=raw_token).first()
refresh_token = RefreshToken.objects.filter(query).first()
if refresh_token:
return TokenRevocationParams(refresh_token, provider)
LOGGER.debug("Token does not exist", token=raw_token)

View File

@@ -231,8 +231,8 @@ class SAMLProviderSerializer(ProviderSerializer):
class SAMLMetadataSerializer(PassiveSerializer):
"""SAML Provider Metadata serializer"""
metadata = CharField(read_only=True)
download_url = CharField(read_only=True, required=False, allow_null=True)
metadata = CharField()
download_url = CharField(required=False, allow_null=True)
class SAMLProviderImportSerializer(PassiveSerializer):
@@ -314,7 +314,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
return response
return Response({"metadata": metadata}, content_type="application/json")
except Provider.application.RelatedObjectDoesNotExist:
return Response({"metadata": ""}, content_type="application/json")
raise Http404 from None
@permission_required(
None,

View File

@@ -157,7 +157,7 @@ class TestSAMLProviderAPI(APITestCase):
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
)
self.assertEqual(200, response.status_code)
self.assertEqual(404, response.status_code)
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}),
)

View File

@@ -6,6 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0056_user_roles"), # must run before group field is removed
("authentik_rbac", "0009_remove_initialpermissions_mode"),
]

View File

@@ -157,7 +157,7 @@ class TaskViewSet(
def retry(self, request: Request, pk=None) -> Response:
"""Retry task"""
task: Task = self.get_object()
if task.state not in (TaskState.REJECTED, TaskState.DONE):
if task.state != TaskState.REJECTED:
return Response(status=400)
broker = get_broker()
broker.enqueue(Message.decode(task.message))

View File

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

View File

@@ -1 +1 @@
2026.2.2-rc3
2026.2.3-rc1

View File

@@ -1,7 +1,6 @@
package radius
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"errors"
@@ -40,13 +39,18 @@ func (r *RadiusRequest) ID() string {
func (r *RadiusRequest) validateMessageAuthenticator() error {
mauth := rfc2869.MessageAuthenticator_Get(r.Packet)
// Per RFC 2869 §5.14, the Message-Authenticator field must be treated as
// 16 zero bytes when computing the HMAC-MD5 for verification.
_ = rfc2869.MessageAuthenticator_Set(r.Packet, make([]byte, 16))
hash := hmac.New(md5.New, r.Secret)
encode, err := r.MarshalBinary()
// Restore the original value regardless of whether marshaling succeeded.
_ = rfc2869.MessageAuthenticator_Set(r.Packet, mauth)
if err != nil {
return err
}
hash.Write(encode)
if bytes.Equal(mauth, hash.Sum(nil)) {
if !hmac.Equal(mauth, hash.Sum(nil)) {
return ErrInvalidMessageAuthenticator
}
return nil
@@ -54,7 +58,7 @@ func (r *RadiusRequest) validateMessageAuthenticator() error {
func (r *RadiusRequest) setMessageAuthenticator(rp *radius.Packet) error {
_ = rfc2869.MessageAuthenticator_Set(rp, make([]byte, 16))
hash := hmac.New(md5.New, rp.Secret)
hash := hmac.New(md5.New, r.pi.SharedSecret)
encode, err := rp.MarshalBinary()
if err != nil {
return err

View File

@@ -0,0 +1,63 @@
package radius
import (
"testing"
"github.com/stretchr/testify/assert"
"layeh.com/radius"
)
var (
radiusPacketAccReq = []byte{0x1, 0x8f, 0x0, 0x4d, 0x4a, 0xd5, 0x47, 0x98, 0xbf, 0x18, 0xe, 0x4b, 0x6a, 0xdd, 0x0, 0xc7, 0x99, 0xb4, 0xa6, 0x57, 0x50, 0x12, 0xa5, 0xf7, 0x16, 0x88, 0xc5, 0xd8, 0xd9, 0xec, 0x19, 0xc8, 0x51, 0x47, 0x9, 0x5f, 0xe5, 0x60, 0x1, 0x9, 0x61, 0x6b, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x2, 0x12, 0x37, 0x36, 0x8, 0xa3, 0x72, 0x20, 0xf, 0xf4, 0xc0, 0xc, 0xd2, 0x40, 0xc1, 0xc3, 0x3f, 0xef, 0x4, 0x6, 0xa, 0x78, 0x14, 0x4c, 0x5, 0x6, 0x0, 0x0, 0x0, 0xa}
)
func Test_Request_validateMessageAuthenticator_valid(t *testing.T) {
p, err := radius.Parse(radiusPacketAccReq, []byte("foo"))
assert.NoError(t, err)
req := RadiusRequest{
Request: &radius.Request{
Packet: p,
},
pi: &ProviderInstance{
SharedSecret: []byte("foo"),
},
}
assert.NoError(t, req.validateMessageAuthenticator())
}
func Test_Request_validateMessageAuthenticator_invalid(t *testing.T) {
p, err := radius.Parse(radiusPacketAccReq, []byte("bar"))
assert.NoError(t, err)
req := RadiusRequest{
Request: &radius.Request{
Packet: p,
},
pi: &ProviderInstance{
SharedSecret: []byte("bar"),
},
}
assert.Error(t, req.validateMessageAuthenticator(), ErrInvalidMessageAuthenticator)
}
func Test_Request_setMessageAuthenticator(t *testing.T) {
p, err := radius.Parse(radiusPacketAccReq, []byte("foo"))
assert.NoError(t, err)
req := RadiusRequest{
Request: &radius.Request{
Packet: p,
},
pi: &ProviderInstance{
SharedSecret: []byte("foo"),
},
}
res := p.Response(radius.CodeAccessAccept)
assert.NoError(t, req.setMessageAuthenticator(res))
nr := RadiusRequest{
Request: &radius.Request{
Packet: res,
},
pi: req.pi,
}
assert.NoError(t, nr.validateMessageAuthenticator())
}

View File

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

View File

@@ -31,7 +31,7 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
@@ -53,7 +53,7 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
restart: unless-stopped
shm_size: 512mb
user: root

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2026.2.2-rc3",
"version": "2026.2.3-rc1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2026.2.2-rc3",
"version": "2026.2.3-rc1",
"dependencies": {
"@eslint/js": "^9.39.1",
"@goauthentik/eslint-config": "./packages/eslint-config",

View File

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

View File

@@ -0,0 +1,92 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* User agent device
* @export
* @interface AuthenticatedSessionUserAgentDevice
*/
export interface AuthenticatedSessionUserAgentDevice {
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentDevice
*/
brand: string | null;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentDevice
*/
family: string;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentDevice
*/
model: string | null;
}
/**
* Check if a given object implements the AuthenticatedSessionUserAgentDevice interface.
*/
export function instanceOfAuthenticatedSessionUserAgentDevice(
value: object,
): value is AuthenticatedSessionUserAgentDevice {
if (!("brand" in value) || value["brand"] === undefined) return false;
if (!("family" in value) || value["family"] === undefined) return false;
if (!("model" in value) || value["model"] === undefined) return false;
return true;
}
export function AuthenticatedSessionUserAgentDeviceFromJSON(
json: any,
): AuthenticatedSessionUserAgentDevice {
return AuthenticatedSessionUserAgentDeviceFromJSONTyped(json, false);
}
export function AuthenticatedSessionUserAgentDeviceFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): AuthenticatedSessionUserAgentDevice {
if (json == null) {
return json;
}
return {
brand: json["brand"],
family: json["family"],
model: json["model"],
};
}
export function AuthenticatedSessionUserAgentDeviceToJSON(
json: any,
): AuthenticatedSessionUserAgentDevice {
return AuthenticatedSessionUserAgentDeviceToJSONTyped(json, false);
}
export function AuthenticatedSessionUserAgentDeviceToJSONTyped(
value?: AuthenticatedSessionUserAgentDevice | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
brand: value["brand"],
family: value["family"],
model: value["model"],
};
}

View File

@@ -0,0 +1,108 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* User agent os
* @export
* @interface AuthenticatedSessionUserAgentOs
*/
export interface AuthenticatedSessionUserAgentOs {
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentOs
*/
family: string;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentOs
*/
major: string | null;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentOs
*/
minor: string | null;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentOs
*/
patch: string | null;
/**
*
* @type {string}
* @memberof AuthenticatedSessionUserAgentOs
*/
patchMinor: string | null;
}
/**
* Check if a given object implements the AuthenticatedSessionUserAgentOs interface.
*/
export function instanceOfAuthenticatedSessionUserAgentOs(
value: object,
): value is AuthenticatedSessionUserAgentOs {
if (!("family" in value) || value["family"] === undefined) return false;
if (!("major" in value) || value["major"] === undefined) return false;
if (!("minor" in value) || value["minor"] === undefined) return false;
if (!("patch" in value) || value["patch"] === undefined) return false;
if (!("patchMinor" in value) || value["patchMinor"] === undefined) return false;
return true;
}
export function AuthenticatedSessionUserAgentOsFromJSON(
json: any,
): AuthenticatedSessionUserAgentOs {
return AuthenticatedSessionUserAgentOsFromJSONTyped(json, false);
}
export function AuthenticatedSessionUserAgentOsFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): AuthenticatedSessionUserAgentOs {
if (json == null) {
return json;
}
return {
family: json["family"],
major: json["major"],
minor: json["minor"],
patch: json["patch"],
patchMinor: json["patch_minor"],
};
}
export function AuthenticatedSessionUserAgentOsToJSON(json: any): AuthenticatedSessionUserAgentOs {
return AuthenticatedSessionUserAgentOsToJSONTyped(json, false);
}
export function AuthenticatedSessionUserAgentOsToJSONTyped(
value?: AuthenticatedSessionUserAgentOs | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
family: value["family"],
major: value["major"],
minor: value["minor"],
patch: value["patch"],
patch_minor: value["patchMinor"],
};
}

View File

@@ -62,6 +62,10 @@ def raise_connection_error(func: Callable[P, R]) -> Callable[P, R]: # noqa: UP0
return func(*args, **kwargs)
except DATABASE_ERRORS as exc:
logger.warning("Database error encountered", exc=exc)
try:
connections.close_all()
except DATABASE_ERRORS:
pass
raise ConnectionError(str(exc)) from exc # type: ignore[no-untyped-call]
return wrapper

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2026.2.2-rc3"
version = "2026.2.3-rc1"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.14.*"
@@ -26,7 +26,7 @@ dependencies = [
"django-prometheus==2.4.1",
"django-storages[s3]==1.14.6",
"django-tenants==3.10.0",
"django==5.2.12",
"django==5.2.13",
"djangoql==0.19.1",
"djangorestframework==3.16.1",
"docker==7.1.0",

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2026.2.2-rc3
version: 2026.2.3-rc1
description: Making authentication simple.
contact:
email: hello@goauthentik.io
@@ -34273,10 +34273,12 @@ components:
properties:
brand:
type: string
nullable: true
family:
type: string
model:
type: string
nullable: true
required:
- brand
- family
@@ -34289,12 +34291,16 @@ components:
type: string
major:
type: string
nullable: true
minor:
type: string
nullable: true
patch:
type: string
nullable: true
patch_minor:
type: string
nullable: true
required:
- family
- major
@@ -37270,14 +37276,17 @@ components:
allOf:
- $ref: '#/components/schemas/Policy'
readOnly: true
nullable: true
group_obj:
allOf:
- $ref: '#/components/schemas/PartialGroup'
readOnly: true
nullable: true
user_obj:
allOf:
- $ref: '#/components/schemas/PartialUser'
readOnly: true
nullable: true
target:
type: string
format: uuid
@@ -51312,14 +51321,17 @@ components:
allOf:
- $ref: '#/components/schemas/Policy'
readOnly: true
nullable: true
group_obj:
allOf:
- $ref: '#/components/schemas/PartialGroup'
readOnly: true
nullable: true
user_obj:
allOf:
- $ref: '#/components/schemas/PartialUser'
readOnly: true
nullable: true
target:
type: string
format: uuid
@@ -53172,13 +53184,10 @@ components:
properties:
metadata:
type: string
readOnly: true
download_url:
type: string
readOnly: true
nullable: true
required:
- download_url
- metadata
SAMLNameIDPolicyEnum:
enum:

View File

@@ -72,6 +72,7 @@ class TestProviderRadius(SeleniumTestCase):
code=AccessRequest, User_Name=self.user.username, NAS_Identifier="localhost"
)
req["User-Password"] = req.PwCrypt(self.user.username)
req.add_message_authenticator()
reply = srv.SendPacket(req)
self.assertEqual(reply.code, AccessAccept)
@@ -94,6 +95,7 @@ class TestProviderRadius(SeleniumTestCase):
code=AccessRequest, User_Name=self.user.username, NAS_Identifier="localhost"
)
req["User-Password"] = req.PwCrypt(self.user.username + "foo")
req.add_message_authenticator()
reply = srv.SendPacket(req)
self.assertEqual(reply.code, AccessReject)

View File

@@ -58,8 +58,10 @@ def get_local_ip(override=True) -> str:
if (local_ip := getenv("LOCAL_IP")) and override:
return local_ip
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr
try:
return socket.gethostbyname(hostname)
except socket.gaierror:
return "0.0.0.0"
class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):

10
uv.lock generated
View File

@@ -221,7 +221,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2026.2.2rc3"
version = "2026.2.3rc1"
source = { editable = "." }
dependencies = [
{ name = "ak-guardian" },
@@ -342,7 +342,7 @@ requires-dist = [
{ name = "dacite", specifier = "==1.9.2" },
{ name = "deepmerge", specifier = "==2.0" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "django", specifier = "==5.2.12" },
{ name = "django", specifier = "==5.2.13" },
{ name = "django-channels-postgres", editable = "packages/django-channels-postgres" },
{ name = "django-countries", specifier = "==7.6.1" },
{ name = "django-cte", specifier = "==3.0.0" },
@@ -1090,16 +1090,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.12"
version = "5.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
]
[[package]]

18
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2026.2.2-rc3",
"version": "2026.2.3-rc1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2026.2.2-rc3",
"version": "2026.2.3-rc1",
"license": "MIT",
"workspaces": [
"./packages/*"
@@ -5342,6 +5342,12 @@
"integrity": "sha512-u2G8ZQ9IhMWTMXaWqZycnK4UthG1fA238CD+DP4Dm4WJi5hdUKKLg0RMRaRpDPNMdkTwIDkp7WtD0Rd9BH9fLw==",
"license": "MIT"
},
"node_modules/@webcomponents/template": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@webcomponents/template/-/template-1.5.1.tgz",
"integrity": "sha512-3e8bx+bgRhyuRwFrDGu7CalILomo11ixuMzVGvpXSxL8lX+ijCIG6J3kS4O/7nElVuKE7vkuXB1xD+RICDNCCg==",
"license": "BSD-3-Clause"
},
"node_modules/@webcomponents/webcomponentsjs": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
@@ -17439,11 +17445,13 @@
"@rollup/plugin-swc": "^0.4.0",
"@swc/cli": "^0.8.0",
"@swc/core": "^1.15.11",
"@webcomponents/template": "^1.5.1",
"base64-js": "^1.5.1",
"core-js": "^3.48.0",
"formdata-polyfill": "^2025.11.0",
"globby": "16.1.0",
"jquery": "^3.7.1",
"lit-html": "^1.4.1",
"rollup": "^4.57.1",
"weakmap-polyfill": "^2.0.4"
},
@@ -17459,6 +17467,12 @@
"@swc/core-win32-ia32-msvc": "^1.15.3",
"@swc/core-win32-x64-msvc": "^1.15.3"
}
},
"packages/sfe/node_modules/lit-html": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
"integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==",
"license": "BSD-3-Clause"
}
}
}

View File

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

View File

@@ -21,11 +21,13 @@
"@rollup/plugin-swc": "^0.4.0",
"@swc/cli": "^0.8.0",
"@swc/core": "^1.15.11",
"@webcomponents/template": "^1.5.1",
"base64-js": "^1.5.1",
"core-js": "^3.48.0",
"formdata-polyfill": "^2025.11.0",
"globby": "16.1.0",
"jquery": "^3.7.1",
"lit-html": "^1.4.1",
"rollup": "^4.57.1",
"weakmap-polyfill": "^2.0.4"
},

View File

@@ -1,6 +1,7 @@
import "formdata-polyfill";
import "weakmap-polyfill";
import "core-js/actual/object/assign";
import "@webcomponents/template";
import {
type AccessDeniedChallenge,
@@ -19,6 +20,8 @@ import {
import { fromByteArray } from "base64-js";
import $ from "jquery";
import { html, nothing, render, TemplateResult } from "lit-html";
/* eslint-disable @typescript-eslint/no-use-before-define */
interface GlobalAuthentik {
@@ -53,11 +56,7 @@ class SimpleFlowExecutor {
}
loading() {
this.container.innerHTML = `<div class="d-flex justify-content-center">
<div class="spinner-border spinner-border-md" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>`;
new LoadingStage(this, {}).render();
}
start() {
@@ -137,7 +136,7 @@ export interface FlowInfoChallenge {
};
}
class Stage<T extends FlowInfoChallenge> {
abstract class Stage<T extends FlowInfoChallenge> {
constructor(
public executor: SimpleFlowExecutor,
public challenge: T,
@@ -150,32 +149,32 @@ class Stage<T extends FlowInfoChallenge> {
return this.challenge.responseErrors[fieldName] || [];
}
renderInputError(fieldName: string) {
return `${this.error(fieldName)
.map((error) => {
return `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
renderInputError(fieldName: string): TemplateResult {
return html`${this.error(fieldName).map((error) => {
return html`<div class="invalid-feedback">${error.string}</div>`;
})}`;
}
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
renderNonFieldErrors(): TemplateResult {
return html`${this.error("non_field_errors").map((error) => {
return html`<div class="alert alert-danger" role="alert">${error.string}</div>`;
})}`;
}
html(html: string) {
this.executor.container.innerHTML = html;
html(html: TemplateResult) {
render(html, this.executor.container);
}
abstract render(): void;
}
class LoadingStage extends Stage<FlowInfoChallenge> {
render() {
throw new Error("Abstract method");
return html`<div class="d-flex justify-content-center">
<div class="spinner-border spinner-border-md" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>`;
}
}
@@ -183,61 +182,87 @@ const IS_INVALID = "is-invalid";
class IdentificationStage extends Stage<IdentificationChallenge> {
render() {
this.html(`
<form id="ident-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form
id="ident-form"
@submit=${(ev: SubmitEvent) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
}}
>
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
this.challenge.applicationPre
? `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
${this.challenge.applicationPre
? html`<p>Log in to continue to ${this.challenge.applicationPre}.</p>`
: nothing}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
<input
type="text"
autofocus
class="form-control"
name="uid_field"
placeholder="Email / Username"
/>
</div>
${
this.challenge.passwordFields
? `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.challenge.passwordFields
? html`<div class="form-label-group my-3 has-validation">
<input
type="password"
class="form-control ${this.error("password").length > 0
? IS_INVALID
: ""}"
name="password"
placeholder="Password"
/>
${this.renderInputError("password")}
</div>`
: nothing}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
<button class="btn btn-primary w-100 py-2" type="submit">
${this.challenge.primaryAction}
</button>
</form>`,
);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
class PasswordStage extends Stage<PasswordChallenge> {
render() {
this.html(`
<form id="password-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form
id="password-form"
@submit=${(ev: SubmitEvent) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
}}
>
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3">
<input type="text" readonly class="form-control-plaintext" value="Welcome, ${this.challenge?.pendingUser}.">
<input
type="text"
readonly
class="form-control-plaintext"
value="Welcome, ${this.challenge?.pendingUser}."
/>
</div>
<div class="form-label-group my-3 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
<input
type="password"
autofocus
class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}"
name="password"
placeholder="Password"
/>
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
</form>`,
);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
@@ -249,23 +274,20 @@ class RedirectStage extends Stage<RedirectChallenge> {
class AutosubmitStage extends Stage<AutosubmitChallenge> {
render() {
this.html(`
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form id="autosubmit-form" action="${this.challenge.url}" method="post">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
return html`<input type="hidden" name="${key}" value="${value}" />`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
</form>`,
);
$("#autosubmit-form").submit();
}
}
@@ -394,73 +416,79 @@ class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge>
? undefined
: challenge,
);
this.html(`<form id="picker-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form id="picker-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
${
challenges.length > 0
? "<p>Select an authentication method.</p>"
: `<p>No compatible authentication method available</p>`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) {
return "";
}
return `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
${challenges.length > 0
? html`<p>Select an authentication method.</p>`
: html`<p>No compatible authentication method available</p>`}
${challenges.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) {
return "";
}
return html`<div class="form-label-group my-3 has-validation">
<button
class="btn btn-secondary w-100 py-2"
type="button"
@click=${() => {
this.deviceChallenge = challenge;
this.render();
}}
>
${label}
</button>
</div>`;
})}
</form>`,
);
}
renderCodeInput() {
this.html(`
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form
id="totp-form"
@submit=${(ev: SubmitEvent) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
}}
>
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? IS_INVALID : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
<input
type="text"
autofocus
class="form-control ${this.error("code").length > 0 ? IS_INVALID : ""}"
name="code"
placeholder="Please enter your code"
autocomplete="one-time-code"
/>
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
</form>`,
);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
renderWebauthn() {
this.html(`
this.html(html`
<form id="totp-form">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
@@ -468,7 +496,7 @@ class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge>
</div>
</div>
</form>
`);
`);
navigator.credentials
.get({
publicKey: this.transformCredentialRequestOptions(
@@ -504,13 +532,13 @@ class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge>
class AccessDeniedStage extends Stage<AccessDeniedChallenge> {
render() {
this.html(`<form id="access-denied">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
this.html(
html`<form id="access-denied">
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="" />
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
<p>
${this.challenge.errorMessage ?? "Access denied."}
</p>
</form>`);
<p>${this.challenge.errorMessage ?? "Access denied."}</p>
</form>`,
);
}
}

View File

@@ -108,11 +108,11 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
}
getObjectEditButton(item: PolicyBinding): SlottedTemplateResult {
if (item.policy) {
if (item.policyObj) {
return html`<ak-forms-modal>
${StrictUnsafe<CustomFormElementTagName>(item.policyObj?.component, {
${StrictUnsafe<CustomFormElementTagName>(item.policyObj.component, {
slot: "form",
instancePk: item.policyObj?.pk,
instancePk: item.policyObj.pk,
actionLabel: msg("Update"),
headline: msg(str`Update ${item.policyObj?.name}`, {
id: "form.headline.update",
@@ -123,20 +123,20 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
${msg("Edit Policy")}
</button>
</ak-forms-modal>`;
} else if (item.group) {
} else if (item.groupObj) {
return html`<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Group")}</span>
<ak-group-form slot="form" .instancePk=${item.groupObj?.pk}> </ak-group-form>
<ak-group-form slot="form" .instancePk=${item.groupObj.pk}> </ak-group-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Edit Group")}
</button>
</ak-forms-modal>`;
} else if (item.user) {
} else if (item.userObj) {
return html`<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update User")}</span>
<ak-user-form slot="form" .instancePk=${item.userObj?.pk}> </ak-user-form>
<ak-user-form slot="form" .instancePk=${item.userObj.pk}> </ak-user-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Edit User")}
</button>

View File

@@ -181,8 +181,7 @@ export class TaskList extends Table<Task> {
item.eta !== undefined ? Timestamp(item.eta) : nothing,
Timestamp(item.mtime ?? new Date()),
html`<ak-task-status .status=${item.aggregatedStatus}></ak-task-status>`,
item.state === TasksTasksListStateEnum.Rejected ||
item.state === TasksTasksListStateEnum.Done
item.state === TasksTasksListStateEnum.Rejected
? html`<ak-action-button
class="pf-m-plain"
.apiRequest=${() => {

View File

@@ -529,6 +529,7 @@ export class CaptchaStage
const template = iframeTemplate(captchaElement, {
challengeURL: challengeURL.toString(),
theme: this.activeTheme,
scriptOnLoad: !(controller instanceof TurnstileController),
});
if (

View File

@@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
import { ifPresent } from "#elements/utils/attributes";
import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController";
import { TurnstileObject } from "turnstile-types";
@@ -20,7 +18,16 @@ export class TurnstileController extends CaptchaController {
public prepareURL = (): URL | null => {
const input = this.host.challenge?.jsUrl;
return input && URL.canParse(input) ? new URL(input) : null;
if (!input || !URL.canParse(input)) return null;
const url = new URL(input);
// Use explicit rendering to prevent Turnstile's 3-hour self-upgrade
// from calling implicitRenderAll() and duplicating widgets.
url.searchParams.set("render", "explicit");
url.searchParams.set("onload", "onTurnstileReady");
return url;
};
/**
@@ -33,25 +40,34 @@ export class TurnstileController extends CaptchaController {
/**
* Renders the Turnstile captcha frame.
*
* Uses explicit rendering to avoid Turnstile's self-upgrade mechanism
* (every ~3 hours) from calling `implicitRenderAll()` and duplicating widgets.
*
* @remarks
*
* Turnstile will log a warning if the `data-language` attribute
* Turnstile will log a warning if the `language` option
* is not in lower-case format.
*
* @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages}
*/
public interactive = () => {
const languageTag = this.host.activeLanguageTag.toLowerCase();
const siteKey = this.host.challenge?.siteKey ?? "";
const theme = this.host.activeTheme;
const language = this.host.activeLanguageTag.toLowerCase();
return html`<div
id="ak-container"
class="cf-turnstile"
data-sitekey=${ifPresent(this.host.challenge?.siteKey)}
data-theme=${this.host.activeTheme}
data-callback="callback"
data-size="flexible"
data-language=${ifPresent(languageTag)}
></div>`;
return html`<div id="ak-container"></div>
<script>
function onTurnstileReady() {
turnstile.render("#ak-container", {
sitekey: "${siteKey}",
theme: "${theme}",
language: "${language}",
size: "flexible",
callback,
});
loadListener();
}
</script>`;
};
public refreshInteractive = async () => {

View File

@@ -25,6 +25,11 @@ export function themeMeta(theme: ResolvedUITheme) {
export interface IFrameTemplateInit {
challengeURL: URL | string;
theme: ResolvedUITheme;
/**
* If `true`, the script element will fire `loadListener()` on load.
* Defaults to `true`.
*/
scriptOnLoad?: boolean;
}
/**
@@ -37,7 +42,7 @@ export interface IFrameTemplateInit {
*/
export function iframeTemplate(
children: TemplateResult,
{ challengeURL, theme }: IFrameTemplateInit,
{ challengeURL, theme, scriptOnLoad = true }: IFrameTemplateInit,
) {
return createDocumentTemplate({
head: html`
@@ -90,7 +95,10 @@ export function iframeTemplate(
}
</style>
${children}
<script onload="loadListener()" src="${challengeURL.toString()}"></script>
<script
${scriptOnLoad ? 'onload="loadListener()"' : ""}
src="${challengeURL.toString()}"
></script>
`,
});
}

View File

@@ -17,16 +17,16 @@ export interface BroadcastMessage {
[key: string]: unknown;
}
export class Broadcast extends BroadcastChannel {
export class Broadcast extends BroadcastChannel implements Disposable {
static shared = new Broadcast();
private discoveredTabIds = new Set<string>();
exitedTabIds: string[] = [];
protected discoveredTabIDs = new Set<string>();
public exitedTabIDs: string[] = [];
#logger: Logger;
protected logger: Logger;
#onMessage = (ev: MessageEvent<BroadcastMessage>) => {
this.#logger.debug("broadcast event", ev.data);
protected messageListener = (ev: MessageEvent<BroadcastMessage>) => {
this.logger.debug("broadcast event", ev.data);
switch (ev.data.type) {
case BroadcastMessageType.discover:
if (ev.data.sender === TabID.shared.current) {
@@ -38,40 +38,50 @@ export class Broadcast extends BroadcastChannel {
});
return;
case BroadcastMessageType.discoverReply:
this.discoveredTabIds.add(ev.data.sender as string);
this.discoveredTabIDs.add(ev.data.sender as string);
return;
case BroadcastMessageType.exit:
this.exitedTabIds.push(ev.data.sender);
this.exitedTabIDs.push(ev.data.sender);
return;
case BroadcastMessageType.continue:
if (ev.data.target === TabID.shared.current) {
this.#logger.debug("Continuing upon event");
this.logger.debug("Continuing upon event");
window.dispatchEvent(new CustomEvent("ak-multitab-continue"));
}
return;
}
};
protected pageHideListener = () => {
this.akExitTab();
};
constructor() {
super(BROADCAST_CHANNEL_NAME);
this.addEventListener("message", this.#onMessage);
this.#logger = ConsoleLogger.prefix("mtab/broadcast");
this.addEventListener("message", this.messageListener);
window.addEventListener("pagehide", this.pageHideListener);
this.logger = ConsoleLogger.prefix("mtab/broadcast");
}
[Symbol.dispose]() {
this.removeEventListener("message", this.#onMessage);
this.removeEventListener("message", this.messageListener);
}
async akTabDiscover(): Promise<Set<string>> {
this.discoveredTabIds.clear();
this.discoveredTabIDs.clear();
this.postMessage({
type: BroadcastMessageType.discover,
sender: TabID.shared.current,
});
await new Promise<void>((r) => {
setTimeout(r, 20);
});
return this.discoveredTabIds;
return this.discoveredTabIDs;
}
akResumeTab(tabId: string) {

View File

@@ -8,10 +8,9 @@ import { ConsoleLogger } from "#logger/browser";
const lockKey = "authentik-tab-locked";
const logger = ConsoleLogger.prefix("mtab/orchestrate");
const TAB_EXIT_TIMEOUT_MS = 3000;
export function multiTabOrchestrateLeave() {
if (!globalAK().brand.flags.flowsContinuousLogin) {
return;
}
Broadcast.shared.akExitTab();
TabID.shared.clear();
}
@@ -20,35 +19,54 @@ export async function multiTabOrchestrateResume() {
if (!globalAK().brand.flags.flowsContinuousLogin) {
return;
}
const lockTabId = localStorage.getItem(lockKey);
const lockTabID = localStorage.getItem(lockKey);
const tabs = await Broadcast.shared.akTabDiscover();
logger.debug("Got list of tabs", tabs);
if (lockTabId && tabs.has(lockTabId)) {
if (lockTabID && tabs.has(lockTabID)) {
logger.debug("Tabs locked, leaving.");
multiTabOrchestrateLeave();
return;
}
logger.debug("Locking tabs");
localStorage.setItem(lockKey, TabID.shared.current);
for (const tab of tabs) {
logger.debug("Telling tab to continue", tab);
Broadcast.shared.akResumeTab(tab);
const done = Promise.withResolvers<void>();
const checker = setInterval(() => {
if (Broadcast.shared.exitedTabIds.includes(tab)) {
let timeout = -1;
const checker = requestAnimationFrame(() => {
if (Broadcast.shared.exitedTabIDs.includes(tab)) {
logger.debug("tab exited", tab);
setTimeout(() => {
self.clearTimeout(timeout);
self.setTimeout(() => {
logger.debug("continue exited", tab);
done.resolve();
}, 1000);
clearInterval(checker);
cancelAnimationFrame(checker);
}
}, 1);
});
timeout = self.setTimeout(() => {
logger.warn("Timed out waiting for tab to exit, moving on", tab);
cancelAnimationFrame(checker);
done.resolve();
}, TAB_EXIT_TIMEOUT_MS);
await done.promise;
logger.debug("Tab done, continuing", tab);
}
logger.debug("All tabs done.");
localStorage.removeItem(lockKey);
}

View File

@@ -80,14 +80,16 @@ The two most common types of bindings in authentik are:
### Policy bindings
A _policy binding_ connects a specific policy (a policy object) to a flow or flow-stage binding. With the policy binding, the flow (or specifically the stage within the flow) will now have additional content (i.e. the rules of the policy).
A _policy binding_ connects a specific policy (a policy object) to a flow or stage binding. With the policy binding, the flow (or specifically the stage within the flow) will now have additional content (i.e. the rules of the policy).
With policy bindings, you can also bind groups and users to another component (an application, a source, a flow, etc.). For example you can bind a group to an application, and then only that group (or other groups also bound to it), can access the application.
With policy bindings, you can also bind groups and users to another component (an application, a source, a flow, etc.). For example you can bind a specific group to an application, and then only that group (and/or other groups also bound to it, depending on the [policy engine mode](../applications/manage_apps.mdx#use-bindings-to-control-access)), can access the application.
When you bind a policy to a stage binding, this task is done _per flow_, and does not carry across to other flows that use this same stage. That is, you will need to go to the **Stage Bindings** tab for the specific flow, and add a policy to the stage.
Bindings are also used for [Application Entitlements](../../add-secure-apps/applications/manage_apps.mdx#application-entitlements), where you can bind specific users or groups to an application as a way to manage who has access to certain areas _within an application_.
:::info
Be aware that policy bindings that are bound directly to the flow are evaluated _before_ the flow executes, so if the user is not authenticated, the flow will not start.
Be aware that any policy binding bound directly to the entire flow (not to a stage within the flow) are evaluated _before_ the flow executes, so if the user has not been identified, the flow will not be accessible. This is due to bindings relying on user information that isn't available yet.
:::
### Flow-stage bindings

View File

@@ -2,7 +2,7 @@
title: Default flows
---
When you create a new provider, you can select certain default flows that will be used with the provider and its associated application. For example, you can [create a custom flow](../index.md#create-a-custom-flow) that overrides the defaults configured on the brand.
When you create a new provider, you can select certain default flows that will be used with the provider and its associated application. For example, you can [create a flow](../index.md#create-a-flow) that overrides the defaults configured on the brand.
If no default flow is selected when the provider is created, authentik will first check if there is a default flow configured in the active [**Brand**](../../../../sys-mgmt/brands/index.md). If no default is configured there, authentik will go through all flows with the matching designation, sorted by `slug`, evaluate policies bound directly to the flows, and pick the first flow whose policies allow access.

View File

@@ -2,9 +2,9 @@
title: Flows
---
Flows are a major component in authentik. In conjunction with stages and [policies](../../../customize/policies/index.md), flows are at the heart of our system of building blocks, used to define and execute the workflows of authentication, authorization, enrollment, and user settings.
Flows are a major component in authentik. In conjunction with [stages](../stages/index.md) and [policies](../../../customize/policies/index.md), flows are at the heart of our system of building blocks, used to define and execute the workflows of authentication, authorization, enrollment, and user settings.
There are over a dozen default, out-of-the-box flows available in authentik. Users can decide if they already have everything they need with the [default flows](../flow/examples/default_flows.md) or if they want to [create](#create-a-custom-flow) their own custom flow, using the Admin interface, Terraform, or via the API.
There are over a dozen default, out-of-the-box flows available in authentik. Users can decide if they already have everything they need with the [default flows](../flow/examples/default_flows.md) or if they want to [create](#create-a-flow) their own customized flow, using the Admin interface, Terraform, or via the API.
A flow is a method of describing a sequence of stages. A stage represents a single verification or logic step. By connecting a series of stages within a flow (and optionally attaching policies as needed) you can build a highly flexible process for authenticating users, enrolling them, and more.
@@ -44,7 +44,7 @@ Flow imports are blueprint files, which may contain objects other than flows (su
You should only import files from trusted sources and review blueprints before importing them.
:::
## Create a custom flow
## Create a flow
To create a flow, follow these steps:

View File

@@ -2,49 +2,52 @@
title: Flow Inspector
---
The flow inspector, introduced in 2021.10, allows administrators to visually determine how custom flows work, inspect the current [flow context](./context/index.mdx), and investigate issues.
The Flow Inspector allows administrators to visually determine how custom flows work, inspect the current [flow context](./context/index.mdx) by stepping through the flow process and observing the Inspector with each step, and investigate issues.
As shown in the screenshot below, the flow inspector displays next to the selected flow (in this case, "Change Password"), with [information](#flow-inspector-details) about that specific flow and flow context.
As shown in the screenshot below, the Flow Inspector displays to the right, beside the selected flow (in this case, "Change Password"), with [information](#flow-inspector-details) about that specific flow and flow context.
![](./flow-inspector.png)
## Access the Flow Inspector
:::info
Be aware that when running a flow with the inspector enabled, the flow is still executed normally. This means that for example, a [User write](../stages/user_write.md) stage _will_ write user data.
:::warning
Be aware that when running a flow with the Inspector enabled, the flow is still executed normally. This means that for example, a [User write](../stages/user_write.md) stage _will_ write user data.
:::
The inspector is accessible to users that have been granted the [permission](../../../users-sources/access-control/permissions.md) **Can inspect a Flow's execution**, either directly or through a role. Superusers can always inspect flow executions.
When developing authentik with the debug mode enabled, the inspector is enabled by default and can be accessed by both unauthenticated users and standard users. However the debug mode should only be used for the development of authentik. So unless you are a developer and need the more verbose error information, the best practice for using the flow inspector is to assign the permission, not use debug mode.
The Inspector is accessible to users that have been granted the [permission](../../../users-sources/access-control/permissions.md) **Can inspect a Flow's execution**, either directly or through a role. Superusers can always inspect flow executions.
Starting with authentik 2025.2, for users with appropriate permissions to access the inspector a button is shown in the top right of the [default flow executor](./executors/if-flow.md) which opens the flow inspector.
### Manually running a flow with the inspector
### Manually running a flow with the Inspector
1. To access the inspector, open the Admin interface and navigate to **Flows and Stages > Flows**.
1. To access the Inspector, open the Admin interface and navigate to **Flows and Stages > Flows**.
2. Select the specific flow that you want to inspect by clicking its name in the list.
3. On the Flow's detail page, on the left side under **Execute Flow**, click **with inspector**.
3. On the Flow's detail page, on the left side under **Execute Flow**, click **Use Inspector**.
4. The selected flow will launch in a new browser tab, with the flow inspector displayed to the right.
4. The selected flow will launch in a new browser tab, with the Flow Inspector displayed to the right.
Alternatively, a user with the correct permission can launch the inspector by adding the query parameter `?inspector` to the URL when the URL opens on a flow.
### Additional ways to access the Flow Inspector
:::info
Troubleshooting:
Alternatively, a user with the correct permission can launch the Inspector by adding the query parameter `?inspector` to the URL after the URL opens on a flow.
- If the flow inspector does not launch and a "Bad request" error displays, this is likely either because you selected a flow that has a policy bound directly to it that prevents access (so the inspector won't open because the flow can't be executed) or because you do not have view permission on that specific flow.
Users with permissions to access the Flow Inspector see a button in the top right of the [default flow executor](./executors/if-flow.md) to open the Inspector.
When developing authentik with the debug mode enabled, the Inspector is enabled by default and can be accessed by both unauthenticated users and standard users. However the debug mode should only be used for the development of authentik. So unless you are a developer and need the more verbose error information, the best practice for using the Flow Inspector is to assign the permission, not use debug mode.
:::info Troubleshooting
- If the Flow Inspector does not launch and a "Bad request" error displays, this is likely either because you selected a flow that has a policy bound directly to it that prevents access (so the Inspector won't open because the flow can't be executed) or because you do not have [view permission](../../../users-sources/access-control/manage_permissions.md#view-permissions) on that specific flow.
:::
### Flow Inspector Details
The following information is shown in the inspector:
The following information is shown in the Inspector:
#### Next stage
This is the currently planned next stage. If you have stage bindings configured to `Evaluate when flow is planned`\_`, then you will see the result here. If, however, you have them configured to re-evaluate (`Evaluate when stage is run`), then this will not show up here, since the results will vary based on your input.
This is the currently planned next stage. If you have stage bindings configured to `Evaluate when flow is planned`, then you will see the result here. If, however, you have them configured to re-evaluate (`Evaluate when stage is run`), then this will not show up, since the results will vary based on your input.
Shown is the name and kind of the stage, as well as the unique ID.

View File

@@ -13,7 +13,7 @@ The email address will be saved and can be used with the [Authenticator validati
To use the Email Authenticator Setup stage in a flow, follow these steps:
1. [Create](../../flow/index.md#create-a-custom-flow) a new flow or edit an existing one.
1. [Create](../../flow/index.md#create-a-flow) a new flow or edit an existing one.
2. On the flow's **Stage Bindings** tab, click **Create and bind stage** to create and add the Email Authenticator Setup stage. (If the stage already exists, click **Bind existing stage**.)
3. Configure the stage settings as described below.
- **Name**: provide a descriptive name, such as Email Authenticator Setup.

View File

@@ -0,0 +1,81 @@
---
title: Consent stage
---
The Consent stage is added to a flow to prompt the user for consent to share data such as User ID or other non-credential type information with the relying party (RP), the application the user is logging in to.
A Consent stage is typically added to an [authorization flow](../../flow/index.md#create-a-flow), but can be added to any flow.
:::info Default authorization flow with a Consent stage
Note that by default, the `default-provider-authorization-explicit-consent` flow already has a Consent stage bound to it. If you use this default flow, you do not need to take any of the below steps; the `default-provider-authorization-explicit-consent` flow is ready for use.
:::
## Example use case
This stage is to prompt users when they access an application to agree that authentik can provide user data to the application that the user is logging in to. This sharing of user data can facilitate tasks in the application; for example, providing an avatar, user name, or email address for the application to immediately use.
## Consent stage modes
The Consent stage has three configurable modes:
1. **Always require consent**: the user is prompted every time that they log in to give consent by clicking **Continue**.
2. **Consent given lasts indefinitely**: this mode stores the fact that the user previously clicked **Continue**, and creates a Consent object with a link to the user and to the application, and stores which permissions were consented to.
3. **Consent expires**: similar to **Consent given lasts indefinitely**, except the consent expires on the date defined in the stage in the field **Consent expires in**.
## Create and configure a Consent stage
If you want to add the consent stage to a flow other than the `default-provider-authorization-explicit-consent` flow (which already has a Consent stage bound to it), use the following steps.
The basic workflow for creating and configuring a Consent stage involves creating the stage and then binding it to an authorization flow.
Optionally, if you also want to customize the exact wording that appears on the consent prompt, you can create an [Expression policy](../../../../customize/policies/expression.mdx) with the text that you want to display on the Consent prompt, and then [bind](../../../../customize/policies/working_with_policies.md#bind-a-policy-to-a-stage-binding) the policy to the Consent stage binding in the authorization flow.
### 1. Create a Consent stage
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages** > **Stages** and click **Create**.
3. On the **New stage** wizard select **Consent Stage** and then click **Next**.
4. Provide the following configuration settings:
- **Name**:
- **Stage-specific settings**:
- **Mode**: Select the appropriate [mode](#consent-stage-modes) to use with this stage.
5. Click **Finish** to save the new stage.
### 2. Bind the Consent stage to an authorization flow
To include the Consent stage in the flow, follow [these directions](../../stages/index.md#bind-a-stage-to-a-flow).
### 3. Create an Expression policy (_optional_)
If you want to customize the text that appears on the consent prompt, you can create an Expression policy with the exact wording you want, and then bind it to the Consent stage in the flow.
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Customization** > **Policies** and click **Create**.
3. On the **New policy** wizard select **Expression Policy** and then click **Next**.
4. Provide the following configuration settings:
- **Name**:
- **Policy-specific settings**:
- **Expression**: use the following syntax to customize the wording on the stage:
````
context['flow_plan'].context['consent_header'] = "Are you OK with your IdP provider sharing your user identification data with the application?"
return True
```python
````
5. Click **Finish** to save the policy.
### 4. Bind the policy to the Consent stage in the authorization flow (_optional_)
The last step is to bind the policy that you just created in Step 3 to the Consent stage binding, _within_ the authorization flow.
:::info Important note about policy binding
You need to bind the policy to the stage within this flow, so go first to the flow where you added the Consent stage.
:::
1. Log in to authentik as an administrator, open the authentik Admin interface, and navigate to **Flows and Stages > Flows**.
2. In the list of flows, click on the name of the authorization flow that you want to use.
3. On the **Flow overview** tab, confirm that the flow contains a Consent stage.
4. Click the **Stage Bindings** tab.
5. Click the caret (>) beside the Consent stage to which you want to bind the policy, and expand the stage details.
6. Click **Bind existing Policy/Group/User**.
7. In the **Create Binding** dialog, click **Policy** and then select the Expression policy that you created above.
8. Click **Create** to save the binding.

View File

@@ -2,11 +2,39 @@
title: Password stage
---
This is a generic password prompt which authenticates the current `pending_user`. This stage allows the selection of the source the user is authenticated against.
This is a generic password prompt that authenticates the current `pending_user`. This stage allows the selection of how the user's credentials are validated, with either a standard password, an App password, or source (LDAP or Kerberos) against which the user is authenticated.
## Create a Password stage
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages > Stages** and click **Create**.
3. In the **New Stage** dialog select **Password stage**, and then click **Next**.
4. Provide the following settings:
- **Name**: enter a descriptive name.
- **Stage-specific settings**:
- **Backends**: select one or more of the following options:
- **User database + standard password**: configures the stage to use the authentik database, accessed with the credentials and standard password of the user who is logging in.
- **User database + app passwords**: configures the stage to use the authentik database, accessed with the user's credentials and an App password (created by the user on the User interface, or an administrator on the Admin interface).
- **User database + LDAP password**: configures the stage to use the authentik database, accessed with the user identifier (User ID) and the password provided by the [LDAP source](../../../../users-sources/sources/protocols/ldap/index.md).
- **User database + Kerberos password**: configures the stage to use the authentik database, accessed with the user identifier (User ID) and the password provided by the [Kerberos source](../../../../users-sources/sources/protocols/kerberos/index.md).
If you select multiple backend settings, authentik goes through them each in order.
- **Configuration flow**: you are able to select any of the default flows, but typically you should select `default-password-change (Change Password)`. However, you might have created a specific flow for passwords, that adds a stage for MFA or some such, so you could select that flow here instead.
- **Failed attempts before cancel**: indicate how many times a user is allowed to attempt the password.
- **Allow Show Password**: toggle this option to allow the user to view in plain text the password that they are entering.
5. Click **Finish** to create the new Password stage.
:::tip
If you create a service account, that account has an automatically generated App password. If you impersonate the service account, you can view it under the **Settings** > **Tokens and App passwords** section of the User interface or under **Directory** > **Tokens and App passwords** of the Admin interface.
:::
## Passwordless login
There are two different ways to configure passwordless authentication; you can follow the instructions [here](../authenticator_validate/index.mdx#passwordless-authentication) to allow users to directly authenticate with their authenticator (only supported for WebAuthn devices), or dynamically skip the password stage depending on the user's device, which is documented here.
There are two different ways to configure passwordless authentication;
- allow users to directly authenticate with their authenticator (only supported for WebAuthn devices), by following [these instructions](../authenticator_validate/index.mdx#passwordless-authentication).
- dynamically skip a Password stage (depending on the user's device), as documented on this page.
If you want users to be able to pick a passkey from the browser's passkey/autofill UI without entering a username first, configure **Passkey autofill (WebAuthn conditional UI)** in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This is separate from configuring a dedicated passwordless flow, and can be used alongside normal identification flows.

View File

@@ -213,6 +213,6 @@ When a _Signing Key_ is selected in the provider, the JWT will be signed asymmet
When no _Signing Key_ is selected, the JWT will be signed symmetrically with the _Client secret_ of the provider, which can be seen in the provider settings.
### Encryption:ak-version
### Encryption
authentik can also encrypt JWTs (turning them into JWEs) it issues by selecting an _Encryption Key_ in the provider. When selected, all JWTs will be encrypted symmetrically using the selected certificate. authentik uses the `RSA-OAEP-256` algorithm with the `A256CBC-HS512` encryption method.

View File

@@ -13,6 +13,8 @@ app.company {
# capitalization of the headers is important, otherwise they will be empty
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
# Add the 'authorization' header to the list if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
# optional, in this config trust all private ranges, should probably be set to the outposts IP
trusted_proxies private_ranges

View File

@@ -20,7 +20,7 @@ spec:
headersToUpstreamOnAllow:
- set-cookie
- x-authentik-*
# Add authorization headers to the allow list if you need proxy providers which
# Add the 'authorization' header to headersToUpstreamOnAllow if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
# - authorization
includeRequestHeadersInCheck:

View File

@@ -41,6 +41,8 @@ metadata:
https://app.company/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: |-
Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid
# Add the 'authorization' header to auth-response-headers if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header X-Forwarded-Host $http_host;
```

View File

@@ -33,6 +33,8 @@ services:
traefik.http.middlewares.authentik.forwardauth.address: http://authentik-proxy:9000/outpost.goauthentik.io/auth/traefik
traefik.http.middlewares.authentik.forwardauth.trustForwardHeader: true
traefik.http.middlewares.authentik.forwardauth.authResponseHeaders: X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
# Add the 'authorization' header to authResponseHeaders if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
restart: unless-stopped
whoami:

View File

@@ -23,6 +23,9 @@ spec:
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
# Add the 'authorization' header to authResponseHeaders if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
# - authorization
```
:::info

View File

@@ -18,6 +18,9 @@ http:
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
# Add the 'authorization' header to authResponseHeaders if you need proxy providers which
# send a custom HTTP-Basic Authentication header based on values from authentik
# - authorization
routers:
default-router:
rule: "Host(`app.company`)"

View File

@@ -6,6 +6,8 @@ Image files are used in authentik to add an icon to new applications that you ad
authentik provides a centralized file management system for storing and organizing these files. Files can be uploaded and managed from **Customization** > **Files** in the Admin interface. By default, files are stored on disk in the `/data` directory, but [S3 storage](../sys-mgmt/ops/storage-s3.md) can also be configured.
If file uploads are missing or unavailable after an upgrade, see [Errors when uploading icons](../troubleshooting/image_upload.md).
## Upload and manage files
To upload and use image files, follow these steps:

View File

@@ -48,6 +48,10 @@ These bindings control which users can access a flow.
These bindings control which stages are applied to a flow.
::: info
When you bind a policy to a stage binding, this task is done _per flow_, and does not carry across to other flows that might use this same stage.
:::
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages** > **Flows**.
3. In the list of flows, click on the name of the flow which has the stage to which you want to bind a policy.

View File

@@ -195,6 +195,8 @@ Now that you can access the authentik Admin interface, and you have added an app
Now that you have added your first application, and a new user, here are some typical next steps:
- Assign your new user to appropriate [groups](../../users-sources/user/user_basic_operations.md#add-a-user-to-a-group) and [roles](../../users-sources/user/user_basic_operations.md#add-a-user-to-a-role).
- [Restrict access](../../add-secure-apps/applications/manage_apps.mdx#use-bindings-to-control-access) to an application. See more below about [using bindings to restrict access](#using-bindings-to-allow-or-restrict-access-to-applications).
- Learn more about [Role Based Access Control (RBAC) in authentik](../../users-sources/access-control/index.mdx).
- Configure federated or external [sources](../../users-sources/sources/index.md) (an existing source of user credentials and other user data).
- Set up MFA
- Define [property mappings](../../add-secure-apps/providers/property-mappings/index.md).

View File

@@ -322,6 +322,41 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2026.2
- web: fix identification stage styling in compatibility mode (cherry-pick #20684 to version-2026.2) (#20694)
- web/flows: fix source icons being always inverted (cherry-pick #20419 to version-2026.2) (#20607)
## Fixed in 2026.2.2
- core: expiring model: ignore DoesNotExist error (cherry-pick #20922 to version-2026.2) (#20925)
- core: fix provider not nullable (cherry-pick #21275 to version-2026.2) (#21282)
- endpoints: prevent selection of incompatible connector (cherry-pick #20806 to version-2026.2) (#20807)
- endpoints/connectors: fix enabled flag not respected (cherry-pick #21144 to version-2026.2) (#21145)
- enterprise/endpoints/connectors/agent: add login_hint support for interactive auth (cherry-pick #20647 to version-2026.2) (#21047)
- events: avoid implicitly setting context from login_failed event (cherry-pick #21045 to version-2026.2) (#21050)
- events: prevent exception when events contains incompatible unicode (cherry-pick #21048 to version-2026.2) (#21053)
- flows: continuous login debug (cherry-pick #21044 to version-2026.2) (#21090)
- internal: fix certificate fallback without SNI (cherry-pick #21417 to version-2026.2) (#21419)
- outposts: only dispatch logout task if any outpost exists (cherry-pick #20920 to version-2026.2) (#20949)
- packages/django-channels-postgres: provide sync API for group_send (cherry-pick #20740 to version-2026.2) (#20741)
- packages/django-dramatiq-postgres: scheduler: only dispatch tasks if they're not running yet (cherry-pick #20921 to version-2026.2) (#20950)
- providers/ldap: inherit adjustable page size for LDAP searchers (cherry-pick #21377 to version-2026.2) (#21384)
- providers/oauth2: decode percent-encoded basic auth (cherry-pick #20779 to version-2026.2) (#20781)
- providers/proxy: Add a default maxResponseBodySize to Traefik Middleware (cherry-pick #21111 to version-2026.2) (#21140)
- providers/proxy: remove redundant logout event (cherry-pick #20860 to version-2026.2) (#20866)
- providers/saml: Fix redirect for saml slo (cherry-pick #21258 to version-2026.2) (#21284)
- providers/scim: fix out-of-scope users and groups not being deleted from destination application (cherry-pick #20742 to version-2026.2) (#20780)
- providers/ldap: avoid concurrent header writes in API Client (cherry-pick #21223 to version-2026.2) (#21228)
- sources/ldap: fix exception in ldap debug endpoint (cherry-pick #21219 to version-2026.2) (#21222)
- sources/ldap: fix incorrect error response for invalid sync_users_password (cherry-pick #21016 to version-2026.2) (#21039)
- sources/oauth: Allow patching without provider type (cherry-pick #21211 to version-2026.2) (#21213)
- tasks: allow retry for rejected tasks only (cherry-pick #21433 to version-2026.2) (#21436)
- web/admin: bad width on policy test results (cherry-pick #20668 to version-2026.2) (#20697)
- web/admin: fix missing OSM referrerPolicy header (cherry-pick #20984 to version-2026.2) (#20990)
- web/admin: Fix SCIM page_size UI issue (cherry-pick #20890 to version-2026.2) (#20929)
- web/admin: handle non-string values in formatUUID to prevent Event Log crash (cherry-pick #20804 to version-2026.2) (#21052)
- web/applications: add wsfed to app wizard (cherry-pick #20880 to version-2026.2) (#21184)
- web/flow: be more aggressive about checking inspector hide/show status (#21358)
- web/flow: reset stale authenticator selection between consecutive validate stages (cherry-pick #20802 to version-2026.2) (#21014)
- web/flows: continuous login (cherry-pick #19862 to version-2026.2) (#20712)
- web/rbac: disambiguate duplicate permission names in initial permissions (cherry-pick #20786 to version-2026.2) (#20805)
## API Changes
### authentik (v2026.2.0)

View File

@@ -330,6 +330,7 @@ const items = [
"add-secure-apps/flows-stages/stages/authenticator_validate/index",
"add-secure-apps/flows-stages/stages/authenticator_webauthn/index",
"add-secure-apps/flows-stages/stages/captcha/index",
"add-secure-apps/flows-stages/stages/consent/index",
"add-secure-apps/flows-stages/stages/deny",
"add-secure-apps/flows-stages/stages/email/index",
"add-secure-apps/flows-stages/stages/endpoint/index",
@@ -501,6 +502,7 @@ const items = [
"users-sources/user/user_ref",
"users-sources/user/invitations",
"users-sources/user/password_reset_on_login",
"users-sources/user/user-interface",
],
},
{

View File

@@ -2,15 +2,19 @@
title: Errors when uploading icons
---
There are two common causes for icon and image upload problems when authentik uses local file storage.
## 1. Permissions problems
:::info
This is specific to the Docker Compose installation, if you're running into issues on Kubernetes please open a GitHub issue.
This section applies to Docker Compose and other deployments that use bind mounts, where host filesystem permissions determine whether authentik can write to the mounted data directory.
:::
This issue is most likely caused by permissions. Docker creates bound volumes as root, but the authentik processes don't run as root.
This issue is most likely caused by permissions. Docker creates bound volumes as root, but the authentik processes do not run as root.
This will cause issues with icon uploads (for Applications), background uploads (for Flows) and local backups.
To fix these issues, run these commands in the folder of your Docker Compose file:
For Docker Compose, run these commands in the directory of your Compose file:
```shell
sudo chown 1000:1000 data/
@@ -18,3 +22,26 @@ sudo chown 1000:1000 custom-templates/
sudo chmod ug+rwx data/
sudo chmod ug+rx certs/
```
Alternatively, If you are using Kubernetes, ensure that the volume mounted at `/data` is writable by the authentik container.
## 2. Legacy `/media` mounts after upgrading
If you upgraded from an older release and existing files still appear, but the upload controls are missing in **Customization** > **Files**, or you cannot upload new files, check your local storage mount path.
Current authentik versions expect local file storage at `/data`, with media files stored under `/data/media`. A legacy mount to `/media` will still allow older files to be read through compatibility handling, while preventing new uploads and file management.
Update your deployment to use the current storage layout.
Examples:
```yaml
# Docker Compose
volumes:
- ./data:/data
- ./data/custom-templates:/templates
```
For Kubernetes deployments, mount your persistent storage at `/data` instead of `/media`.
If you previously stored files under a path mounted to `/media`, move that data so it is available under `/data/media` inside the authentik container.

View File

@@ -1,5 +1,6 @@
---
title: Apple
title: Log in with Apple
sidebar_label: Apple
tags:
- source
- apple

View File

@@ -1,5 +1,6 @@
---
title: Discord
title: Log in with Discord
sidebar_label: Discord
tags:
- source
- discord

View File

@@ -1,5 +1,6 @@
---
title: Entra ID
title: Log in with Entra ID
sidebar_label: Entra ID
tags:
- source
- entra

View File

@@ -1,5 +1,6 @@
---
title: Facebook
title: Log in with Facebook
sidebar_label: Facebook
tags:
- source
- facebook

View File

@@ -1,5 +1,6 @@
---
title: GitHub
title: Log in with GitHub
sidebar_label: GitHub
tags:
- source
- github

View File

@@ -1,5 +1,6 @@
---
title: Keycloak
title: Log in with Keycloak
sidebar_label: Keycloak
tags:
- source
- keycloak

View File

@@ -1,5 +1,6 @@
---
title: Mailcow
title: Log in with Mailcow
sidebar_label: Mailcow
tags:
- source
- mailcow

View File

@@ -1,5 +1,6 @@
---
title: Okta
title: Log in with Okta
sidebar_label: Okta
description: "Integrate Okta as a source in authentik"
tags: [source, okta]
---

View File

@@ -1,5 +1,6 @@
---
title: Plex
title: Log in with Plex
sidebar_label: Plex
tags:
- source
- plex

View File

@@ -1,5 +1,6 @@
---
title: Shibboleth
title: Log in with Shibboleth
sidebar_label: Shibboleth
tags:
- source
- shibboleth

View File

@@ -1,5 +1,6 @@
---
title: Telegram
title: Log in with Telegram
sidebar_label: Telegram
support_level: community
---

View File

@@ -1,5 +1,6 @@
---
title: Twitch
title: Log in with Twitch
sidebar_label: Twitch
tags:
- source
- twitch

View File

@@ -1,5 +1,6 @@
---
title: X (Twitter)
title: Log in with X (formerly Twitter)
sidebar_label: X (formerly Twitter)
tags:
- source
- x

View File

@@ -1,5 +1,6 @@
---
title: WeChat
title: Log in with WeChat
sidebar_label: WeChat
tags:
- source
- wechat

View File

@@ -103,7 +103,7 @@ To require a user to reset their password on next login, you will need to set a
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Directory** > **Users** and click the **Edit** icon of the user in question.
3. Add the following values to the user's attribute field:
3. Add the following values to the user's **Attribute** field:
```python
reset_password: True
```

View File

@@ -0,0 +1,64 @@
---
title: User interface overview
sidebar_label: User interface
---
End-users who are accessing their applications via authentik typically only access the User interface, not the Admin interface. (There are exceptions; some end-users have [permissions to also access the Admin interface](../../users-sources/access-control/manage_permissions.md#assign-can-view-admin-interface-permissions), while some end-users never go to the User interface, but rather [log directly into their application](/brands/#external-user-settings) using authentik behind the scenes.)
Conversely, administrators for an authentik instance work primarily in the Admin interface; that is where administrators add applications, create new users and groups, manage system settings, and more.
:::info
This document covers the basic tasks that end-users accomplish in the User interface. All of our technical documentation is available to all users, just note that the vast majority of it is for the Admin interface because the User interface is for simple end-user tasks.
:::
## Access the User interface
As an end-user, you will typically first see the User interface when you log into authentik. The main page of the User interface is the **My applications** page, where all of the applications that you access via authentik.
To view your own settings click the gear icon in the upper right. The following sections are displayed on the page:
### User details
This section of the User interface displays (and allows you to edit if you have the requisite permissions) the fields below. Note that these are the options available in a default authentik instance; administrators can customize which options show up here.
- **Username**: the username is the unique identifier associated with the user, and is required for logging in. This value can only be edited by the user if the [global System settings](../../sys-mgmt/settings.md#allow-users-to-change-username) are configured to allow all users to change their username, or if the attribute [`goauthentik.io/user/can-change-username`](../../users-sources/user/user_ref.mdx#goauthentikiousercan-change-username) has been added to the **Attributes** field for a specific user (overriding the global System setting).
- **Name**: a display name, or nickname, for the user. Similar to the username, this value can be set globally in [System settings](../../sys-mgmt/settings.md#allow-users-to-change-name), or per user with the attribute [`goauthentik.io/user/can-change-name`](../../users-sources/user/user_ref.mdx#goauthentikiousercan-change-name).
- **Email**: the email address for the user. This value also can be set globally in [System settings](../../sys-mgmt/settings.md#allow-users-to-change-email), or if the attribute [`goauthentik.io/user/can-change-email`](../../users-sources/user/user_ref.mdx#goauthentikiousercan-change-email) has been added to the **Attributes** field for a specific user (overriding the global System setting).
- **Locale**: override any global locale settings and either choose a specific language or select **Auto-Detect**, which relies on the user's browser local settings.
- **Change your password**: if a user has the [permission to update their password](../user/user_basic_operations.md#reset-a-password), they can do so here.
### Sessions
This tab shows all active sessions for the user. Here you can delete sessions, including the current one (which would result in an automatic log out) or a session on a remote device.
### Consent
You can view applications to which you gave consent to allow authentik to share your profile user data with the application.
When an administrator adds this stage to an authorization flow, the user logging in is presented with a pop-up confirmation page asking if they agree to allow the application to directly request their account data (typically profile and email address) from the source. The user clicks **Continue** to give consent.
For more information refer to our documentation on the [Consent stage](../../add-secure-apps/flows-stages/stages/consent/index.md).
### MFA Devices
This is where a users can add and configure a new MFA device for accessing authentik. The three default options for MFA are:
- **Static tokens**: authentik generates 6 single-use tokens.
- **TOTP device**: using your preferred authenticator, scan the QR code, enter the code from the authenticator into the authentik prompt, and then click **Continue**. For authenticators that do not support QR scanning, you can copy the secret and paste it into you authenticator.
- **WebAuthn device**: this option uses the [WebAuthn/FIDO2/Passkeys Authenticator setup stage](../../add-secure-apps/flows-stages/stages/authenticator_webauthn/index.mdx) to allow the user to create a passkey for the device.
An authentik administrator can add additional MFA options for users, such as [Email](../../add-secure-apps/flows-stages/stages/authenticator_email/index.md), [SMS](../../add-secure-apps/flows-stages/stages/authenticator_sms/index.mdx), or [Duo](../../add-secure-apps/flows-stages/stages/authenticator_duo/index.mdx), by adding the stage for that authentication method to the flow.
:::info LDAP providers and MFA
Because LDAP does not natively support OTP, authentik supports [appending the OTP code to the password](../../add-secure-apps/providers/ldap/index.md#code-based-mfa-support) for situations where the protocol is LDAP and they are required to use MFA. If enabled, the user can enter the authenticator's code as part of the bind/authentication password, separated by a semicolon. For example, for the password `example-password` and the MFA code `123456`, the input in the password field must be `example-password;123456`.
:::
### Connected services
If an authentik administrator adds a [source](../sources/index.md) to the instance, such as GitHub, Discord, Google Workspace or Microsoft Entra ID, then users will see a list of those sources here and can choose to log in (**Connect**) using credentials from that source, or **Disconnect** form the service. Note that SCIM and LDAP sources are not displayed.
### Tokens and App passwords
**Tokens**: Users can create a set of 6 token to use as standard _access tokens_ for authorization, allowing a client application to access an API or other protected resource.
**App password** an App password can be used as a secondary form of authentication. For example, in situations where MFA is not natively supported for the protocol that the application uses, the App passwords behaves as the user's regular password.

View File

@@ -116,6 +116,8 @@ If a user has lost their credentials and needs to recover their account, there a
Both options require you to configure a recovery flow and set it as the **Default recovery flow** for the active brand.
If the user only needs their password reset, see these [instructions](#reset-a-password).
### Configure a recovery flow
To get started, you can [import](../../add-secure-apps/flows-stages/flow/index.md#import-or-export-a-flow) this example flow: [Recovery with email verification flow](../../add-secure-apps/flows-stages/flow/examples/flows.md#recovery-with-email-and-mfa-verification)
@@ -143,7 +145,7 @@ A pop-up will appear on your browser with the link for you to copy and to send t
### 2. Email a recovery link
:::info Email stage required
This option is only available if the default recovery flow has an [Email Stage](../../add-secure-apps/flows-stages/stages/email/index.mdx) bound to it. The example recovery flow includes an email stage.
This option is only available if the recovery flow has an [Email Stage](../../add-secure-apps/flows-stages/stages/email/index.mdx) bound to it. The example recovery flow includes an email stage.
:::
You can send a link with the URL for the user to reset their password via Email. This option will only work if you have [configured email](../../install-config/email.mdx) and set an email address for the user.
@@ -154,7 +156,9 @@ You can send a link with the URL for the user to reset their password via Email.
If the user does not receive the email, check if the mail server parameters [are properly configured](../../troubleshooting/emails.md).
## Reset the password for the user
## Reset a password
### Admin resets a user's password
As an Admin, you can simply reset the password for the user.
@@ -162,6 +166,10 @@ As an Admin, you can simply reset the password for the user.
2. Either click the name of the user to display the full User details page, or click the chevron beside their name to expand the options.
3. To reset the user's password, click **Reset password**, and then define the new value.
### User resets their password
If a [Recovery flow](#configure-a-recovery-flow) has been applied to the brand, users can reset their own passwords in the [User interface](../user/user-interface.mdx).
## Deactivate or Delete user
### To deactivate a user: