mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
96 Commits
pr-21647
...
saml-endpo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3d795cc2 | ||
|
|
2cd89b0ab0 | ||
|
|
a2ca19d718 | ||
|
|
aed634734b | ||
|
|
05005f4eb9 | ||
|
|
baf61056c7 | ||
|
|
e4b0ea7d15 | ||
|
|
740a5b85e3 | ||
|
|
8fd17966ab | ||
|
|
e63ff698da | ||
|
|
7d0ec4de23 | ||
|
|
b2b5f6400d | ||
|
|
94ce30adb5 | ||
|
|
52c573bfe2 | ||
|
|
74e2c63888 | ||
|
|
b390b679b7 | ||
|
|
501b851f3f | ||
|
|
f75a03e4ba | ||
|
|
95a7d8c92d | ||
|
|
27d1be0b85 | ||
|
|
7ee653cf0a | ||
|
|
079017c799 | ||
|
|
d647976b98 | ||
|
|
46862cca22 | ||
|
|
edce545f0d | ||
|
|
028b711746 | ||
|
|
fa18e71ca4 | ||
|
|
1fd472c0d9 | ||
|
|
b4adb7ee70 | ||
|
|
98f6726d95 | ||
|
|
ced8db0b65 | ||
|
|
d8e575c631 | ||
|
|
b7c03362a3 | ||
|
|
1ca4a34da7 | ||
|
|
94c3686065 | ||
|
|
3b61bf04d2 | ||
|
|
e310468a6e | ||
|
|
0947d38f0b | ||
|
|
f207491cf6 | ||
|
|
83294f4866 | ||
|
|
4af2d51f50 | ||
|
|
87bd0d7436 | ||
|
|
cf0c2881b1 | ||
|
|
9fe96b6e82 | ||
|
|
48084c0051 | ||
|
|
1c057517c2 | ||
|
|
a529f2be86 | ||
|
|
9fdad4d686 | ||
|
|
d56ff32732 | ||
|
|
95dd492555 | ||
|
|
033668373d | ||
|
|
c358a4a6e5 | ||
|
|
565f5cf9c1 | ||
|
|
f4807135e5 | ||
|
|
a96445cdf8 | ||
|
|
858ac8d5ff | ||
|
|
fb060d89af | ||
|
|
682ed056dd | ||
|
|
be2cba2068 | ||
|
|
5bb8a1e341 | ||
|
|
8cbe1bfdd7 | ||
|
|
93d615f0f4 | ||
|
|
17cdb82f15 | ||
|
|
007fa940d9 | ||
|
|
29e82d4985 | ||
|
|
f2fd092e8a | ||
|
|
3f179a25d7 | ||
|
|
eba970dd03 | ||
|
|
21447c461c | ||
|
|
96c203757c | ||
|
|
61b345e577 | ||
|
|
2a027264b3 | ||
|
|
fe4a7d2c5f | ||
|
|
71af5e40a3 | ||
|
|
3e75278052 | ||
|
|
620387f294 | ||
|
|
9dfc4e76ee | ||
|
|
85f0ab899e | ||
|
|
72c76bb95b | ||
|
|
9ea465441b | ||
|
|
3c9d682eb3 | ||
|
|
32de314485 | ||
|
|
3a9211f248 | ||
|
|
26cfdf59c9 | ||
|
|
b6f9013977 | ||
|
|
3133f8cbda | ||
|
|
b66024f26f | ||
|
|
8f1bdc01b6 | ||
|
|
5c3cd2c6ed | ||
|
|
1e68fc887a | ||
|
|
f60a441435 | ||
|
|
f207fdfed0 | ||
|
|
1137924e49 | ||
|
|
4cb40fee4b | ||
|
|
649a4e57c2 | ||
|
|
ca03d81bd9 |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -64,7 +64,7 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 # v2
|
||||
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
|
||||
2
.github/workflows/_reusable-docker-build.yml
vendored
2
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@3de37de96c4e900bc3eef9055d3c50abdb4f769d # v2
|
||||
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
|
||||
@@ -14,6 +14,7 @@ pyproject.toml @goauthentik/backend
|
||||
uv.lock @goauthentik/backend
|
||||
Cargo.toml @goauthentik/backend
|
||||
Cargo.lock @goauthentik/backend
|
||||
build.rs @goauthentik/backend
|
||||
go.mod @goauthentik/backend
|
||||
go.sum @goauthentik/backend
|
||||
.cargo/ @goauthentik/backend
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -198,6 +198,7 @@ dependencies = [
|
||||
"metrics-exporter-prometheus",
|
||||
"nix 0.31.2",
|
||||
"pyo3",
|
||||
"pyo3-build-config",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -216,6 +217,7 @@ dependencies = [
|
||||
"eyre",
|
||||
"forwarded-header-value",
|
||||
"futures",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
@@ -1505,9 +1507,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-unix-socket"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c255628da188a9d9ee218bae99da33a4b684ed63abe140a94d0f6e4b5af9a090"
|
||||
checksum = "88978f1d73da0eb87d86555fcc40cbdd87bc86eb6525710b89db8c9278ec6a59"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
|
||||
@@ -39,7 +39,7 @@ eyre = "= 0.6.12"
|
||||
forwarded-header-value = "= 0.1.1"
|
||||
futures = "= 0.3.32"
|
||||
glob = "= 0.3.3"
|
||||
hyper-unix-socket = "= 0.3.0"
|
||||
hyper-unix-socket = "= 0.6.1"
|
||||
hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
@@ -49,6 +49,7 @@ nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
pyo3-build-config = "= 0.28.3"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.2", features = [
|
||||
"form",
|
||||
@@ -260,6 +261,9 @@ default = ["core", "proxy"]
|
||||
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = ["ak-common/proxy"]
|
||||
|
||||
[build-dependencies]
|
||||
pyo3-build-config.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ak-axum.workspace = true
|
||||
ak-common.workspace = true
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_IMPLICIT = "implicit"
|
||||
GRANT_TYPE_HYBRID = "hybrid"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
GRANT_TYPE_PASSWORD = "password" # nosec
|
||||
|
||||
@@ -30,6 +30,8 @@ SAML_BINDING_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
|
||||
SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
||||
|
||||
DEFAULT_ISSUER = "authentik"
|
||||
|
||||
DSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"
|
||||
RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
|
||||
# https://datatracker.ietf.org/doc/html/rfc4051#section-2.3.2
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Case, Q, QuerySet
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -120,6 +120,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"meta_publisher",
|
||||
"policy_engine_mode",
|
||||
"group",
|
||||
"meta_hide",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"backchannel_providers": {"required": False},
|
||||
@@ -283,14 +284,12 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
) == "true"
|
||||
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
queryset = queryset.exclude(meta_hide=True)
|
||||
if only_with_launch_url:
|
||||
# Pre-filter at DB level to skip expensive per-app policy evaluation
|
||||
# for apps that can never appear in the launcher:
|
||||
# - No meta_launch_url AND no provider: no possible launch URL
|
||||
# - meta_launch_url="blank://blank": documented convention to hide from launcher
|
||||
queryset = queryset.exclude(
|
||||
Q(meta_launch_url="", provider__isnull=True) | Q(meta_launch_url="blank://blank")
|
||||
)
|
||||
# for apps that can never appear in the launcher (no meta_launch_url
|
||||
# and no provider, so no possible launch URL).
|
||||
queryset = queryset.exclude(meta_launch_url="", provider__isnull=True)
|
||||
paginator: Pagination = self.paginator
|
||||
paginated_apps = paginator.paginate_queryset(queryset, request)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
@@ -37,6 +37,77 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
class BulkManyRelatedField(ManyRelatedField):
|
||||
"""ManyRelatedField that validates all PKs in a single query instead of one per PK."""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, str) or not hasattr(data, "__iter__"):
|
||||
self.fail("not_a_list", input_type=type(data).__name__)
|
||||
if not self.allow_empty and len(data) == 0:
|
||||
self.fail("empty")
|
||||
|
||||
child = self.child_relation
|
||||
pk_field = child.pk_field
|
||||
# Coerce PKs through pk_field if defined
|
||||
pk_map = {}
|
||||
for item in data:
|
||||
if isinstance(item, bool):
|
||||
self.fail("incorrect_type", data_type=type(item).__name__)
|
||||
pk = pk_field.to_internal_value(item) if pk_field else item
|
||||
pk_map[pk] = item # map coerced PK -> original value for error reporting
|
||||
|
||||
queryset = child.get_queryset()
|
||||
# Use count to validate all PKs exist in a single query
|
||||
found_count = queryset.filter(pk__in=pk_map.keys()).count()
|
||||
if found_count < len(pk_map):
|
||||
# Some PKs not found — fall back to per-PK checks for error reporting.
|
||||
# This only runs when there's an actual validation error (rare path).
|
||||
for pk, original in pk_map.items():
|
||||
if not queryset.filter(pk=pk).exists():
|
||||
child.fail("does_not_exist", pk_value=original)
|
||||
|
||||
# Return raw PKs — Django's M2M set() accepts both objects and PKs,
|
||||
# using get_prep_value() for type coercion. This avoids loading all
|
||||
# objects into memory and avoids triggering post_init signals.
|
||||
return list(pk_map.keys())
|
||||
|
||||
def to_representation(self, iterable):
|
||||
# For non-prefetched querysets, get PKs directly without loading model instances.
|
||||
# When prefetched, _result_cache is a list (possibly empty); when not, it's None.
|
||||
if hasattr(iterable, "values_list") and getattr(iterable, "_result_cache", None) is None:
|
||||
return list(iterable.values_list("pk", flat=True))
|
||||
return super().to_representation(iterable)
|
||||
|
||||
|
||||
class BulkPrimaryKeyRelatedField(PrimaryKeyRelatedField):
|
||||
"""PrimaryKeyRelatedField that uses bulk validation when many=True."""
|
||||
|
||||
@classmethod
|
||||
def many_init(cls, *args, **kwargs):
|
||||
allow_empty = kwargs.pop("allow_empty", None)
|
||||
max_length = kwargs.pop("max_length", None)
|
||||
min_length = kwargs.pop("min_length", None)
|
||||
child_relation = cls(*args, **kwargs)
|
||||
list_kwargs = {
|
||||
"child_relation": child_relation,
|
||||
}
|
||||
if allow_empty is not None:
|
||||
list_kwargs["allow_empty"] = allow_empty
|
||||
if max_length is not None:
|
||||
list_kwargs["max_length"] = max_length
|
||||
if min_length is not None:
|
||||
list_kwargs["min_length"] = min_length
|
||||
list_kwargs.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key in ("required", "default", "source")
|
||||
}
|
||||
)
|
||||
return BulkManyRelatedField(**list_kwargs)
|
||||
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
@@ -79,6 +150,7 @@ class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
users = BulkPrimaryKeyRelatedField(queryset=User.objects.all(), many=True, default=list)
|
||||
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
||||
parents_obj = SerializerMethodField(allow_null=True)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
@@ -193,9 +265,6 @@ class GroupSerializer(ModelSerializer):
|
||||
"children_obj",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"users": {
|
||||
"default": list,
|
||||
},
|
||||
"children": {
|
||||
"required": False,
|
||||
"default": list,
|
||||
@@ -225,6 +294,7 @@ class GroupFilter(FilterSet):
|
||||
members_by_pk = ModelMultipleChoiceFilter(
|
||||
field_name="users",
|
||||
queryset=User.objects.all(),
|
||||
distinct=False,
|
||||
)
|
||||
|
||||
def filter_attributes(self, queryset, name, value):
|
||||
@@ -276,7 +346,8 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
base_qs = Group.objects.all().prefetch_related("roles")
|
||||
# Always prefetch parents and children since their PKs are always serialized
|
||||
base_qs = Group.objects.all().prefetch_related("roles", "parents", "children")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
@@ -287,16 +358,9 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
)
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_children:
|
||||
base_qs = base_qs.prefetch_related("children")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_parents:
|
||||
base_qs = base_qs.prefetch_related("parents")
|
||||
# When include_users=false, skip users prefetch entirely.
|
||||
# BulkManyRelatedField.to_representation will use values_list to get PKs
|
||||
# directly without loading User instances into memory.
|
||||
|
||||
return base_qs
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import AnonymousUser, Permission
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
@@ -131,7 +132,7 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
class UserSerializer(ModelSerializer):
|
||||
"""User Serializer"""
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
is_superuser = SerializerMethodField()
|
||||
avatar = SerializerMethodField()
|
||||
attributes = JSONDictField(required=False)
|
||||
groups = PrimaryKeyRelatedField(
|
||||
@@ -168,6 +169,14 @@ class UserSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_roles", "true")).lower() == "true"
|
||||
|
||||
@extend_schema_field(BooleanField)
|
||||
def get_is_superuser(self, instance: User) -> bool:
|
||||
"""Use annotation if available to avoid N+1 query"""
|
||||
ann = getattr(instance, "_annotated_is_superuser", None)
|
||||
if ann is not None:
|
||||
return ann
|
||||
return instance.is_superuser
|
||||
|
||||
@extend_schema_field(PartialGroupSerializer(many=True))
|
||||
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
|
||||
if not self._should_include_groups:
|
||||
@@ -541,10 +550,30 @@ class UserViewSet(
|
||||
|
||||
def get_queryset(self):
|
||||
base_qs = User.objects.all().exclude_anonymous()
|
||||
# Always prefetch groups since group PKs are always serialized.
|
||||
# Use full prefetch when include_groups=true (for groups_obj), ID-only otherwise.
|
||||
if self.serializer_class(context={"request": self.request})._should_include_groups:
|
||||
base_qs = base_qs.prefetch_related("groups")
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("groups", queryset=Group.objects.all().only("group_uuid"))
|
||||
)
|
||||
if self.serializer_class(context={"request": self.request})._should_include_roles:
|
||||
base_qs = base_qs.prefetch_related("roles")
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("roles", queryset=Role.objects.all().only("uuid"))
|
||||
)
|
||||
# Annotate is_superuser to avoid N+1 query per user
|
||||
base_qs = base_qs.annotate(
|
||||
_annotated_is_superuser=Exists(
|
||||
Group.objects.filter(
|
||||
is_superuser=True,
|
||||
).filter(
|
||||
Q(users=OuterRef("pk")) | Q(descendant_nodes__descendant__users=OuterRef("pk"))
|
||||
)
|
||||
)
|
||||
)
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
|
||||
33
authentik/core/migrations/0059_add_application_meta_hide.py
Normal file
33
authentik/core/migrations/0059_add_application_meta_hide.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-09 18:04
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_blank_launch_url(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Application = apps.get_model("authentik_core", "Application")
|
||||
|
||||
Application.objects.using(db_alias).filter(meta_launch_url="blank://blank").update(
|
||||
meta_hide=True, meta_launch_url=""
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0058_setup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="meta_hide",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Hide this application from the user's My applications page.",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_blank_launch_url, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -735,6 +735,9 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
meta_icon = FileField(default="", blank=True)
|
||||
meta_description = models.TextField(default="", blank=True)
|
||||
meta_publisher = models.TextField(default="", blank=True)
|
||||
meta_hide = models.BooleanField(
|
||||
default=False, help_text=_("Hide this application from the user's My applications page.")
|
||||
)
|
||||
|
||||
objects = ApplicationQuerySet.as_manager()
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
"meta_description": "",
|
||||
"meta_hide": False,
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
},
|
||||
@@ -187,12 +188,14 @@ class TestApplicationsAPI(APITestCase):
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
"meta_description": "",
|
||||
"meta_hide": False,
|
||||
"meta_publisher": "",
|
||||
"policy_engine_mode": "any",
|
||||
},
|
||||
{
|
||||
"launch_url": None,
|
||||
"meta_description": "",
|
||||
"meta_hide": False,
|
||||
"meta_icon": "",
|
||||
"meta_icon_url": None,
|
||||
"meta_icon_themed_urls": None,
|
||||
|
||||
@@ -65,6 +65,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"grant_types",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"access_code_validity",
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.oauth2.models import GrantTypes, RedirectURI
|
||||
from authentik.providers.oauth2.models import GrantType, RedirectURI
|
||||
|
||||
|
||||
class OAuth2Error(SentryIgnoredException):
|
||||
@@ -182,7 +182,7 @@ class AuthorizeError(OAuth2Error):
|
||||
# See:
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
|
||||
fragment_or_query = (
|
||||
"#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?"
|
||||
"#" if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID] else "?"
|
||||
)
|
||||
|
||||
uri = (
|
||||
@@ -225,7 +225,7 @@ class TokenError(OAuth2Error):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, error):
|
||||
def __init__(self, error: str):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self.errors[error]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-17 11:04
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_default_grant_types():
|
||||
from authentik.providers.oauth2.models import GrantType
|
||||
|
||||
return [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.HYBRID,
|
||||
GrantType.IMPLICIT,
|
||||
GrantType.CLIENT_CREDENTIALS,
|
||||
GrantType.PASSWORD,
|
||||
GrantType.DEVICE_CODE,
|
||||
GrantType.REFRESH_TOKEN,
|
||||
]
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_providers_oauth2",
|
||||
"0031_remove_oauth2provider_backchannel_logout_uri_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="grant_types",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("authorization_code", "Authorization Code"),
|
||||
("implicit", "Implicit"),
|
||||
("hybrid", "Hybrid"),
|
||||
("refresh_token", "Refresh Token"),
|
||||
("client_credentials", "Client Credentials"),
|
||||
("password", "Password"),
|
||||
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
|
||||
]
|
||||
),
|
||||
default=migrate_default_grant_types,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="grant_types",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("authorization_code", "Authorization Code"),
|
||||
("implicit", "Implicit"),
|
||||
("hybrid", "Hybrid"),
|
||||
("refresh_token", "Refresh Token"),
|
||||
("client_credentials", "Client Credentials"),
|
||||
("password", "Password"),
|
||||
("urn:ietf:params:oauth:grant-type:device_code", "Device Code"),
|
||||
]
|
||||
),
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||
from dacite import Config
|
||||
from dacite.core import from_dict
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import HashIndex
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -33,7 +34,16 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import WebfingerProvider
|
||||
from authentik.common.oauth.constants import SubModes
|
||||
from authentik.common.oauth.constants import (
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
GRANT_TYPE_HYBRID,
|
||||
GRANT_TYPE_IMPLICIT,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
SubModes,
|
||||
)
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
@@ -58,7 +68,7 @@ def generate_client_secret() -> str:
|
||||
return generate_id(128)
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
class ClientType(models.TextChoices):
|
||||
"""Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable."""
|
||||
|
||||
@@ -66,12 +76,16 @@ class ClientTypes(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
|
||||
|
||||
class GrantTypes(models.TextChoices):
|
||||
class GrantType(models.TextChoices):
|
||||
"""OAuth2 Grant types we support"""
|
||||
|
||||
AUTHORIZATION_CODE = "authorization_code"
|
||||
IMPLICIT = "implicit"
|
||||
HYBRID = "hybrid"
|
||||
AUTHORIZATION_CODE = GRANT_TYPE_AUTHORIZATION_CODE
|
||||
IMPLICIT = GRANT_TYPE_IMPLICIT
|
||||
HYBRID = GRANT_TYPE_HYBRID
|
||||
REFRESH_TOKEN = GRANT_TYPE_REFRESH_TOKEN
|
||||
CLIENT_CREDENTIALS = GRANT_TYPE_CLIENT_CREDENTIALS
|
||||
PASSWORD = GRANT_TYPE_PASSWORD
|
||||
DEVICE_CODE = GRANT_TYPE_DEVICE_CODE
|
||||
|
||||
|
||||
class ResponseMode(models.TextChoices):
|
||||
@@ -188,14 +202,15 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
|
||||
client_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=ClientTypes.choices,
|
||||
default=ClientTypes.CONFIDENTIAL,
|
||||
choices=ClientType.choices,
|
||||
default=ClientType.CONFIDENTIAL,
|
||||
verbose_name=_("Client Type"),
|
||||
help_text=_(
|
||||
"Confidential clients are capable of maintaining the confidentiality "
|
||||
"of their credentials. Public clients are incapable"
|
||||
),
|
||||
)
|
||||
grant_types = ArrayField(models.TextField(choices=GrantType.choices), default=list)
|
||||
client_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
|
||||
@@ -22,7 +22,7 @@ from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, Red
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -41,12 +41,34 @@ class TestAuthorize(OAuthTestCase):
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_disallowed_grant_type(self):
|
||||
"""Test with disallowed grant type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
grant_types=[],
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://local.invalid/Foo",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
self.assertEqual(cm.exception.error, "invalid_request")
|
||||
|
||||
def test_invalid_grant_type(self):
|
||||
"""Test with invalid grant type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
@@ -74,6 +96,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
@@ -188,6 +211,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.REGEX, ".+")],
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
@@ -206,6 +230,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
@@ -227,12 +252,14 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).redirect_uri,
|
||||
"http://local.invalid/Foo",
|
||||
)
|
||||
provider.grant_types = [GrantType.IMPLICIT]
|
||||
provider.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
@@ -246,7 +273,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.IMPLICIT,
|
||||
GrantType.IMPLICIT,
|
||||
)
|
||||
# Implicit without openid scope
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
@@ -261,8 +288,10 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type,
|
||||
GrantTypes.IMPLICIT,
|
||||
GrantType.IMPLICIT,
|
||||
)
|
||||
provider.grant_types = [GrantType.HYBRID]
|
||||
provider.save()
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
@@ -274,7 +303,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantTypes.HYBRID
|
||||
OAuthAuthorizationParams.from_request(request).grant_type, GrantType.HYBRID
|
||||
)
|
||||
with self.assertRaises(AuthorizeError) as cm:
|
||||
request = self.factory.get(
|
||||
@@ -297,6 +326,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -333,6 +363,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -404,6 +435,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -466,6 +498,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -515,6 +548,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -572,6 +606,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
signing_key=self.keypair,
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
state = generate_id()
|
||||
@@ -612,6 +647,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
)
|
||||
request = self.factory.get(
|
||||
@@ -635,6 +671,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
|
||||
grant_types=[GrantType.IMPLICIT],
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
@@ -667,6 +704,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -697,6 +735,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -736,6 +775,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authentication_flow=auth_flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -762,6 +802,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 DeviceToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
@@ -42,6 +43,21 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_backchannel_invalid_no_grant(self):
|
||||
"""Test backchannel"""
|
||||
self.provider.grant_types = []
|
||||
self.provider.save()
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:device"),
|
||||
data={
|
||||
"client_id": "test",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_backchannel_invalid_no_app(self):
|
||||
"""Test backchannel"""
|
||||
# test without application
|
||||
self.application.provider = None
|
||||
self.application.save()
|
||||
|
||||
@@ -9,7 +9,7 @@ from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
@@ -22,6 +22,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
|
||||
@@ -14,7 +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,
|
||||
ClientType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -173,7 +173,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
|
||||
def test_introspect_provider_public(self):
|
||||
"""Test introspect"""
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.client_type = ClientType.PUBLIC
|
||||
self.provider.save()
|
||||
token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
@@ -208,7 +208,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
client_type=ClientTypes.PUBLIC,
|
||||
client_type=ClientType.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
@@ -126,7 +126,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
|
||||
def test_revoke_public(self):
|
||||
"""Test revoke public client"""
|
||||
self.provider.client_type = ClientTypes.PUBLIC
|
||||
self.provider.client_type = ClientType.PUBLIC
|
||||
self.provider.save()
|
||||
token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
@@ -241,7 +241,7 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
|
||||
signing_key=create_test_cert(),
|
||||
client_type=ClientTypes.PUBLIC,
|
||||
client_type=ClientType.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
@@ -270,14 +270,14 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
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.client_type = ClientType.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,
|
||||
client_type=ClientType.PUBLIC,
|
||||
)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=other_provider)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -44,11 +45,39 @@ class TestToken(OAuthTestCase):
|
||||
self.factory = RequestFactory()
|
||||
self.app = Application.objects.create(name=generate_id(), slug="test")
|
||||
|
||||
def test_invalid_grant_type(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, auth_time=timezone.now()
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
"code": code.code,
|
||||
"redirect_uri": "http://TestServer",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
with self.assertRaises(TokenError) as cm:
|
||||
TokenParams.parse(request, provider, provider.client_id, provider.client_secret)
|
||||
self.assertEqual(cm.exception.cause, "grant_type_not_configured")
|
||||
|
||||
def test_request_auth_code(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -76,6 +105,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -97,6 +127,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -139,6 +170,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -179,6 +211,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
encryption_key=self.keypair,
|
||||
@@ -210,6 +243,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -271,6 +305,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -328,6 +363,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
@@ -400,6 +436,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.REFRESH_TOKEN],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
refresh_token_threshold="hours=1", # nosec
|
||||
@@ -497,6 +534,7 @@ class TestToken(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
|
||||
signing_key=self.keypair,
|
||||
include_claims_in_id_token=True,
|
||||
|
||||
@@ -22,6 +22,7 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -55,6 +56,7 @@ class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS],
|
||||
)
|
||||
self.provider.jwt_federation_providers.add(self.other_provider)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
||||
@@ -20,6 +20,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -68,6 +69,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=self.cert,
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS],
|
||||
)
|
||||
self.provider.jwt_federation_sources.add(self.source)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
|
||||
@@ -21,6 +21,7 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -41,6 +42,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -22,6 +22,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -42,6 +43,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -25,6 +25,7 @@ from authentik.core.tests.utils import (
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -45,6 +46,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.CLIENT_CREDENTIALS, GrantType.PASSWORD],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -17,6 +17,7 @@ from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
DeviceToken,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -37,6 +38,7 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
|
||||
signing_key=create_test_cert(),
|
||||
grant_types=[GrantType.DEVICE_CODE],
|
||||
)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
@@ -11,6 +11,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -37,6 +38,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -95,6 +97,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -151,6 +154,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@@ -196,6 +200,7 @@ class TestTokenPKCE(OAuthTestCase):
|
||||
authorization_flow=flow,
|
||||
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
|
||||
access_code_validity="seconds=100",
|
||||
grant_types=[GrantType.AUTHORIZATION_CODE],
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
|
||||
@@ -57,7 +57,7 @@ from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
ResponseMode,
|
||||
@@ -164,28 +164,31 @@ class OAuthAuthorizationParams:
|
||||
"""Check grant"""
|
||||
# Determine which flow to use.
|
||||
if self.response_type in [ResponseTypes.CODE]:
|
||||
self.grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
self.grant_type = GrantType.AUTHORIZATION_CODE
|
||||
elif self.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.IMPLICIT
|
||||
self.grant_type = GrantType.IMPLICIT
|
||||
elif self.response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
self.grant_type = GrantTypes.HYBRID
|
||||
|
||||
self.grant_type = GrantType.HYBRID
|
||||
# Grant type validation.
|
||||
if not self.grant_type:
|
||||
LOGGER.warning("Invalid response type", type=self.response_type)
|
||||
raise AuthorizeError(self.redirect_uri, "unsupported_response_type", "", self.state)
|
||||
|
||||
if self.grant_type not in self.provider.grant_types:
|
||||
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
|
||||
|
||||
if self.response_mode not in ResponseMode.values:
|
||||
self.response_mode = ResponseMode.QUERY
|
||||
|
||||
if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
if self.grant_type in [GrantType.IMPLICIT, GrantType.HYBRID]:
|
||||
self.response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
def check_redirect_uri(self):
|
||||
@@ -246,7 +249,7 @@ class OAuthAuthorizationParams:
|
||||
)
|
||||
self.scope = self.scope.intersection(default_scope_names)
|
||||
if SCOPE_OPENID not in self.scope and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
self.grant_type == GrantType.HYBRID
|
||||
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
@@ -597,8 +600,8 @@ class OAuthFulfillmentStage(StageView):
|
||||
code = None
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save()
|
||||
@@ -613,7 +616,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = {}
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
query_fragment["code"] = code.code
|
||||
query_fragment["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -627,7 +630,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
post_params = {}
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
if self.params.grant_type in [GrantType.AUTHORIZATION_CODE]:
|
||||
post_params["code"] = code.code
|
||||
post_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
@@ -696,7 +699,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
if self.params.grant_type == GrantType.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = TOKEN_TYPE
|
||||
|
||||
@@ -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, ScopeMapping
|
||||
from authentik.providers.oauth2.models import DeviceToken, GrantType, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
@@ -42,6 +42,8 @@ class DeviceView(View):
|
||||
_ = provider.application
|
||||
except Application.DoesNotExist:
|
||||
raise DeviceCodeError("invalid_client") from None
|
||||
if GrantType.DEVICE_CODE not in provider.grant_types:
|
||||
raise DeviceCodeError("invalid_client")
|
||||
self.provider = provider
|
||||
self.client_id = client_id
|
||||
|
||||
|
||||
@@ -11,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, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -45,7 +45,7 @@ class TokenIntrospectionParams:
|
||||
if not provider:
|
||||
LOGGER.info("Failed to authenticate introspection request")
|
||||
raise TokenIntrospectionError
|
||||
if provider.client_type != ClientTypes.CONFIDENTIAL:
|
||||
if provider.client_type != ClientType.CONFIDENTIAL:
|
||||
LOGGER.info("Introspection request from public provider, denying.")
|
||||
raise TokenIntrospectionError
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -165,6 +165,10 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type not in self.provider.grant_types:
|
||||
LOGGER.warning("Invalid grant_type for provider", grant_type=self.grant_type)
|
||||
raise TokenError("invalid_grant").with_cause("grant_type_not_configured")
|
||||
|
||||
# Confidential clients MUST authenticate to the token endpoint per
|
||||
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
|
||||
# that requirement - the device_code alone is not a substitute for
|
||||
@@ -174,7 +178,7 @@ class TokenParams:
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
GRANT_TYPE_DEVICE_CODE,
|
||||
]:
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
if self.provider.client_type == ClientType.CONFIDENTIAL and not compare_digest(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
LOGGER.warning(
|
||||
@@ -606,10 +610,10 @@ class TokenView(View):
|
||||
if not self.provider:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
self.params = self.params_class.parse(
|
||||
request, self.provider, client_id, client_secret
|
||||
)
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
|
||||
with start_span(
|
||||
op="authentik.providers.oauth2.post.response",
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenRevocationError
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, ClientType, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import (
|
||||
TokenResponse,
|
||||
authenticate_provider,
|
||||
@@ -33,11 +33,13 @@ class TokenRevocationParams:
|
||||
raw_token = request.POST.get("token")
|
||||
|
||||
provider, _, _ = provider_from_request(request)
|
||||
if provider and provider.client_type == ClientType.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:
|
||||
if provider.client_type == ClientType.CONFIDENTIAL:
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
|
||||
@@ -16,7 +16,8 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
ClientType,
|
||||
GrantType,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
@@ -161,7 +162,12 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.grant_types = [
|
||||
GrantType.AUTHORIZATION_CODE,
|
||||
GrantType.CLIENT_CREDENTIALS,
|
||||
GrantType.PASSWORD,
|
||||
]
|
||||
self.client_type = ClientType.CONFIDENTIAL
|
||||
self.signing_key = None
|
||||
self.include_claims_in_id_token = True
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
|
||||
@@ -9,7 +9,7 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.models import ClientTypes
|
||||
from authentik.providers.oauth2.models import ClientType
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
|
||||
def test_update_defaults(self):
|
||||
"""Test create"""
|
||||
@@ -114,8 +114,8 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
provider.client_type = ClientTypes.PUBLIC
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
provider.client_type = ClientType.PUBLIC
|
||||
provider.save()
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:proxyprovider-detail", kwargs={"pk": provider.pk}),
|
||||
@@ -130,7 +130,7 @@ class ProxyProviderTests(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
self.assertEqual(provider.client_type, ClientType.CONFIDENTIAL)
|
||||
|
||||
def test_sa_fetch(self):
|
||||
"""Test fetching the outpost config as the service account"""
|
||||
|
||||
@@ -24,7 +24,11 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.common.saml.constants import SAML_BINDING_POST, SAML_BINDING_REDIRECT
|
||||
from authentik.common.saml.constants import (
|
||||
DEFAULT_ISSUER,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
)
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
|
||||
@@ -55,7 +59,13 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
url_download_metadata = SerializerMethodField()
|
||||
url_issuer = SerializerMethodField()
|
||||
|
||||
# Unified SAML endpoint (primary)
|
||||
url_unified = SerializerMethodField()
|
||||
url_unified_init = SerializerMethodField()
|
||||
|
||||
# Legacy endpoints (for backward compatibility)
|
||||
url_sso_post = SerializerMethodField()
|
||||
url_sso_redirect = SerializerMethodField()
|
||||
url_sso_init = SerializerMethodField()
|
||||
@@ -85,6 +95,53 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
+ "?download"
|
||||
)
|
||||
|
||||
def get_url_issuer(self, instance: SAMLProvider) -> str:
|
||||
"""Get Issuer/EntityID URL"""
|
||||
if instance.issuer_override:
|
||||
return instance.issuer_override
|
||||
if "request" not in self._context:
|
||||
return DEFAULT_ISSUER
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
def get_url_unified(self, instance: SAMLProvider) -> str:
|
||||
"""Get unified SAML endpoint URL (handles SSO and SLO)"""
|
||||
if "request" not in self._context:
|
||||
return ""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
|
||||
def get_url_unified_init(self, instance: SAMLProvider) -> str:
|
||||
"""Get IdP-initiated SAML URL"""
|
||||
if "request" not in self._context:
|
||||
return ""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:init",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
|
||||
def get_url_sso_post(self, instance: SAMLProvider) -> str:
|
||||
"""Get SSO Post URL"""
|
||||
if "request" not in self._context:
|
||||
@@ -198,7 +255,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"acs_url",
|
||||
"sls_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
"issuer_override",
|
||||
"assertion_valid_not_before",
|
||||
"assertion_valid_not_on_or_after",
|
||||
"session_valid_not_on_or_after",
|
||||
@@ -220,6 +277,9 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"default_relay_state",
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_issuer",
|
||||
"url_unified",
|
||||
"url_unified_init",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
"url_sso_init",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-24 06:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0021_samlprovider_sign_logout_response"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="samlprovider",
|
||||
old_name="issuer",
|
||||
new_name="issuer_override",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="issuer_override",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Also known as EntityID. Providing a value overrides the default issuer generated by authentik.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsession",
|
||||
name="issuer",
|
||||
field=models.TextField(
|
||||
default=None, help_text="SAML Issuer used for this session", null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -77,7 +77,14 @@ class SAMLProvider(Provider):
|
||||
"no audience restriction will be added."
|
||||
),
|
||||
)
|
||||
issuer = models.TextField(help_text=_("Also known as EntityID"), default="authentik")
|
||||
issuer_override = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=_(
|
||||
"Also known as EntityID. Providing a value overrides the default issuer "
|
||||
"generated by authentik."
|
||||
),
|
||||
)
|
||||
sls_url = models.TextField(
|
||||
blank=True,
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||
@@ -234,7 +241,7 @@ class SAMLProvider(Provider):
|
||||
"""Use IDP-Initiated SAML flow as launch URL"""
|
||||
try:
|
||||
return reverse(
|
||||
"authentik_providers_saml:sso-init",
|
||||
"authentik_providers_saml:init",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
@@ -318,6 +325,9 @@ class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
|
||||
session_index = models.TextField(help_text=_("SAML SessionIndex for this session"))
|
||||
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
|
||||
name_id_format = models.TextField(default="", blank=True, help_text=_("SAML NameID format"))
|
||||
issuer = models.TextField(
|
||||
default=None, null=True, help_text=_("SAML Issuer used for this session")
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
|
||||
@@ -6,6 +6,7 @@ from types import GeneratorType
|
||||
|
||||
import xmlsec
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement, _Element # nosec
|
||||
@@ -63,6 +64,7 @@ class AssertionProcessor:
|
||||
session_index: str
|
||||
name_id: str
|
||||
name_id_format: str
|
||||
issuer: str
|
||||
session_not_on_or_after_datetime: datetime
|
||||
|
||||
def __init__(self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest):
|
||||
@@ -137,10 +139,24 @@ class AssertionProcessor:
|
||||
continue
|
||||
return attribute_statement
|
||||
|
||||
def _get_issuer_value(self) -> str:
|
||||
"""Get issuer value, with fallback to generated URL if empty"""
|
||||
# If user has set an override issuer, use it
|
||||
if self.provider.issuer_override:
|
||||
return self.provider.issuer_override
|
||||
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer Element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
|
||||
issuer.text = self.provider.issuer
|
||||
self.issuer = self._get_issuer_value()
|
||||
issuer.text = self.issuer
|
||||
return issuer
|
||||
|
||||
def get_assertion_auth_n_statement(self) -> Element:
|
||||
|
||||
@@ -8,6 +8,7 @@ from lxml import etree # nosec
|
||||
from lxml.etree import Element, _Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DEFAULT_ISSUER,
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
@@ -33,11 +34,12 @@ class LogoutRequestProcessor:
|
||||
name_id_format: str
|
||||
session_index: str | None
|
||||
relay_state: str | None
|
||||
issuer: str | None
|
||||
|
||||
_issue_instant: str
|
||||
_request_id: str
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
provider: SAMLProvider,
|
||||
user: User | None,
|
||||
@@ -46,6 +48,7 @@ class LogoutRequestProcessor:
|
||||
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index: str | None = None,
|
||||
relay_state: str | None = None,
|
||||
issuer: str | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.user = user
|
||||
@@ -54,14 +57,23 @@ class LogoutRequestProcessor:
|
||||
self.name_id_format = name_id_format
|
||||
self.session_index = session_index
|
||||
self.relay_state = relay_state
|
||||
self.issuer = issuer
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._request_id = get_random_id()
|
||||
|
||||
def _get_issuer_value(self) -> str:
|
||||
"""Get issuer value from session, with fallback to provider"""
|
||||
if self.issuer:
|
||||
return self.issuer
|
||||
if self.provider.issuer_override:
|
||||
return self.provider.issuer_override
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.provider.issuer
|
||||
issuer.text = self._get_issuer_value()
|
||||
return issuer
|
||||
|
||||
def get_name_id(self) -> Element:
|
||||
|
||||
@@ -8,6 +8,7 @@ from lxml import etree
|
||||
from lxml.etree import Element, SubElement
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DEFAULT_ISSUER,
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
@@ -28,27 +29,38 @@ class LogoutResponseProcessor:
|
||||
logout_request: LogoutRequest
|
||||
destination: str | None
|
||||
relay_state: str | None
|
||||
issuer: str | None
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
provider: SAMLProvider,
|
||||
logout_request: LogoutRequest,
|
||||
destination: str | None = None,
|
||||
relay_state: str | None = None,
|
||||
issuer: str | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.logout_request = logout_request
|
||||
self.destination = destination
|
||||
self.relay_state = relay_state or (logout_request.relay_state if logout_request else None)
|
||||
self.issuer = issuer
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def _get_issuer_value(self) -> str:
|
||||
"""Get issuer value from session, with fallback to provider"""
|
||||
if self.issuer:
|
||||
return self.issuer
|
||||
if self.provider.issuer_override:
|
||||
return self.provider.issuer_override
|
||||
return DEFAULT_ISSUER
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.provider.issuer
|
||||
issuer.text = self._get_issuer_value()
|
||||
return issuer
|
||||
|
||||
def build(self, status: str = "Success") -> Element:
|
||||
|
||||
@@ -40,6 +40,19 @@ class MetadataProcessor:
|
||||
self.force_binding = None
|
||||
self.xml_id = "_" + sha256(f"{provider.name}-{provider.pk}".encode("ascii")).hexdigest()
|
||||
|
||||
def _get_issuer_value(self) -> str:
|
||||
"""Get issuer value, with fallback to generated URL if empty"""
|
||||
# If user has set an override issuer, use it
|
||||
if self.provider.issuer_override:
|
||||
return self.provider.issuer_override
|
||||
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
|
||||
# Using type unions doesn't work with cython types (which is what lxml is)
|
||||
def get_signing_key_descriptor(self) -> Element | None:
|
||||
"""Get Signing KeyDescriptor, if enabled for the provider"""
|
||||
@@ -68,54 +81,35 @@ class MetadataProcessor:
|
||||
element.text = name_id_format
|
||||
yield element
|
||||
|
||||
def _get_unified_url(self) -> str:
|
||||
"""Get the unified SAML endpoint URL"""
|
||||
return self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:base",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
)
|
||||
|
||||
def get_sso_bindings(self) -> Iterator[Element]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
(SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
(SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding_svc, url in binding_url_map.items():
|
||||
binding, svc = binding_svc
|
||||
"""Get all SSO Bindings - both point to unified endpoint"""
|
||||
unified_url = self._get_unified_url()
|
||||
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
|
||||
if self.force_binding and self.force_binding != binding:
|
||||
continue
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = url
|
||||
element.attrib["Location"] = unified_url
|
||||
yield element
|
||||
|
||||
def get_slo_bindings(self) -> Iterator[Element]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
(SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:slo-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
(SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:slo-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding_svc, url in binding_url_map.items():
|
||||
binding, svc = binding_svc
|
||||
"""Get all SLO Bindings - both point to unified endpoint"""
|
||||
unified_url = self._get_unified_url()
|
||||
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
|
||||
if self.force_binding and self.force_binding != binding:
|
||||
continue
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = url
|
||||
element.attrib["Location"] = unified_url
|
||||
yield element
|
||||
|
||||
def _prepare_signature(self, entity_descriptor: _Element):
|
||||
@@ -189,7 +183,7 @@ class MetadataProcessor:
|
||||
"""Build full EntityDescriptor"""
|
||||
entity_descriptor = Element(f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP)
|
||||
entity_descriptor.attrib["ID"] = self.xml_id
|
||||
entity_descriptor.attrib["entityID"] = self.provider.issuer
|
||||
entity_descriptor.attrib["entityID"] = self._get_issuer_value()
|
||||
|
||||
if self.provider.signing_kp:
|
||||
self._prepare_signature(entity_descriptor)
|
||||
|
||||
@@ -51,7 +51,6 @@ class ServiceProviderMetadata:
|
||||
provider = SAMLProvider.objects.create(
|
||||
name=name, authorization_flow=authorization_flow, invalidation_flow=invalidation_flow
|
||||
)
|
||||
provider.issuer = self.entity_id
|
||||
provider.sp_binding = self.acs_binding
|
||||
provider.acs_url = self.acs_location
|
||||
provider.default_name_id_policy = self.name_id_policy
|
||||
|
||||
@@ -75,6 +75,7 @@ def handle_saml_iframe_pre_user_logout(
|
||||
name_id_format=session.name_id_format,
|
||||
session_index=session.session_index,
|
||||
relay_state=relay_state,
|
||||
issuer=session.issuer,
|
||||
)
|
||||
|
||||
if session.provider.sls_binding == SAMLBindings.POST:
|
||||
@@ -163,6 +164,7 @@ def handle_flow_pre_user_logout(
|
||||
name_id_format=session.name_id_format,
|
||||
session_index=session.session_index,
|
||||
relay_state=relay_state,
|
||||
issuer=session.issuer,
|
||||
)
|
||||
|
||||
if session.provider.sls_binding == SAMLBindings.POST:
|
||||
@@ -224,6 +226,7 @@ def user_session_deleted_saml_logout(sender, instance: AuthenticatedSession, **_
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
issuer=saml_session.issuer,
|
||||
)
|
||||
|
||||
|
||||
@@ -257,4 +260,5 @@ def user_deactivated_saml_logout(sender, instance: User, **kwargs):
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
issuer=saml_session.issuer,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ def send_saml_logout_request(
|
||||
name_id: str,
|
||||
name_id_format: str,
|
||||
session_index: str,
|
||||
issuer: str,
|
||||
):
|
||||
"""Send SAML LogoutRequest to a Service Provider using session data"""
|
||||
provider = SAMLProvider.objects.filter(pk=provider_pk).first()
|
||||
@@ -47,6 +48,7 @@ def send_saml_logout_request(
|
||||
name_id=name_id,
|
||||
name_id_format=name_id_format,
|
||||
session_index=session_index,
|
||||
issuer=issuer,
|
||||
)
|
||||
|
||||
return send_post_logout_request(provider, processor)
|
||||
@@ -89,6 +91,7 @@ def send_saml_logout_response(
|
||||
sls_url: str,
|
||||
logout_request_id: str | None = None,
|
||||
relay_state: str | None = None,
|
||||
issuer: str | None = None,
|
||||
):
|
||||
"""Send SAML LogoutResponse to a Service Provider using backchannel (server-to-server)"""
|
||||
provider = SAMLProvider.objects.filter(pk=provider_pk).first()
|
||||
@@ -119,6 +122,7 @@ def send_saml_logout_response(
|
||||
logout_request=logout_request,
|
||||
destination=sls_url,
|
||||
relay_state=relay_state,
|
||||
issuer=issuer,
|
||||
)
|
||||
|
||||
encoded_response = processor.encode_post()
|
||||
|
||||
@@ -15,6 +15,7 @@ from authentik.common.saml.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import (
|
||||
RequestFactory,
|
||||
create_test_admin_user,
|
||||
@@ -97,6 +98,11 @@ class TestAuthNRequest(TestCase):
|
||||
)
|
||||
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||
self.provider.save()
|
||||
Application.objects.create(
|
||||
name="test-app",
|
||||
slug="test-app",
|
||||
provider=self.provider,
|
||||
)
|
||||
self.source = SAMLSource.objects.create(
|
||||
slug="provider",
|
||||
issuer="authentik",
|
||||
@@ -526,7 +532,7 @@ class TestAuthNRequest(TestCase):
|
||||
authorization_flow=create_test_flow(),
|
||||
acs_url="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
audience="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
issuer="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
signing_kp=static_keypair,
|
||||
verification_kp=static_keypair,
|
||||
)
|
||||
@@ -547,7 +553,7 @@ class TestAuthNRequest(TestCase):
|
||||
"saml/acs/2d737f96-55fb-4035-953e-5e24134eb778"
|
||||
),
|
||||
audience="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
issuer="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
issuer_override="https://10.120.20.200/saml-sp/SAML2/POST",
|
||||
signing_kp=create_test_cert(),
|
||||
)
|
||||
parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST)
|
||||
|
||||
@@ -47,7 +47,7 @@ class TestNativeLogoutStageView(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp1.example.com/acs",
|
||||
sls_url="https://sp1.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE,
|
||||
@@ -58,7 +58,7 @@ class TestNativeLogoutStageView(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp2.example.com/acs",
|
||||
sls_url="https://sp2.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="post",
|
||||
sls_binding="post",
|
||||
logout_method=SAMLLogoutMethods.FRONTCHANNEL_NATIVE,
|
||||
@@ -218,7 +218,7 @@ class TestIframeLogoutStageView(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp1.example.com/acs",
|
||||
sls_url="https://sp1.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
logout_method="frontchannel_iframe",
|
||||
@@ -229,7 +229,7 @@ class TestIframeLogoutStageView(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp2.example.com/acs",
|
||||
sls_url="https://sp2.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="post",
|
||||
sls_binding="post",
|
||||
logout_method="frontchannel_iframe",
|
||||
@@ -372,7 +372,7 @@ class TestIdPLogoutIntegration(FlowTestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
signing_kp=self.keypair,
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestLogoutIntegration(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
signature_algorithm=RSA_SHA256,
|
||||
@@ -57,7 +57,7 @@ class TestLogoutIntegration(TestCase):
|
||||
parsed = self.parser.parse(encoded)
|
||||
|
||||
# Verify all fields match
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(parsed.name_id, "test@example.com")
|
||||
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
self.assertEqual(parsed.session_index, "test-session-123")
|
||||
@@ -72,7 +72,7 @@ class TestLogoutIntegration(TestCase):
|
||||
parsed = self.parser.parse_detached(encoded)
|
||||
|
||||
# Verify all fields match
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(parsed.name_id, "test@example.com")
|
||||
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
self.assertEqual(parsed.session_index, "test-session-123")
|
||||
@@ -106,7 +106,7 @@ class TestLogoutIntegration(TestCase):
|
||||
parsed = parser.parse(encoded)
|
||||
|
||||
# Verify all fields match
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(parsed.name_id, "signed@example.com")
|
||||
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
self.assertEqual(parsed.session_index, "signed-session-456")
|
||||
@@ -125,7 +125,7 @@ class TestLogoutIntegration(TestCase):
|
||||
parsed = self.parser.parse_detached(saml_request)
|
||||
|
||||
# Verify parsing succeeded
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(parsed.name_id, "test@example.com")
|
||||
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
|
||||
@@ -164,7 +164,7 @@ class TestLogoutIntegration(TestCase):
|
||||
|
||||
# Parse the SAMLRequest (unsigned XML)
|
||||
parsed = self.parser.parse_detached(params["SAMLRequest"][0])
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
|
||||
def test_form_data_can_be_parsed(self):
|
||||
"""Test that form data generates parseable POST request"""
|
||||
@@ -175,7 +175,7 @@ class TestLogoutIntegration(TestCase):
|
||||
parsed = self.parser.parse(form_data["SAMLRequest"])
|
||||
|
||||
# Verify parsing succeeded
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(parsed.name_id, "test@example.com")
|
||||
self.assertEqual(parsed.name_id_format, SAML_NAME_ID_FORMAT_EMAIL)
|
||||
self.assertEqual(parsed.session_index, "test-session-123")
|
||||
@@ -244,4 +244,4 @@ class TestLogoutIntegration(TestCase):
|
||||
|
||||
# But same issuer
|
||||
self.assertEqual(parsed1.issuer, parsed2.issuer)
|
||||
self.assertEqual(parsed1.issuer, self.provider.issuer)
|
||||
self.assertEqual(parsed1.issuer, self.provider.issuer_override)
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestLogoutRequestProcessor(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
signature_algorithm=RSA_SHA256,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""logout response tests"""
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.common.saml.constants import (
|
||||
@@ -9,10 +9,13 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
)
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
|
||||
|
||||
class TestLogoutResponse(TestCase):
|
||||
@@ -21,6 +24,7 @@ class TestLogoutResponse(TestCase):
|
||||
@apply_blueprint("system/providers-saml.yaml")
|
||||
def setUp(self):
|
||||
cert = create_test_cert()
|
||||
self.factory = RequestFactory()
|
||||
self.provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
authorization_flow=create_test_flow(),
|
||||
acs_url="http://testserver/source/saml/provider/acs/",
|
||||
@@ -30,17 +34,31 @@ class TestLogoutResponse(TestCase):
|
||||
)
|
||||
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||
self.provider.save()
|
||||
self.application = Application.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
provider=self.provider,
|
||||
)
|
||||
|
||||
def test_build_response(self):
|
||||
"""Test building a LogoutResponse"""
|
||||
"""Test building a LogoutResponse uses the generated issuer from the assertion"""
|
||||
# Generate the issuer the same way the assertion/metadata processors would
|
||||
request = self.factory.get("/")
|
||||
metadata_processor = MetadataProcessor(self.provider, request)
|
||||
generated_issuer = metadata_processor._get_issuer_value()
|
||||
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="test-relay-state",
|
||||
)
|
||||
|
||||
# Pass the generated issuer as if it came from SAMLSession.issuer
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider, logout_request, destination=self.provider.sls_url
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
issuer=generated_issuer,
|
||||
)
|
||||
response_xml = processor.build_response(status="Success")
|
||||
|
||||
@@ -51,9 +69,9 @@ class TestLogoutResponse(TestCase):
|
||||
self.assertEqual(root.attrib["Destination"], self.provider.sls_url)
|
||||
self.assertEqual(root.attrib["InResponseTo"], "test-request-id")
|
||||
|
||||
# Check Issuer
|
||||
# Check Issuer matches the generated issuer from the assertion processor
|
||||
issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
self.assertEqual(issuer.text, self.provider.issuer)
|
||||
self.assertEqual(issuer.text, generated_issuer)
|
||||
|
||||
# Check Status
|
||||
status = root.find(f".//{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
|
||||
@@ -85,7 +85,6 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/simple.xml"))
|
||||
provider = metadata.to_provider("test", self.flow, self.flow)
|
||||
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
|
||||
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
|
||||
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||
self.assertEqual(provider.default_name_id_policy, SAMLNameIDPolicy.EMAIL)
|
||||
self.assertEqual(
|
||||
@@ -99,7 +98,6 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
metadata = ServiceProviderMetadataParser().parse(load_fixture("fixtures/cert.xml"))
|
||||
provider = metadata.to_provider("test", self.flow, self.flow)
|
||||
self.assertEqual(provider.acs_url, "http://localhost:8080/apps/user_saml/saml/acs")
|
||||
self.assertEqual(provider.issuer, "http://localhost:8080/apps/user_saml/saml/metadata")
|
||||
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||
self.assertEqual(
|
||||
provider.verification_kp.certificate_data, load_fixture("fixtures/cert.pem")
|
||||
|
||||
@@ -32,7 +32,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name="test-provider",
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
)
|
||||
|
||||
# Create another provider for testing
|
||||
@@ -40,7 +40,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name="test-provider-2",
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp2.example.com/acs",
|
||||
issuer="https://idp2.example.com",
|
||||
issuer_override="https://idp2.example.com",
|
||||
)
|
||||
|
||||
# Create a session first (using authentik's custom Session model)
|
||||
@@ -72,6 +72,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify the session was created
|
||||
@@ -100,6 +101,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Try to create another session with same session_index and provider
|
||||
@@ -113,6 +115,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
def test_cascade_deletion_user(self):
|
||||
@@ -127,6 +130,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify session exists
|
||||
@@ -150,6 +154,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify session exists
|
||||
@@ -173,6 +178,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify session exists
|
||||
@@ -196,6 +202,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Create second session with different provider
|
||||
@@ -208,6 +215,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify both sessions exist
|
||||
@@ -229,6 +237,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=future_time,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify expiry time
|
||||
@@ -248,6 +257,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=past_time,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Check if marked as expired
|
||||
@@ -265,6 +275,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format="", # Blank format
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify it was created successfully
|
||||
@@ -283,6 +294,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
session2 = SAMLSession.objects.create(
|
||||
@@ -294,6 +306,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Query by provider
|
||||
@@ -316,6 +329,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Check serializer property
|
||||
@@ -334,6 +348,7 @@ class TestSAMLSessionModel(TestCase):
|
||||
name_id_format=self.name_id_format,
|
||||
expires=self.expires,
|
||||
expiring=True,
|
||||
issuer="authentik",
|
||||
)
|
||||
|
||||
# Verify sessions exist
|
||||
|
||||
@@ -7,6 +7,7 @@ from guardian.shortcuts import get_anonymous_user
|
||||
from lxml import etree # nosec
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_cert, create_test_flow
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
@@ -30,6 +31,11 @@ class TestSchema(TestCase):
|
||||
)
|
||||
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||
self.provider.save()
|
||||
Application.objects.create(
|
||||
name="test-app",
|
||||
slug="test-app",
|
||||
provider=self.provider,
|
||||
)
|
||||
self.source = SAMLSource.objects.create(
|
||||
slug="provider",
|
||||
issuer="authentik",
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestSendSamlLogoutResponse(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestSendSamlLogoutRequest(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
@@ -155,6 +155,7 @@ class TestSendSamlLogoutRequest(TestCase):
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
issuer="https://idp.example.com",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
@@ -179,6 +180,7 @@ class TestSendSamlLogoutRequest(TestCase):
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
issuer="https://idp.example.com",
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
@@ -198,6 +200,7 @@ class TestSendSamlLogoutRequest(TestCase):
|
||||
name_id="test@example.com",
|
||||
name_id_format=SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index="test-session-123",
|
||||
issuer="https://idp.example.com",
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +217,7 @@ class TestSendPostLogoutRequest(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
signing_kp=self.cert,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
invalidation_flow=self.invalidation_flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
)
|
||||
@@ -90,7 +90,7 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
# Verify logout request was stored in plan context
|
||||
self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
|
||||
logout_request = view.plan_context["authentik/providers/saml/logout_request"]
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer)
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(logout_request.session_index, "test-session-123")
|
||||
|
||||
def test_redirect_view_handles_logout_response_with_plan_context(self):
|
||||
@@ -228,7 +228,7 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
# Verify logout request was stored in plan context
|
||||
self.assertIn("authentik/providers/saml/logout_request", view.plan_context)
|
||||
logout_request = view.plan_context["authentik/providers/saml/logout_request"]
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer)
|
||||
self.assertEqual(logout_request.issuer, self.provider.issuer_override)
|
||||
self.assertEqual(logout_request.session_index, "test-session-123")
|
||||
|
||||
def test_post_view_handles_logout_response_with_plan_context(self):
|
||||
@@ -396,7 +396,7 @@ class TestSPInitiatedSLOViews(TestCase):
|
||||
authorization_flow=self.flow,
|
||||
acs_url="https://sp2.example.com/acs",
|
||||
sls_url="https://sp2.example.com/sls",
|
||||
issuer="https://idp2.example.com",
|
||||
issuer_override="https://idp2.example.com",
|
||||
invalidation_flow=None, # No invalidation flow
|
||||
)
|
||||
|
||||
@@ -524,7 +524,7 @@ class TestSPInitiatedSLOLogoutMethods(TestCase):
|
||||
invalidation_flow=self.invalidation_flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="https://sp.example.com/sls",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
sp_binding="redirect",
|
||||
sls_binding="redirect",
|
||||
signing_kp=self.cert,
|
||||
@@ -714,7 +714,7 @@ class TestSPInitiatedSLOLogoutMethods(TestCase):
|
||||
invalidation_flow=self.invalidation_flow,
|
||||
acs_url="https://sp.example.com/acs",
|
||||
sls_url="", # No SLS URL
|
||||
issuer="https://idp.example.com",
|
||||
issuer_override="https://idp.example.com",
|
||||
)
|
||||
|
||||
app_no_sls = Application.objects.create(
|
||||
|
||||
@@ -4,13 +4,26 @@ from django.urls import path
|
||||
|
||||
from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
from authentik.providers.saml.views import metadata, sso
|
||||
from authentik.providers.saml.views import metadata, sso, unified
|
||||
from authentik.providers.saml.views.sp_slo import (
|
||||
SPInitiatedSLOBindingPOSTView,
|
||||
SPInitiatedSLOBindingRedirectView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Unified Endpoint - handles SSO and SLO based on message type
|
||||
path(
|
||||
"<slug:application_slug>/",
|
||||
unified.SAMLUnifiedView.as_view(),
|
||||
name="base",
|
||||
),
|
||||
# IdP-initiated
|
||||
path(
|
||||
"<slug:application_slug>/init/",
|
||||
sso.SAMLSSOBindingInitView.as_view(),
|
||||
name="init",
|
||||
),
|
||||
# LEGACY Endpoints (backward compatibility)
|
||||
# SSO Bindings
|
||||
path(
|
||||
"<slug:application_slug>/sso/binding/redirect/",
|
||||
|
||||
@@ -81,6 +81,7 @@ class SAMLFlowFinalView(ChallengeStageView):
|
||||
"session": auth_session,
|
||||
"name_id": processor.name_id,
|
||||
"name_id_format": processor.name_id_format,
|
||||
"issuer": processor.issuer,
|
||||
"expires": processor.session_not_on_or_after_datetime,
|
||||
"expiring": True,
|
||||
},
|
||||
|
||||
@@ -107,12 +107,25 @@ class SPInitiatedSLOView(PolicyAccessView):
|
||||
# Store relay state for the logout response
|
||||
plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
|
||||
|
||||
# Look up the session issuer to use in the logout response
|
||||
auth_session = AuthenticatedSession.from_request(request, request.user)
|
||||
session_issuer = None
|
||||
if auth_session:
|
||||
saml_session = SAMLSession.objects.filter(
|
||||
session=auth_session,
|
||||
user=request.user,
|
||||
provider=self.provider,
|
||||
).first()
|
||||
if saml_session:
|
||||
session_issuer = saml_session.issuer
|
||||
|
||||
if self.provider.logout_method == SAMLLogoutMethods.FRONTCHANNEL_NATIVE:
|
||||
# Native mode - user will be redirected/posted away from authentik
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
issuer=session_issuer,
|
||||
)
|
||||
|
||||
if self.provider.sls_binding == SAMLBindings.POST:
|
||||
@@ -152,6 +165,7 @@ class SPInitiatedSLOView(PolicyAccessView):
|
||||
sls_url=self.provider.sls_url,
|
||||
logout_request_id=logout_request.id if logout_request else None,
|
||||
relay_state=relay_state,
|
||||
issuer=session_issuer,
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
@@ -168,6 +182,7 @@ class SPInitiatedSLOView(PolicyAccessView):
|
||||
self.provider,
|
||||
logout_request,
|
||||
destination=self.provider.sls_url,
|
||||
issuer=session_issuer,
|
||||
)
|
||||
|
||||
logout_response = processor.build_response()
|
||||
|
||||
118
authentik/providers/saml/views/unified.py
Normal file
118
authentik/providers/saml/views/unified.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Unified SAML endpoint - handles SSO and SLO based on message type"""
|
||||
|
||||
from base64 import b64decode
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import NS_MAP
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.providers.saml.views.flows import (
|
||||
REQUEST_KEY_SAML_REQUEST,
|
||||
REQUEST_KEY_SAML_RESPONSE,
|
||||
)
|
||||
from authentik.providers.saml.views.sp_slo import (
|
||||
SPInitiatedSLOBindingPOSTView,
|
||||
SPInitiatedSLOBindingRedirectView,
|
||||
)
|
||||
from authentik.providers.saml.views.sso import (
|
||||
SAMLSSOBindingPOSTView,
|
||||
SAMLSSOBindingRedirectView,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# SAML message type constants
|
||||
SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest"
|
||||
SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest"
|
||||
|
||||
|
||||
def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None:
|
||||
"""Parse SAML request to determine if AuthnRequest or LogoutRequest."""
|
||||
try:
|
||||
if is_post_binding:
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
else:
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
|
||||
root = fromstring(decoded_xml)
|
||||
if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)):
|
||||
return SAML_MESSAGE_TYPE_AUTHN_REQUEST
|
||||
if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)):
|
||||
return SAML_MESSAGE_TYPE_LOGOUT_REQUEST
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SAMLUnifiedView(View):
|
||||
"""Unified SAML endpoint - handles SSO and SLO based on message type.
|
||||
|
||||
The operation type is determined by parsing
|
||||
the incoming SAML message:
|
||||
- AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView)
|
||||
- LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
|
||||
- LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
|
||||
"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Route the request based on SAML message type."""
|
||||
# ak user was not logged in, redirected to login, and is back w POST payload in session
|
||||
if SESSION_KEY_POST in request.session:
|
||||
return self._delegate_to_sso(request, application_slug, is_post_binding=True)
|
||||
|
||||
# Determine binding from HTTP method
|
||||
is_post_binding = request.method == "POST"
|
||||
data = request.POST if is_post_binding else request.GET
|
||||
|
||||
# LogoutResponse - delegate to SLO view (handles it in dispatch)
|
||||
if REQUEST_KEY_SAML_RESPONSE in data:
|
||||
return self._delegate_to_slo(request, application_slug, is_post_binding)
|
||||
|
||||
# Check for SAML request
|
||||
if REQUEST_KEY_SAML_REQUEST not in data:
|
||||
LOGGER.info("SAML payload missing")
|
||||
return bad_request_message(request, "The SAML request payload is missing.")
|
||||
|
||||
# Detect message type and delegate
|
||||
saml_request = data[REQUEST_KEY_SAML_REQUEST]
|
||||
message_type = detect_saml_message_type(saml_request, is_post_binding)
|
||||
|
||||
if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST:
|
||||
return self._delegate_to_sso(request, application_slug, is_post_binding)
|
||||
elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST:
|
||||
return self._delegate_to_slo(request, application_slug, is_post_binding)
|
||||
else:
|
||||
LOGGER.warning("Unknown SAML message type", message_type=message_type)
|
||||
return bad_request_message(
|
||||
request, f"Unsupported SAML message type: {message_type or 'unknown'}"
|
||||
)
|
||||
|
||||
def _delegate_to_sso(
|
||||
self, request: HttpRequest, application_slug: str, is_post_binding: bool
|
||||
) -> HttpResponse:
|
||||
"""Delegate to the appropriate SSO view."""
|
||||
if is_post_binding:
|
||||
view = SAMLSSOBindingPOSTView.as_view()
|
||||
else:
|
||||
view = SAMLSSOBindingRedirectView.as_view()
|
||||
return view(request, application_slug=application_slug)
|
||||
|
||||
def _delegate_to_slo(
|
||||
self, request: HttpRequest, application_slug: str, is_post_binding: bool
|
||||
) -> HttpResponse:
|
||||
"""Delegate to the appropriate SLO view."""
|
||||
if is_post_binding:
|
||||
view = SPInitiatedSLOBindingPOSTView.as_view()
|
||||
else:
|
||||
view = SPInitiatedSLOBindingRedirectView.as_view()
|
||||
return view(request, application_slug=application_slug)
|
||||
@@ -97,6 +97,9 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
||||
if cached_config is not None:
|
||||
return cached_config
|
||||
|
||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.VCENTER:
|
||||
return default_config
|
||||
|
||||
# Attempt to fetch from remote
|
||||
path = "/ServiceProviderConfig"
|
||||
if self.provider.compatibility_mode == SCIMCompatibilityMode.SALESFORCE:
|
||||
|
||||
@@ -94,6 +94,7 @@ class Migration(migrations.Migration):
|
||||
("slack", "Slack"),
|
||||
("sfdc", "Salesforce"),
|
||||
("webex", "Webex"),
|
||||
("vcenter", "vCenter"),
|
||||
],
|
||||
default="default",
|
||||
help_text="Alter authentik behavior for vendor-specific SCIM implementations.",
|
||||
|
||||
@@ -83,6 +83,7 @@ class SCIMCompatibilityMode(models.TextChoices):
|
||||
SLACK = "slack", _("Slack")
|
||||
SALESFORCE = "sfdc", _("Salesforce")
|
||||
WEBEX = "webex", _("Webex")
|
||||
VCENTER = "vcenter", _("vCenter")
|
||||
|
||||
|
||||
class SCIMProvider(OutgoingSyncProvider, BackchannelProvider):
|
||||
|
||||
@@ -94,7 +94,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||
def get_user_id(self, info: dict[str, Any]) -> str | None:
|
||||
"""Return unique identifier from the profile info."""
|
||||
if "id" in info:
|
||||
return info["id"]
|
||||
return str(info["id"])
|
||||
return None
|
||||
|
||||
def handle_login_failure(self, reason: str) -> HttpResponse:
|
||||
|
||||
@@ -353,7 +353,7 @@ class IdentificationStageView(ChallengeStageView):
|
||||
PLAN_CONTEXT_APPLICATION, Application()
|
||||
)
|
||||
challenge.initial_data["application_pre"] = app.name
|
||||
if launch_url := app.get_launch_url():
|
||||
if not app.meta_hide and (launch_url := app.get_launch_url()):
|
||||
challenge.initial_data["application_pre_launch"] = launch_url
|
||||
if (
|
||||
PLAN_CONTEXT_DEVICE in self.executor.plan.context
|
||||
|
||||
@@ -5215,6 +5215,11 @@
|
||||
"type": "string",
|
||||
"title": "Group"
|
||||
},
|
||||
"meta_hide": {
|
||||
"type": "boolean",
|
||||
"title": "Meta hide",
|
||||
"description": "Hide this application from the user's My applications page."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
@@ -9962,6 +9967,23 @@
|
||||
"title": "Client Type",
|
||||
"description": "Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable"
|
||||
},
|
||||
"grant_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"authorization_code",
|
||||
"implicit",
|
||||
"hybrid",
|
||||
"refresh_token",
|
||||
"client_credentials",
|
||||
"password",
|
||||
"urn:ietf:params:oauth:grant-type:device_code"
|
||||
],
|
||||
"title": "Grant types"
|
||||
},
|
||||
"title": "Grant types"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
@@ -10795,11 +10817,10 @@
|
||||
"title": "Audience",
|
||||
"description": "Value of the audience restriction field of the assertion. When left empty, no audience restriction will be added."
|
||||
},
|
||||
"issuer": {
|
||||
"issuer_override": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Issuer",
|
||||
"description": "Also known as EntityID"
|
||||
"title": "Issuer override",
|
||||
"description": "Also known as EntityID. Providing a value overrides the default issuer generated by authentik."
|
||||
},
|
||||
"assertion_valid_not_before": {
|
||||
"type": "string",
|
||||
@@ -11082,7 +11103,8 @@
|
||||
"aws",
|
||||
"slack",
|
||||
"sfdc",
|
||||
"webex"
|
||||
"webex",
|
||||
"vcenter"
|
||||
],
|
||||
"title": "SCIM Compatibility Mode",
|
||||
"description": "Alter authentik behavior for vendor-specific SCIM implementations."
|
||||
|
||||
@@ -75,6 +75,10 @@ entries:
|
||||
url: https://localhost:8443/test/a/authentik/callback
|
||||
- matching_mode: strict
|
||||
url: https://host.docker.internal:8443/test/a/authentik/callback
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- implicit
|
||||
- refresh_token
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
@@ -106,6 +110,10 @@ entries:
|
||||
url: https://localhost:8443/test/a/authentik/callback
|
||||
- matching_mode: strict
|
||||
url: https://host.docker.internal:8443/test/a/authentik/callback
|
||||
grant_types:
|
||||
- authorization_code
|
||||
- implicit
|
||||
- refresh_token
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
|
||||
|
||||
6
build.rs
Normal file
6
build.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "core")]
|
||||
{
|
||||
pyo3_build_config::add_libpython_rpath_link_args();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -147,8 +147,11 @@ RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
|
||||
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
|
||||
--mount=type=bind,target=.cargo/,src=.cargo/ \
|
||||
--mount=type=bind,target=build.rs,src=build.rs \
|
||||
--mount=type=bind,target=src/,src=src/ \
|
||||
--mount=type=bind,target=packages/,src=packages/ \
|
||||
--mount=type=bind,target=packages/ak-axum,src=packages/ak-axum \
|
||||
--mount=type=bind,target=packages/ak-common,src=packages/ak-common \
|
||||
--mount=type=bind,target=packages/client-rust,src=packages/client-rust \
|
||||
--mount=type=bind,target=authentik/lib/default.yml,src=authentik/lib/default.yml \
|
||||
# Required otherwise workspace discovery fails
|
||||
--mount=type=bind,target=website/scripts/docsmg/,src=website/scripts/docsmg/ \
|
||||
@@ -191,7 +194,10 @@ COPY --from=rust-toolchain /root/.cargo /root/.cargo
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||
--mount=type=bind,target=packages,src=packages \
|
||||
--mount=type=bind,target=packages/ak-guardian,src=packages/ak-guardian \
|
||||
--mount=type=bind,target=packages/django-channels-postgres,src=packages/django-channels-postgres \
|
||||
--mount=type=bind,target=packages/django-dramatiq-postgres,src=packages/django-dramatiq-postgres \
|
||||
--mount=type=bind,target=packages/django-postgres-cache,src=packages/django-postgres-cache \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -21,7 +21,7 @@ COPY web .
|
||||
RUN npm run build-proxy
|
||||
|
||||
# Stage 2: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:4a7137ea573f79c86ae451ff05817ed762ef5597fcf732259e97abeb3108d873 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-23 00:25+0000\n"
|
||||
"POT-Creation-Date: 2026-04-28 00:30+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -2717,6 +2717,10 @@ msgstr ""
|
||||
msgid "Webex"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "vCenter"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Group filters used to define sync-scope for groups."
|
||||
msgstr ""
|
||||
|
||||
5230
locale/no_NO/LC_MESSAGES/django.po
Normal file
5230
locale/no_NO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ durstr.workspace = true
|
||||
eyre.workspace = true
|
||||
forwarded-header-value.workspace = true
|
||||
futures.workspace = true
|
||||
pin-project-lite.workspace = true
|
||||
tokio-rustls.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
|
||||
737
packages/ak-axum/src/accept/catch_panic.rs
Normal file
737
packages/ak-axum/src/accept/catch_panic.rs
Normal file
@@ -0,0 +1,737 @@
|
||||
//! axum-server acceptor that catches panics and shuts down the application.
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
io::{self, IoSlice},
|
||||
panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use ak_common::Arbiter;
|
||||
use axum_server::accept::Accept;
|
||||
use futures::{FutureExt as _, future::BoxFuture};
|
||||
use pin_project_lite::pin_project;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tower::Service;
|
||||
use tracing::{error, instrument};
|
||||
|
||||
fn extract_panic_msg<'a>(panic: &'a Box<dyn Any + Send + 'static>) -> &'a str {
|
||||
panic
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("unknown panic message")
|
||||
}
|
||||
|
||||
/// Acceptor catching panics from the underlying acceptor.
|
||||
///
|
||||
/// Also wraps the stream and service to catch panics.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CatchPanicAcceptor<A> {
|
||||
inner: A,
|
||||
arbiter: Arbiter,
|
||||
}
|
||||
|
||||
impl<A> CatchPanicAcceptor<A> {
|
||||
pub(crate) fn new(inner: A, arbiter: Arbiter) -> Self {
|
||||
Self { inner, arbiter }
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, I, S> Accept<I, S> for CatchPanicAcceptor<A>
|
||||
where
|
||||
A: Accept<I, S> + Clone + Send + 'static,
|
||||
A::Stream: AsyncRead + AsyncWrite + Send,
|
||||
A::Service: Send,
|
||||
A::Future: Send,
|
||||
I: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
S: Send + 'static,
|
||||
{
|
||||
type Future = BoxFuture<'static, io::Result<(Self::Stream, Self::Service)>>;
|
||||
type Service = CatchPanicService<A::Service>;
|
||||
type Stream = CatchPanicStream<A::Stream>;
|
||||
|
||||
#[instrument(skip_all)]
|
||||
fn accept(&self, stream: I, service: S) -> Self::Future {
|
||||
let acceptor = self.inner.clone();
|
||||
let arbiter = self.arbiter.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
match AssertUnwindSafe(acceptor.accept(stream, service))
|
||||
.catch_unwind()
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let (stream, service) = result?;
|
||||
Ok((
|
||||
CatchPanicStream::new(stream, arbiter.clone()),
|
||||
CatchPanicService::new(service, arbiter),
|
||||
))
|
||||
}
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"acceptor panicked, shutting down immediately"
|
||||
);
|
||||
arbiter.do_fast_shutdown().await;
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pin_project! {
|
||||
/// A stream wrapper that catches panics from the underlying stream.
|
||||
pub(crate) struct CatchPanicStream<S> {
|
||||
#[pin]
|
||||
inner: S,
|
||||
arbiter: Arbiter,
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> CatchPanicStream<S> {
|
||||
pub(crate) fn new(inner: S, arbiter: Arbiter) -> Self {
|
||||
Self { inner, arbiter }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead> AsyncRead for CatchPanicStream<S> {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_read(cx, buf))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream poll_read panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite> AsyncWrite for CatchPanicStream<S> {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_write(cx, buf))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream poll_write panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_write_vectored(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
bufs: &[IoSlice<'_>],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| {
|
||||
this.inner.poll_write_vectored(cx, bufs)
|
||||
})) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream poll_write_vectored panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
match catch_unwind(AssertUnwindSafe(|| self.inner.is_write_vectored())) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream is_write_vectored panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = self.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_flush(cx))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream poll_flush panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| this.inner.poll_shutdown(cx))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"stream poll_shutdown panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic wrapper that catches panics from the underlying service.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CatchPanicService<S> {
|
||||
inner: S,
|
||||
arbiter: Arbiter,
|
||||
}
|
||||
|
||||
impl<S> CatchPanicService<S> {
|
||||
pub(crate) fn new(inner: S, arbiter: Arbiter) -> Self {
|
||||
Self { inner, arbiter }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, R> Service<R> for CatchPanicService<S>
|
||||
where
|
||||
S: Service<R>,
|
||||
{
|
||||
type Error = S::Error;
|
||||
type Future = CatchPanicFuture<S::Future>;
|
||||
type Response = S::Response;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
let inner = &mut self.inner;
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| inner.poll_ready(cx))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"service poll_ready panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = self.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, req: R) -> Self::Future {
|
||||
let inner = &mut self.inner;
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| inner.call(req))) {
|
||||
Ok(future) => CatchPanicFuture::new(future, self.arbiter.clone()),
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"service call panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = self.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pin_project! {
|
||||
/// A Future wrapper that catches panics from the inner future.
|
||||
pub(crate) struct CatchPanicFuture<F> {
|
||||
#[pin]
|
||||
inner: F,
|
||||
arbiter: Arbiter,
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> CatchPanicFuture<F> {
|
||||
fn new(inner: F, arbiter: Arbiter) -> Self {
|
||||
Self { inner, arbiter }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: Future> Future for CatchPanicFuture<F> {
|
||||
type Output = F::Output;
|
||||
|
||||
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
|
||||
match catch_unwind(AssertUnwindSafe(|| this.inner.poll(cx))) {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
error!(
|
||||
panic = extract_panic_msg(&panic),
|
||||
"service future panicked, shutting down immediately"
|
||||
);
|
||||
let arbiter = this.arbiter.clone();
|
||||
tokio::spawn(async move { arbiter.do_fast_shutdown().await });
|
||||
resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
io,
|
||||
panic::{AssertUnwindSafe, panic_any},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use ak_common::{Arbiter, Tasks};
|
||||
use axum_server::accept::Accept;
|
||||
use futures::{
|
||||
FutureExt as _,
|
||||
future::{BoxFuture, poll_fn},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream, ReadBuf, duplex},
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
use tower::Service;
|
||||
|
||||
use super::{CatchPanicAcceptor, CatchPanicService, CatchPanicStream};
|
||||
|
||||
fn duplex_stream() -> DuplexStream {
|
||||
let (stream, _peer) = duplex(1024);
|
||||
stream
|
||||
}
|
||||
|
||||
/// Returns `true` if the arbiter's fast-shutdown has already been triggered.
|
||||
async fn fast_shutdown_triggered(arbiter: &Arbiter) -> bool {
|
||||
timeout(Duration::from_millis(50), arbiter.fast_shutdown())
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OkAcceptor;
|
||||
|
||||
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for OkAcceptor {
|
||||
type Future = BoxFuture<'static, io::Result<(I, S)>>;
|
||||
type Service = S;
|
||||
type Stream = I;
|
||||
|
||||
fn accept(&self, stream: I, service: S) -> Self::Future {
|
||||
Box::pin(async move { Ok((stream, service)) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ErrorAcceptor;
|
||||
|
||||
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for ErrorAcceptor {
|
||||
type Future = BoxFuture<'static, io::Result<(I, S)>>;
|
||||
type Service = S;
|
||||
type Stream = I;
|
||||
|
||||
fn accept(&self, _stream: I, _service: S) -> Self::Future {
|
||||
Box::pin(async move { Err(io::Error::other("inner error")) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics with a `&'static str` payload.
|
||||
#[derive(Clone)]
|
||||
struct PanicStrAcceptor;
|
||||
|
||||
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicStrAcceptor {
|
||||
type Future = BoxFuture<'static, io::Result<(I, S)>>;
|
||||
type Service = S;
|
||||
type Stream = I;
|
||||
|
||||
fn accept(&self, _stream: I, _service: S) -> Self::Future {
|
||||
Box::pin(async move { panic!("str panic message") })
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics with a `String` payload.
|
||||
#[derive(Clone)]
|
||||
struct PanicStringAcceptor;
|
||||
|
||||
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicStringAcceptor {
|
||||
type Future = BoxFuture<'static, io::Result<(I, S)>>;
|
||||
type Service = S;
|
||||
type Stream = I;
|
||||
|
||||
fn accept(&self, _stream: I, _service: S) -> Self::Future {
|
||||
Box::pin(async move {
|
||||
let msg = "string panic message".to_owned();
|
||||
panic_any(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics with a payload that is neither `&str` nor `String`.
|
||||
#[derive(Clone)]
|
||||
struct PanicUnknownAcceptor;
|
||||
|
||||
impl<I: Send + 'static, S: Send + 'static> Accept<I, S> for PanicUnknownAcceptor {
|
||||
type Future = BoxFuture<'static, io::Result<(I, S)>>;
|
||||
type Service = S;
|
||||
type Stream = I;
|
||||
|
||||
fn accept(&self, _stream: I, _service: S) -> Self::Future {
|
||||
Box::pin(async move { panic_any(42u32) })
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicStream;
|
||||
|
||||
impl tokio::io::AsyncRead for PanicStream {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
_buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
panic!("poll_read panic")
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for PanicStream {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
_buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
panic!("poll_write panic")
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
panic!("poll_flush panic")
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
panic!("poll_shutdown panic")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OkService;
|
||||
|
||||
impl Service<()> for OkService {
|
||||
type Error = Infallible;
|
||||
type Future = futures::future::Ready<Result<(), Infallible>>;
|
||||
type Response = ();
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, _req: ()) -> Self::Future {
|
||||
futures::future::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicPollReadyService;
|
||||
|
||||
impl Service<()> for PanicPollReadyService {
|
||||
type Error = Infallible;
|
||||
type Future = futures::future::Ready<Result<(), Infallible>>;
|
||||
type Response = ();
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
panic!("poll_ready panic")
|
||||
}
|
||||
|
||||
fn call(&mut self, _req: ()) -> Self::Future {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicCallBodyService;
|
||||
|
||||
impl Service<()> for PanicCallBodyService {
|
||||
type Error = Infallible;
|
||||
type Future = futures::future::Ready<Result<(), Infallible>>;
|
||||
type Response = ();
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, _req: ()) -> Self::Future {
|
||||
panic!("call body panic")
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicFuture;
|
||||
|
||||
impl Future for PanicFuture {
|
||||
type Output = Result<(), Infallible>;
|
||||
|
||||
fn poll(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
panic!("future panic")
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicCallFutureService;
|
||||
|
||||
impl Service<()> for PanicCallFutureService {
|
||||
type Error = Infallible;
|
||||
type Future = PanicFuture;
|
||||
type Response = ();
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, _req: ()) -> Self::Future {
|
||||
PanicFuture
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptor_passes_through_success() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let acceptor = CatchPanicAcceptor::new(OkAcceptor, arbiter.clone());
|
||||
|
||||
let result = acceptor.accept(duplex_stream(), OkService).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptor_passes_through_error() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let acceptor = CatchPanicAcceptor::new(ErrorAcceptor, arbiter.clone());
|
||||
|
||||
let result = acceptor.accept(duplex_stream(), OkService).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.err().unwrap().to_string(), "inner error");
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptor_catches_str_panic_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let acceptor = CatchPanicAcceptor::new(PanicStrAcceptor, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptor_catches_string_panic_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let acceptor = CatchPanicAcceptor::new(PanicStringAcceptor, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acceptor_catches_unknown_panic_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let acceptor = CatchPanicAcceptor::new(PanicUnknownAcceptor, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(acceptor.accept(duplex_stream(), OkService))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_read_passes_through() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let (mut a, mut b) = duplex(1024);
|
||||
b.write_all(b"hello").await.unwrap();
|
||||
|
||||
let mut stream = CatchPanicStream::new(&mut a, arbiter.clone());
|
||||
let mut buf = [0u8; 5];
|
||||
let result = stream.read(&mut buf).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(&buf, b"hello");
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_read_panic_returns_error_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(stream.read(&mut [0u8; 10]))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_write_passes_through() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let (mut a, _b) = duplex(1024);
|
||||
|
||||
let mut stream = CatchPanicStream::new(&mut a, arbiter.clone());
|
||||
let result = stream.write_all(b"hello").await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_write_panic_returns_error_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(stream.write(b"hello"))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_flush_panic_returns_error_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(stream.flush()).catch_unwind().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_poll_shutdown_panic_returns_error_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut stream = CatchPanicStream::new(PanicStream, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(stream.shutdown()).catch_unwind().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_poll_ready_passes_through() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut service = CatchPanicService::new(OkService, arbiter.clone());
|
||||
|
||||
let result = poll_fn(|cx| service.poll_ready(cx)).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_poll_ready_panic_re_panics_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut service = CatchPanicService::new(PanicPollReadyService, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(poll_fn(|cx| service.poll_ready(cx)))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_passes_through() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut service = CatchPanicService::new(OkService, arbiter.clone());
|
||||
|
||||
let result = service.call(()).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(!fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_body_panic_re_panics_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut service = CatchPanicService::new(PanicCallBodyService, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(async { service.call(()).await })
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_future_panic_re_panics_and_shuts_down() {
|
||||
let tasks = Tasks::new().expect("failed to create tasks");
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut service = CatchPanicService::new(PanicCallFutureService, arbiter.clone());
|
||||
|
||||
let result = AssertUnwindSafe(service.call(())).catch_unwind().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(fast_shutdown_triggered(&arbiter).await);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod catch_panic;
|
||||
pub mod proxy_protocol;
|
||||
pub mod tls;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
//! Utilities for working with [`Router`].
|
||||
|
||||
use ak_common::config;
|
||||
use axum::{Router, http::StatusCode, middleware::from_fn};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::Request,
|
||||
http::{HeaderName, HeaderValue, StatusCode},
|
||||
middleware::{Next, from_fn},
|
||||
response::Response,
|
||||
};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
|
||||
@@ -13,6 +19,16 @@ use crate::{
|
||||
tracing::{span_middleware, tracing_middleware},
|
||||
};
|
||||
|
||||
const X_POWERED_BY: HeaderName = HeaderName::from_static("x-powered-by");
|
||||
|
||||
async fn powered_by_authentik_middleware(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(X_POWERED_BY, HeaderValue::from_static("authentik"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Wrap a [`Router`] with common middlewares.
|
||||
///
|
||||
/// Set `with_tracing` to [`true`] to log requests.
|
||||
@@ -30,6 +46,7 @@ pub fn wrap_router(router: Router, with_tracing: bool) -> Router {
|
||||
timeout,
|
||||
))
|
||||
.layer(from_fn(span_middleware))
|
||||
.layer(from_fn(powered_by_authentik_middleware))
|
||||
.layer(from_fn(trusted_proxy_middleware))
|
||||
.layer(from_fn(client_ip_middleware))
|
||||
.layer(from_fn(scheme_middleware))
|
||||
|
||||
@@ -12,7 +12,9 @@ use axum_server::{
|
||||
use eyre::Result;
|
||||
use tracing::{info, trace};
|
||||
|
||||
use crate::accept::{proxy_protocol::ProxyProtocolAcceptor, tls::TlsAcceptor};
|
||||
use crate::accept::{
|
||||
catch_panic::CatchPanicAcceptor, proxy_protocol::ProxyProtocolAcceptor, tls::TlsAcceptor,
|
||||
};
|
||||
|
||||
async fn run_plain(
|
||||
arbiter: Arbiter,
|
||||
@@ -27,7 +29,10 @@ async fn run_plain(
|
||||
arbiter.add_net_handle(handle.clone()).await;
|
||||
|
||||
let res = axum_server::Server::bind(addr)
|
||||
.acceptor(ProxyProtocolAcceptor::new().acceptor(DefaultAcceptor::new()))
|
||||
.acceptor(CatchPanicAcceptor::new(
|
||||
ProxyProtocolAcceptor::new().acceptor(DefaultAcceptor::new()),
|
||||
arbiter.clone(),
|
||||
))
|
||||
.handle(handle)
|
||||
.serve(router.into_make_service_with_connect_info::<net::SocketAddr>())
|
||||
.await;
|
||||
@@ -80,7 +85,10 @@ pub(crate) async fn run_unix(
|
||||
}
|
||||
}
|
||||
let res = axum_server::Server::bind(addr.clone())
|
||||
.acceptor(DefaultAcceptor::new())
|
||||
.acceptor(CatchPanicAcceptor::new(
|
||||
DefaultAcceptor::new(),
|
||||
arbiter.clone(),
|
||||
))
|
||||
.handle(handle)
|
||||
.serve(router.into_make_service())
|
||||
.await;
|
||||
@@ -133,9 +141,12 @@ async fn run_tls(
|
||||
arbiter.add_net_handle(handle.clone()).await;
|
||||
|
||||
axum_server::Server::bind(addr)
|
||||
.acceptor(ProxyProtocolAcceptor::new().acceptor(TlsAcceptor::new(
|
||||
RustlsAcceptor::new(config).acceptor(DefaultAcceptor::new()),
|
||||
)))
|
||||
.acceptor(CatchPanicAcceptor::new(
|
||||
ProxyProtocolAcceptor::new().acceptor(TlsAcceptor::new(
|
||||
RustlsAcceptor::new(config).acceptor(DefaultAcceptor::new()),
|
||||
)),
|
||||
arbiter.clone(),
|
||||
))
|
||||
.handle(handle)
|
||||
.serve(router.into_make_service_with_connect_info::<net::SocketAddr>())
|
||||
.await?;
|
||||
|
||||
@@ -235,7 +235,7 @@ impl Arbiter {
|
||||
}
|
||||
|
||||
/// Shutdown the application immediately.
|
||||
async fn do_fast_shutdown(&self) {
|
||||
pub async fn do_fast_shutdown(&self) {
|
||||
info!("arbiter has been told to shutdown immediately");
|
||||
self.unix_handles
|
||||
.lock()
|
||||
@@ -253,7 +253,7 @@ impl Arbiter {
|
||||
}
|
||||
|
||||
/// Shutdown the application gracefully.
|
||||
async fn do_graceful_shutdown(&self) {
|
||||
pub async fn do_graceful_shutdown(&self) {
|
||||
info!("arbiter has been told to shutdown gracefully");
|
||||
// Match the value in lifecycle/gunicorn.conf.py for graceful shutdown
|
||||
let timeout = Some(Duration::from_secs(30 + 5));
|
||||
|
||||
@@ -16,7 +16,10 @@ use url::Url;
|
||||
pub mod schema;
|
||||
pub use schema::Config;
|
||||
|
||||
use crate::arbiter::{Arbiter, Event, Tasks};
|
||||
use crate::{
|
||||
arbiter::{Arbiter, Event, Tasks},
|
||||
config::schema::KEYS_TO_PARSE_AS_LIST,
|
||||
};
|
||||
|
||||
static DEFAULT_CONFIG: &str = include_str!("../../../../authentik/lib/default.yml");
|
||||
static CONFIG_MANAGER: OnceLock<ConfigManager> = OnceLock::new();
|
||||
@@ -75,11 +78,15 @@ impl Config {
|
||||
config_rs::File::from(path.as_path()).format(config_rs::FileFormat::Yaml),
|
||||
);
|
||||
}
|
||||
builder = builder.add_source(
|
||||
config_rs::Environment::with_prefix("AUTHENTIK")
|
||||
.prefix_separator("_")
|
||||
.separator("__"),
|
||||
);
|
||||
let mut env_source = config_rs::Environment::with_prefix("AUTHENTIK")
|
||||
.prefix_separator("_")
|
||||
.separator("__")
|
||||
.try_parsing(true)
|
||||
.list_separator(",");
|
||||
for key in KEYS_TO_PARSE_AS_LIST {
|
||||
env_source = env_source.with_list_parse_key(key);
|
||||
}
|
||||
builder = builder.add_source(env_source);
|
||||
if let Some(overrides) = overrides {
|
||||
builder = builder.add_source(config_rs::File::from_str(
|
||||
&overrides.to_string(),
|
||||
@@ -455,4 +462,92 @@ mod tests {
|
||||
super::set(json!({"secret_key": "my_new_secret_key"})).expect("failed to set config");
|
||||
assert_eq!(super::get().secret_key, "my_new_secret_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_bool_true() {
|
||||
#[expect(unsafe_code, reason = "testing")]
|
||||
// SAFETY: testing
|
||||
unsafe {
|
||||
env::set_var("AUTHENTIK_DEBUG", "true");
|
||||
}
|
||||
|
||||
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
|
||||
assert!(config.debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_bool_false() {
|
||||
#[expect(unsafe_code, reason = "testing")]
|
||||
// SAFETY: testing
|
||||
unsafe {
|
||||
env::set_var("AUTHENTIK_DEBUG", "false");
|
||||
}
|
||||
|
||||
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
|
||||
assert!(!config.debug);
|
||||
}
|
||||
|
||||
// See https://github.com/rust-cli/config-rs/issues/443
|
||||
// #[test]
|
||||
// fn env_list_empty() {
|
||||
// #[expect(unsafe_code, reason = "testing")]
|
||||
// // SAFETY: testing
|
||||
// unsafe {
|
||||
// env::set_var("AUTHENTIK_LISTEN__HTTP", "");
|
||||
// }
|
||||
//
|
||||
// let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
//
|
||||
// assert_eq!(config.listen.http, []);
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn env_list_one_element() {
|
||||
#[expect(unsafe_code, reason = "testing")]
|
||||
// SAFETY: testing
|
||||
unsafe {
|
||||
env::set_var("AUTHENTIK_LISTEN__HTTP", "[::1]:9000");
|
||||
}
|
||||
|
||||
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
|
||||
assert_eq!(
|
||||
config.listen.http,
|
||||
["[::1]:9000".parse().expect("infallible")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_list_many_elements() {
|
||||
#[expect(unsafe_code, reason = "testing")]
|
||||
// SAFETY: testing
|
||||
unsafe {
|
||||
env::set_var("AUTHENTIK_LISTEN__HTTP", "[::1]:9000,[::1]:9001");
|
||||
}
|
||||
|
||||
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
|
||||
assert_eq!(
|
||||
config.listen.http,
|
||||
[
|
||||
"[::1]:9000".parse().expect("infallible"),
|
||||
"[::1]:9001".parse().expect("infallible")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_string() {
|
||||
#[expect(unsafe_code, reason = "testing")]
|
||||
// SAFETY: testing
|
||||
unsafe {
|
||||
env::set_var("AUTHENTIK_SECRET_KEY", "my_secret_key");
|
||||
}
|
||||
|
||||
let (config, _) = super::Config::load(&[], None).expect("failed to load config");
|
||||
|
||||
assert_eq!(config.secret_key, "my_secret_key",);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize};
|
||||
use ipnet::IpNet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(super) const KEYS_TO_PARSE_AS_LIST: [&str; 4] = [
|
||||
"listen.http",
|
||||
"listen.metrics",
|
||||
"listen.trusted_proxy_cidrs",
|
||||
"log.http_headers",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub postgresql: PostgreSQLConfig,
|
||||
|
||||
12
packages/client-ts/src/apis/ProvidersApi.ts
generated
12
packages/client-ts/src/apis/ProvidersApi.ts
generated
@@ -634,7 +634,7 @@ export interface ProvidersSamlListRequest {
|
||||
encryptionKp?: string;
|
||||
invalidationFlow?: string;
|
||||
isBackchannel?: boolean;
|
||||
issuer?: string;
|
||||
issuerOverride?: string;
|
||||
logoutMethod?: SAMLLogoutMethods;
|
||||
name?: string;
|
||||
nameIdMapping?: string;
|
||||
@@ -841,7 +841,7 @@ export interface ProvidersWsfedListRequest {
|
||||
encryptionKp?: string;
|
||||
invalidationFlow?: string;
|
||||
isBackchannel?: boolean;
|
||||
issuer?: string;
|
||||
issuerOverride?: string;
|
||||
logoutMethod?: SAMLLogoutMethods;
|
||||
name?: string;
|
||||
nameIdMapping?: string;
|
||||
@@ -6842,8 +6842,8 @@ export class ProvidersApi extends runtime.BaseAPI {
|
||||
queryParameters["is_backchannel"] = requestParameters["isBackchannel"];
|
||||
}
|
||||
|
||||
if (requestParameters["issuer"] != null) {
|
||||
queryParameters["issuer"] = requestParameters["issuer"];
|
||||
if (requestParameters["issuerOverride"] != null) {
|
||||
queryParameters["issuer_override"] = requestParameters["issuerOverride"];
|
||||
}
|
||||
|
||||
if (requestParameters["logoutMethod"] != null) {
|
||||
@@ -9326,8 +9326,8 @@ export class ProvidersApi extends runtime.BaseAPI {
|
||||
queryParameters["is_backchannel"] = requestParameters["isBackchannel"];
|
||||
}
|
||||
|
||||
if (requestParameters["issuer"] != null) {
|
||||
queryParameters["issuer"] = requestParameters["issuer"];
|
||||
if (requestParameters["issuerOverride"] != null) {
|
||||
queryParameters["issuer_override"] = requestParameters["issuerOverride"];
|
||||
}
|
||||
|
||||
if (requestParameters["logoutMethod"] != null) {
|
||||
|
||||
8
packages/client-ts/src/models/Application.ts
generated
8
packages/client-ts/src/models/Application.ts
generated
@@ -127,6 +127,12 @@ export interface Application {
|
||||
* @memberof Application
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* Hide this application from the user's My applications page.
|
||||
* @type {boolean}
|
||||
* @memberof Application
|
||||
*/
|
||||
metaHide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,6 +183,7 @@ export function ApplicationFromJSONTyped(json: any, ignoreDiscriminator: boolean
|
||||
? undefined
|
||||
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
|
||||
group: json["group"] == null ? undefined : json["group"],
|
||||
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,5 +219,6 @@ export function ApplicationToJSONTyped(
|
||||
meta_publisher: value["metaPublisher"],
|
||||
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
|
||||
group: value["group"],
|
||||
meta_hide: value["metaHide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@ export interface ApplicationRequest {
|
||||
* @memberof ApplicationRequest
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* Hide this application from the user's My applications page.
|
||||
* @type {boolean}
|
||||
* @memberof ApplicationRequest
|
||||
*/
|
||||
metaHide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +131,7 @@ export function ApplicationRequestFromJSONTyped(
|
||||
? undefined
|
||||
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
|
||||
group: json["group"] == null ? undefined : json["group"],
|
||||
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,5 +159,6 @@ export function ApplicationRequestToJSONTyped(
|
||||
meta_publisher: value["metaPublisher"],
|
||||
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
|
||||
group: value["group"],
|
||||
meta_hide: value["metaHide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CompatibilityModeEnum = {
|
||||
Slack: "slack",
|
||||
Sfdc: "sfdc",
|
||||
Webex: "webex",
|
||||
Vcenter: "vcenter",
|
||||
UnknownDefaultOpenApi: "11184809",
|
||||
} as const;
|
||||
export type CompatibilityModeEnum =
|
||||
|
||||
62
packages/client-ts/src/models/GrantTypesEnum.ts
generated
Normal file
62
packages/client-ts/src/models/GrantTypesEnum.ts
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
/* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const GrantTypesEnum = {
|
||||
AuthorizationCode: "authorization_code",
|
||||
Implicit: "implicit",
|
||||
Hybrid: "hybrid",
|
||||
RefreshToken: "refresh_token",
|
||||
ClientCredentials: "client_credentials",
|
||||
Password: "password",
|
||||
UrnIetfParamsOauthGrantTypeDeviceCode: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
UnknownDefaultOpenApi: "11184809",
|
||||
} as const;
|
||||
export type GrantTypesEnum = (typeof GrantTypesEnum)[keyof typeof GrantTypesEnum];
|
||||
|
||||
export function instanceOfGrantTypesEnum(value: any): boolean {
|
||||
for (const key in GrantTypesEnum) {
|
||||
if (Object.prototype.hasOwnProperty.call(GrantTypesEnum, key)) {
|
||||
if (GrantTypesEnum[key as keyof typeof GrantTypesEnum] === value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function GrantTypesEnumFromJSON(json: any): GrantTypesEnum {
|
||||
return GrantTypesEnumFromJSONTyped(json, false);
|
||||
}
|
||||
|
||||
export function GrantTypesEnumFromJSONTyped(
|
||||
json: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): GrantTypesEnum {
|
||||
return json as GrantTypesEnum;
|
||||
}
|
||||
|
||||
export function GrantTypesEnumToJSON(value?: GrantTypesEnum | null): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
export function GrantTypesEnumToJSONTyped(
|
||||
value: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): GrantTypesEnum {
|
||||
return value as GrantTypesEnum;
|
||||
}
|
||||
16
packages/client-ts/src/models/OAuth2Provider.ts
generated
16
packages/client-ts/src/models/OAuth2Provider.ts
generated
@@ -14,6 +14,8 @@
|
||||
|
||||
import type { ClientTypeEnum } from "./ClientTypeEnum";
|
||||
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
|
||||
import type { GrantTypesEnum } from "./GrantTypesEnum";
|
||||
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
|
||||
import type { IssuerModeEnum } from "./IssuerModeEnum";
|
||||
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
|
||||
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
|
||||
@@ -122,6 +124,12 @@ export interface OAuth2Provider {
|
||||
* @memberof OAuth2Provider
|
||||
*/
|
||||
clientType?: ClientTypeEnum;
|
||||
/**
|
||||
*
|
||||
* @type {Array<GrantTypesEnum>}
|
||||
* @memberof OAuth2Provider
|
||||
*/
|
||||
grantTypes?: Array<GrantTypesEnum>;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -279,6 +287,10 @@ export function OAuth2ProviderFromJSONTyped(
|
||||
metaModelName: json["meta_model_name"],
|
||||
clientType:
|
||||
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
|
||||
grantTypes:
|
||||
json["grant_types"] == null
|
||||
? undefined
|
||||
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
|
||||
clientId: json["client_id"] == null ? undefined : json["client_id"],
|
||||
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
|
||||
accessCodeValidity:
|
||||
@@ -341,6 +353,10 @@ export function OAuth2ProviderToJSONTyped(
|
||||
invalidation_flow: value["invalidationFlow"],
|
||||
property_mappings: value["propertyMappings"],
|
||||
client_type: ClientTypeEnumToJSON(value["clientType"]),
|
||||
grant_types:
|
||||
value["grantTypes"] == null
|
||||
? undefined
|
||||
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
|
||||
client_id: value["clientId"],
|
||||
client_secret: value["clientSecret"],
|
||||
access_code_validity: value["accessCodeValidity"],
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
import type { ClientTypeEnum } from "./ClientTypeEnum";
|
||||
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
|
||||
import type { GrantTypesEnum } from "./GrantTypesEnum";
|
||||
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
|
||||
import type { IssuerModeEnum } from "./IssuerModeEnum";
|
||||
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
|
||||
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
|
||||
@@ -68,6 +70,12 @@ export interface OAuth2ProviderRequest {
|
||||
* @memberof OAuth2ProviderRequest
|
||||
*/
|
||||
clientType?: ClientTypeEnum;
|
||||
/**
|
||||
*
|
||||
* @type {Array<GrantTypesEnum>}
|
||||
* @memberof OAuth2ProviderRequest
|
||||
*/
|
||||
grantTypes?: Array<GrantTypesEnum>;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -197,6 +205,10 @@ export function OAuth2ProviderRequestFromJSONTyped(
|
||||
propertyMappings: json["property_mappings"] == null ? undefined : json["property_mappings"],
|
||||
clientType:
|
||||
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
|
||||
grantTypes:
|
||||
json["grant_types"] == null
|
||||
? undefined
|
||||
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
|
||||
clientId: json["client_id"] == null ? undefined : json["client_id"],
|
||||
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
|
||||
accessCodeValidity:
|
||||
@@ -248,6 +260,10 @@ export function OAuth2ProviderRequestToJSONTyped(
|
||||
invalidation_flow: value["invalidationFlow"],
|
||||
property_mappings: value["propertyMappings"],
|
||||
client_type: ClientTypeEnumToJSON(value["clientType"]),
|
||||
grant_types:
|
||||
value["grantTypes"] == null
|
||||
? undefined
|
||||
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
|
||||
client_id: value["clientId"],
|
||||
client_secret: value["clientSecret"],
|
||||
access_code_validity: value["accessCodeValidity"],
|
||||
|
||||
@@ -87,6 +87,12 @@ export interface PatchedApplicationRequest {
|
||||
* @memberof PatchedApplicationRequest
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* Hide this application from the user's My applications page.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedApplicationRequest
|
||||
*/
|
||||
metaHide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +131,7 @@ export function PatchedApplicationRequestFromJSONTyped(
|
||||
? undefined
|
||||
: PolicyEngineModeFromJSON(json["policy_engine_mode"]),
|
||||
group: json["group"] == null ? undefined : json["group"],
|
||||
metaHide: json["meta_hide"] == null ? undefined : json["meta_hide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,5 +159,6 @@ export function PatchedApplicationRequestToJSONTyped(
|
||||
meta_publisher: value["metaPublisher"],
|
||||
policy_engine_mode: PolicyEngineModeToJSON(value["policyEngineMode"]),
|
||||
group: value["group"],
|
||||
meta_hide: value["metaHide"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
import type { ClientTypeEnum } from "./ClientTypeEnum";
|
||||
import { ClientTypeEnumFromJSON, ClientTypeEnumToJSON } from "./ClientTypeEnum";
|
||||
import type { GrantTypesEnum } from "./GrantTypesEnum";
|
||||
import { GrantTypesEnumFromJSON, GrantTypesEnumToJSON } from "./GrantTypesEnum";
|
||||
import type { IssuerModeEnum } from "./IssuerModeEnum";
|
||||
import { IssuerModeEnumFromJSON, IssuerModeEnumToJSON } from "./IssuerModeEnum";
|
||||
import type { OAuth2ProviderLogoutMethodEnum } from "./OAuth2ProviderLogoutMethodEnum";
|
||||
@@ -68,6 +70,12 @@ export interface PatchedOAuth2ProviderRequest {
|
||||
* @memberof PatchedOAuth2ProviderRequest
|
||||
*/
|
||||
clientType?: ClientTypeEnum;
|
||||
/**
|
||||
*
|
||||
* @type {Array<GrantTypesEnum>}
|
||||
* @memberof PatchedOAuth2ProviderRequest
|
||||
*/
|
||||
grantTypes?: Array<GrantTypesEnum>;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -196,6 +204,10 @@ export function PatchedOAuth2ProviderRequestFromJSONTyped(
|
||||
propertyMappings: json["property_mappings"] == null ? undefined : json["property_mappings"],
|
||||
clientType:
|
||||
json["client_type"] == null ? undefined : ClientTypeEnumFromJSON(json["client_type"]),
|
||||
grantTypes:
|
||||
json["grant_types"] == null
|
||||
? undefined
|
||||
: (json["grant_types"] as Array<any>).map(GrantTypesEnumFromJSON),
|
||||
clientId: json["client_id"] == null ? undefined : json["client_id"],
|
||||
clientSecret: json["client_secret"] == null ? undefined : json["client_secret"],
|
||||
accessCodeValidity:
|
||||
@@ -250,6 +262,10 @@ export function PatchedOAuth2ProviderRequestToJSONTyped(
|
||||
invalidation_flow: value["invalidationFlow"],
|
||||
property_mappings: value["propertyMappings"],
|
||||
client_type: ClientTypeEnumToJSON(value["clientType"]),
|
||||
grant_types:
|
||||
value["grantTypes"] == null
|
||||
? undefined
|
||||
: (value["grantTypes"] as Array<any>).map(GrantTypesEnumToJSON),
|
||||
client_id: value["clientId"],
|
||||
client_secret: value["clientSecret"],
|
||||
access_code_validity: value["accessCodeValidity"],
|
||||
|
||||
@@ -81,11 +81,11 @@ export interface PatchedSAMLProviderRequest {
|
||||
*/
|
||||
audience?: string;
|
||||
/**
|
||||
* Also known as EntityID
|
||||
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
|
||||
* @type {string}
|
||||
* @memberof PatchedSAMLProviderRequest
|
||||
*/
|
||||
issuer?: string;
|
||||
issuerOverride?: string;
|
||||
/**
|
||||
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
|
||||
* @type {string}
|
||||
@@ -233,7 +233,7 @@ export function PatchedSAMLProviderRequestFromJSONTyped(
|
||||
acsUrl: json["acs_url"] == null ? undefined : json["acs_url"],
|
||||
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
|
||||
audience: json["audience"] == null ? undefined : json["audience"],
|
||||
issuer: json["issuer"] == null ? undefined : json["issuer"],
|
||||
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
|
||||
assertionValidNotBefore:
|
||||
json["assertion_valid_not_before"] == null
|
||||
? undefined
|
||||
@@ -306,7 +306,7 @@ export function PatchedSAMLProviderRequestToJSONTyped(
|
||||
acs_url: value["acsUrl"],
|
||||
sls_url: value["slsUrl"],
|
||||
audience: value["audience"],
|
||||
issuer: value["issuer"],
|
||||
issuer_override: value["issuerOverride"],
|
||||
assertion_valid_not_before: value["assertionValidNotBefore"],
|
||||
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
|
||||
35
packages/client-ts/src/models/SAMLProvider.ts
generated
35
packages/client-ts/src/models/SAMLProvider.ts
generated
@@ -135,11 +135,11 @@ export interface SAMLProvider {
|
||||
*/
|
||||
audience?: string;
|
||||
/**
|
||||
* Also known as EntityID
|
||||
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
|
||||
* @type {string}
|
||||
* @memberof SAMLProvider
|
||||
*/
|
||||
issuer?: string;
|
||||
issuerOverride?: string;
|
||||
/**
|
||||
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
|
||||
* @type {string}
|
||||
@@ -260,6 +260,24 @@ export interface SAMLProvider {
|
||||
* @memberof SAMLProvider
|
||||
*/
|
||||
readonly urlDownloadMetadata: string;
|
||||
/**
|
||||
* Get Issuer/EntityID URL
|
||||
* @type {string}
|
||||
* @memberof SAMLProvider
|
||||
*/
|
||||
readonly urlIssuer: string;
|
||||
/**
|
||||
* Get unified SAML endpoint URL (handles SSO and SLO)
|
||||
* @type {string}
|
||||
* @memberof SAMLProvider
|
||||
*/
|
||||
readonly urlUnified: string;
|
||||
/**
|
||||
* Get IdP-initiated SAML URL
|
||||
* @type {string}
|
||||
* @memberof SAMLProvider
|
||||
*/
|
||||
readonly urlUnifiedInit: string;
|
||||
/**
|
||||
* Get SSO Post URL
|
||||
* @type {string}
|
||||
@@ -321,6 +339,9 @@ export function instanceOfSAMLProvider(value: object): value is SAMLProvider {
|
||||
if (!("acsUrl" in value) || value["acsUrl"] === undefined) return false;
|
||||
if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined)
|
||||
return false;
|
||||
if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false;
|
||||
if (!("urlUnified" in value) || value["urlUnified"] === undefined) return false;
|
||||
if (!("urlUnifiedInit" in value) || value["urlUnifiedInit"] === undefined) return false;
|
||||
if (!("urlSsoPost" in value) || value["urlSsoPost"] === undefined) return false;
|
||||
if (!("urlSsoRedirect" in value) || value["urlSsoRedirect"] === undefined) return false;
|
||||
if (!("urlSsoInit" in value) || value["urlSsoInit"] === undefined) return false;
|
||||
@@ -356,7 +377,7 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
|
||||
acsUrl: json["acs_url"],
|
||||
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
|
||||
audience: json["audience"] == null ? undefined : json["audience"],
|
||||
issuer: json["issuer"] == null ? undefined : json["issuer"],
|
||||
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
|
||||
assertionValidNotBefore:
|
||||
json["assertion_valid_not_before"] == null
|
||||
? undefined
|
||||
@@ -406,6 +427,9 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
|
||||
? undefined
|
||||
: SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]),
|
||||
urlDownloadMetadata: json["url_download_metadata"],
|
||||
urlIssuer: json["url_issuer"],
|
||||
urlUnified: json["url_unified"],
|
||||
urlUnifiedInit: json["url_unified_init"],
|
||||
urlSsoPost: json["url_sso_post"],
|
||||
urlSsoRedirect: json["url_sso_redirect"],
|
||||
urlSsoInit: json["url_sso_init"],
|
||||
@@ -431,6 +455,9 @@ export function SAMLProviderToJSONTyped(
|
||||
| "verbose_name_plural"
|
||||
| "meta_model_name"
|
||||
| "url_download_metadata"
|
||||
| "url_issuer"
|
||||
| "url_unified"
|
||||
| "url_unified_init"
|
||||
| "url_sso_post"
|
||||
| "url_sso_redirect"
|
||||
| "url_sso_init"
|
||||
@@ -452,7 +479,7 @@ export function SAMLProviderToJSONTyped(
|
||||
acs_url: value["acsUrl"],
|
||||
sls_url: value["slsUrl"],
|
||||
audience: value["audience"],
|
||||
issuer: value["issuer"],
|
||||
issuer_override: value["issuerOverride"],
|
||||
assertion_valid_not_before: value["assertionValidNotBefore"],
|
||||
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
|
||||
@@ -81,11 +81,11 @@ export interface SAMLProviderRequest {
|
||||
*/
|
||||
audience?: string;
|
||||
/**
|
||||
* Also known as EntityID
|
||||
* Also known as EntityID. Providing a value overrides the default issuer generated by authentik.
|
||||
* @type {string}
|
||||
* @memberof SAMLProviderRequest
|
||||
*/
|
||||
issuer?: string;
|
||||
issuerOverride?: string;
|
||||
/**
|
||||
* Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).
|
||||
* @type {string}
|
||||
@@ -234,7 +234,7 @@ export function SAMLProviderRequestFromJSONTyped(
|
||||
acsUrl: json["acs_url"],
|
||||
slsUrl: json["sls_url"] == null ? undefined : json["sls_url"],
|
||||
audience: json["audience"] == null ? undefined : json["audience"],
|
||||
issuer: json["issuer"] == null ? undefined : json["issuer"],
|
||||
issuerOverride: json["issuer_override"] == null ? undefined : json["issuer_override"],
|
||||
assertionValidNotBefore:
|
||||
json["assertion_valid_not_before"] == null
|
||||
? undefined
|
||||
@@ -307,7 +307,7 @@ export function SAMLProviderRequestToJSONTyped(
|
||||
acs_url: value["acsUrl"],
|
||||
sls_url: value["slsUrl"],
|
||||
audience: value["audience"],
|
||||
issuer: value["issuer"],
|
||||
issuer_override: value["issuerOverride"],
|
||||
assertion_valid_not_before: value["assertionValidNotBefore"],
|
||||
assertion_valid_not_on_or_after: value["assertionValidNotOnOrAfter"],
|
||||
session_valid_not_on_or_after: value["sessionValidNotOnOrAfter"],
|
||||
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -221,6 +221,7 @@ export * from "./GoogleWorkspaceProviderMappingRequest";
|
||||
export * from "./GoogleWorkspaceProviderRequest";
|
||||
export * from "./GoogleWorkspaceProviderUser";
|
||||
export * from "./GoogleWorkspaceProviderUserRequest";
|
||||
export * from "./GrantTypesEnum";
|
||||
export * from "./Group";
|
||||
export * from "./GroupKerberosSourceConnection";
|
||||
export * from "./GroupKerberosSourceConnectionRequest";
|
||||
|
||||
@@ -9,7 +9,7 @@ dependencies = [
|
||||
"argon2-cffi==25.1.0",
|
||||
"cachetools==7.0.6",
|
||||
"channels==4.3.2",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==47.0.0",
|
||||
"dacite==1.9.2",
|
||||
"deepmerge==2.0",
|
||||
"defusedxml==0.7.1",
|
||||
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"kubernetes==35.0.0",
|
||||
"ldap3==2.9.1",
|
||||
"lxml==6.1.0",
|
||||
"msgraph-sdk==1.55.0",
|
||||
"msgraph-sdk==1.56.0",
|
||||
"opencontainers==0.0.15",
|
||||
"packaging==26.1",
|
||||
"paramiko==4.0.0",
|
||||
@@ -92,18 +92,18 @@ dev = [
|
||||
"importlib-metadata==8.7.1",
|
||||
"k5test==0.10.4",
|
||||
"lxml-stubs==0.5.1",
|
||||
"mypy==1.20.1",
|
||||
"mypy==1.20.2",
|
||||
"pdoc==16.0.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest-flakefinder==1.1.0",
|
||||
"pytest-github-actions-annotate-failures==0.4.0",
|
||||
"pytest-randomly==4.0.1",
|
||||
"pytest-randomly==4.1.0",
|
||||
"pytest-timeout==2.4.0",
|
||||
"pytest==9.0.3",
|
||||
"requests-mock==1.12.1",
|
||||
"ruff==0.15.11",
|
||||
"ruff==0.15.12",
|
||||
"selenium==4.43.0",
|
||||
"types-channels==4.3.0.20260408",
|
||||
"types-channels==4.3.0.20260421",
|
||||
"types-docker==7.1.0.20260409",
|
||||
"types-jwcrypto==1.5.7.20260409",
|
||||
"types-ldap3==2.9.13.20260408",
|
||||
|
||||
68
schema.yml
68
schema.yml
@@ -18919,7 +18919,7 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: issuer
|
||||
name: issuer_override
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
@@ -20078,7 +20078,7 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: issuer
|
||||
name: issuer_override
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
@@ -34111,6 +34111,9 @@ components:
|
||||
$ref: '#/components/schemas/PolicyEngineMode'
|
||||
group:
|
||||
type: string
|
||||
meta_hide:
|
||||
type: boolean
|
||||
description: Hide this application from the user's My applications page.
|
||||
required:
|
||||
- backchannel_providers_obj
|
||||
- launch_url
|
||||
@@ -34192,6 +34195,9 @@ components:
|
||||
$ref: '#/components/schemas/PolicyEngineMode'
|
||||
group:
|
||||
type: string
|
||||
meta_hide:
|
||||
type: boolean
|
||||
description: Hide this application from the user's My applications page.
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
@@ -36180,6 +36186,7 @@ components:
|
||||
- slack
|
||||
- sfdc
|
||||
- webex
|
||||
- vcenter
|
||||
type: string
|
||||
Config:
|
||||
type: object
|
||||
@@ -39976,6 +39983,16 @@ components:
|
||||
- google_id
|
||||
- provider
|
||||
- user
|
||||
GrantTypesEnum:
|
||||
enum:
|
||||
- authorization_code
|
||||
- implicit
|
||||
- hybrid
|
||||
- refresh_token
|
||||
- client_credentials
|
||||
- password
|
||||
- urn:ietf:params:oauth:grant-type:device_code
|
||||
type: string
|
||||
Group:
|
||||
type: object
|
||||
description: Group Serializer
|
||||
@@ -43635,6 +43652,10 @@ components:
|
||||
- $ref: '#/components/schemas/ClientTypeEnum'
|
||||
description: Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable
|
||||
grant_types:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GrantTypesEnum'
|
||||
client_id:
|
||||
type: string
|
||||
maxLength: 255
|
||||
@@ -43756,6 +43777,10 @@ components:
|
||||
- $ref: '#/components/schemas/ClientTypeEnum'
|
||||
description: Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable
|
||||
grant_types:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GrantTypesEnum'
|
||||
client_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -47409,6 +47434,9 @@ components:
|
||||
$ref: '#/components/schemas/PolicyEngineMode'
|
||||
group:
|
||||
type: string
|
||||
meta_hide:
|
||||
type: boolean
|
||||
description: Hide this application from the user's My applications page.
|
||||
PatchedAuthenticatorDuoStageRequest:
|
||||
type: object
|
||||
description: AuthenticatorDuoStage Serializer
|
||||
@@ -49323,6 +49351,10 @@ components:
|
||||
- $ref: '#/components/schemas/ClientTypeEnum'
|
||||
description: Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable
|
||||
grant_types:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GrantTypesEnum'
|
||||
client_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -50170,10 +50202,10 @@ components:
|
||||
type: string
|
||||
description: Value of the audience restriction field of the assertion. When
|
||||
left empty, no audience restriction will be added.
|
||||
issuer:
|
||||
issuer_override:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Also known as EntityID
|
||||
description: Also known as EntityID. Providing a value overrides the default
|
||||
issuer generated by authentik.
|
||||
assertion_valid_not_before:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -53692,9 +53724,10 @@ components:
|
||||
type: string
|
||||
description: Value of the audience restriction field of the assertion. When
|
||||
left empty, no audience restriction will be added.
|
||||
issuer:
|
||||
issuer_override:
|
||||
type: string
|
||||
description: Also known as EntityID
|
||||
description: Also known as EntityID. Providing a value overrides the default
|
||||
issuer generated by authentik.
|
||||
assertion_valid_not_before:
|
||||
type: string
|
||||
description: 'Assertion valid not before current time + this value (Format:
|
||||
@@ -53784,6 +53817,18 @@ components:
|
||||
type: string
|
||||
description: Get metadata download URL
|
||||
readOnly: true
|
||||
url_issuer:
|
||||
type: string
|
||||
description: Get Issuer/EntityID URL
|
||||
readOnly: true
|
||||
url_unified:
|
||||
type: string
|
||||
description: Get unified SAML endpoint URL (handles SSO and SLO)
|
||||
readOnly: true
|
||||
url_unified_init:
|
||||
type: string
|
||||
description: Get IdP-initiated SAML URL
|
||||
readOnly: true
|
||||
url_sso_post:
|
||||
type: string
|
||||
description: Get SSO Post URL
|
||||
@@ -53817,11 +53862,14 @@ components:
|
||||
- name
|
||||
- pk
|
||||
- url_download_metadata
|
||||
- url_issuer
|
||||
- url_slo_post
|
||||
- url_slo_redirect
|
||||
- url_sso_init
|
||||
- url_sso_post
|
||||
- url_sso_redirect
|
||||
- url_unified
|
||||
- url_unified_init
|
||||
- verbose_name
|
||||
- verbose_name_plural
|
||||
SAMLProviderImportRequest:
|
||||
@@ -53884,10 +53932,10 @@ components:
|
||||
type: string
|
||||
description: Value of the audience restriction field of the assertion. When
|
||||
left empty, no audience restriction will be added.
|
||||
issuer:
|
||||
issuer_override:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Also known as EntityID
|
||||
description: Also known as EntityID. Providing a value overrides the default
|
||||
issuer generated by authentik.
|
||||
assertion_valid_not_before:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user