mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
34 Commits
version/20
...
cherry-pic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548aef46d2 | ||
|
|
7af9e98079 | ||
|
|
51901c82ba | ||
|
|
ff653005e4 | ||
|
|
9b64d05e35 | ||
|
|
99a93fa8a2 | ||
|
|
bd2a0e1d7d | ||
|
|
c4d455dd3a | ||
|
|
508dba6a04 | ||
|
|
aa921dcdca | ||
|
|
e5d873c129 | ||
|
|
f0a14d380f | ||
|
|
1da15a549e | ||
|
|
eaf1c45ea6 | ||
|
|
f0f42668c4 | ||
|
|
123fbd26bb | ||
|
|
b94d93b6c4 | ||
|
|
d0b25bf648 | ||
|
|
d4db4e50b4 | ||
|
|
c5e726d7eb | ||
|
|
203a7e0c61 | ||
|
|
2feaeff5db | ||
|
|
8fcc47e047 | ||
|
|
7a6408cc67 | ||
|
|
2da88028da | ||
|
|
fa91404895 | ||
|
|
460fce7279 | ||
|
|
995128955c | ||
|
|
85536abbcf | ||
|
|
5249546862 | ||
|
|
bf91348c05 | ||
|
|
63136f0180 | ||
|
|
faffabf938 | ||
|
|
0b180b15a2 |
20
.github/actions/setup/action.yml
vendored
20
.github/actions/setup/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/actions/setup/compose.yml
vendored
2
.github/actions/setup/compose.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/ci-main.yml
vendored
5
.github/workflows/ci-main.yml
vendored
@@ -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
|
||||
|
||||
12
SECURITY.md
12
SECURITY.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,7 +47,8 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
|
||||
search_fields = [
|
||||
"pbm_uuid",
|
||||
"name",
|
||||
"app",
|
||||
"app__name",
|
||||
"app__slug",
|
||||
"attributes",
|
||||
]
|
||||
filterset_fields = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -44,3 +44,6 @@ class BaseController[T: "Connector"]:
|
||||
|
||||
def stage_view_authentication(self) -> StageView | None:
|
||||
return None
|
||||
|
||||
def sync_endpoints(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
35
authentik/endpoints/tests/test_tasks.py
Normal file
35
authentik/endpoints/tests/test_tasks.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026.2.2-rc3
|
||||
2026.2.3-rc1
|
||||
@@ -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
|
||||
|
||||
63
internal/outpost/radius/request_test.go
Normal file
63
internal/outpost/radius/request_test.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2026.2.2-rc3",
|
||||
"version": "2026.2.3-rc1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
108
packages/client-ts/src/models/AuthenticatedSessionUserAgentOs.ts
Normal file
108
packages/client-ts/src/models/AuthenticatedSessionUserAgentOs.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
17
schema.yml
17
schema.yml
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
10
uv.lock
generated
@@ -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
18
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2026.2.2-rc3",
|
||||
"version": "2026.2.3-rc1",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=${() => {
|
||||
|
||||
@@ -529,6 +529,7 @@ export class CaptchaStage
|
||||
const template = iframeTemplate(captchaElement, {
|
||||
challengeURL: challengeURL.toString(),
|
||||
theme: this.activeTheme,
|
||||
scriptOnLoad: !(controller instanceof TurnstileController),
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Apple
|
||||
title: Log in with Apple
|
||||
sidebar_label: Apple
|
||||
tags:
|
||||
- source
|
||||
- apple
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Discord
|
||||
title: Log in with Discord
|
||||
sidebar_label: Discord
|
||||
tags:
|
||||
- source
|
||||
- discord
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Entra ID
|
||||
title: Log in with Entra ID
|
||||
sidebar_label: Entra ID
|
||||
tags:
|
||||
- source
|
||||
- entra
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Facebook
|
||||
title: Log in with Facebook
|
||||
sidebar_label: Facebook
|
||||
tags:
|
||||
- source
|
||||
- facebook
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: GitHub
|
||||
title: Log in with GitHub
|
||||
sidebar_label: GitHub
|
||||
tags:
|
||||
- source
|
||||
- github
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Keycloak
|
||||
title: Log in with Keycloak
|
||||
sidebar_label: Keycloak
|
||||
tags:
|
||||
- source
|
||||
- keycloak
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Mailcow
|
||||
title: Log in with Mailcow
|
||||
sidebar_label: Mailcow
|
||||
tags:
|
||||
- source
|
||||
- mailcow
|
||||
|
||||
@@ -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]
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Plex
|
||||
title: Log in with Plex
|
||||
sidebar_label: Plex
|
||||
tags:
|
||||
- source
|
||||
- plex
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Shibboleth
|
||||
title: Log in with Shibboleth
|
||||
sidebar_label: Shibboleth
|
||||
tags:
|
||||
- source
|
||||
- shibboleth
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Telegram
|
||||
title: Log in with Telegram
|
||||
sidebar_label: Telegram
|
||||
support_level: community
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Twitch
|
||||
title: Log in with Twitch
|
||||
sidebar_label: Twitch
|
||||
tags:
|
||||
- source
|
||||
- twitch
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: X (Twitter)
|
||||
title: Log in with X (formerly Twitter)
|
||||
sidebar_label: X (formerly Twitter)
|
||||
tags:
|
||||
- source
|
||||
- x
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: WeChat
|
||||
title: Log in with WeChat
|
||||
sidebar_label: WeChat
|
||||
tags:
|
||||
- source
|
||||
- wechat
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
64
website/docs/users-sources/user/user-interface.mdx
Normal file
64
website/docs/users-sources/user/user-interface.mdx
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user