Compare commits

..

3 Commits

Author SHA1 Message Date
929e42d3f2 fix other issues
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-10-15 14:51:20 +02:00
863958b4d6 make sure we don't break something else
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-10-15 14:22:25 +02:00
2249b9307e core: expiring model: don't synchronously delete
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-10-15 14:15:24 +02:00
372 changed files with 2370 additions and 18629 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2024.10.3
current_version = 2024.8.3
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

@ -14,7 +14,7 @@ runs:
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v5
with:

View File

@ -180,7 +180,7 @@ jobs:
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
docker compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v4
with:

View File

@ -6,7 +6,6 @@
"authn",
"entra",
"goauthentik",
"jwe",
"jwks",
"kubernetes",
"oidc",

View File

@ -110,7 +110,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev libkrb5-dev
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \
@ -141,7 +141,7 @@ WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \
# Required for runtime
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
apt-get clean && \
@ -161,7 +161,6 @@ COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/venv /ak-root/venv
COPY --from=web-builder /work/web/dist/ /web/dist/

View File

@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version)
| Version | Supported |
| --------- | --------- |
| 2024.8.x | ✅ |
| 2024.10.x | ✅ |
| Version | Supported |
| -------- | --------- |
| 2024.6.x | ✅ |
| 2024.8.x | ✅ |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2024.10.3"
__version__ = "2024.8.3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,33 +0,0 @@
from rest_framework.permissions import IsAdminUser
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.admin.models import VersionHistory
from authentik.core.api.utils import ModelSerializer
class VersionHistorySerializer(ModelSerializer):
"""VersionHistory Serializer"""
class Meta:
model = VersionHistory
fields = [
"id",
"timestamp",
"version",
"build",
]
class VersionHistoryViewSet(ReadOnlyModelViewSet):
"""VersionHistory Viewset"""
queryset = VersionHistory.objects.all()
serializer_class = VersionHistorySerializer
permission_classes = [IsAdminUser]
filterset_fields = [
"version",
"build",
]
search_fields = ["version", "build"]
ordering = ["-timestamp"]
pagination_class = None

View File

@ -1,22 +0,0 @@
"""authentik admin models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class VersionHistory(models.Model):
id = models.BigAutoField(primary_key=True)
timestamp = models.DateTimeField()
version = models.TextField()
build = models.TextField()
class Meta:
managed = False
db_table = "authentik_version_history"
ordering = ("-timestamp",)
verbose_name = _("Version history")
verbose_name_plural = _("Version history")
default_permissions = []
def __str__(self):
return f"{self.version}.{self.build} ({self.timestamp})"

View File

@ -6,7 +6,6 @@ from authentik.admin.api.meta import AppsViewSet, ModelViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView
from authentik.admin.api.version import VersionView
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.admin.api.workers import WorkerView
api_urlpatterns = [
@ -18,7 +17,6 @@ api_urlpatterns = [
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"),
("admin/version/history", VersionHistoryViewSet, "version_history"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
path("admin/system/", SystemView.as_view(), name="admin_system"),
]

View File

@ -7,7 +7,7 @@ API Browser - {{ brand.branding_title }}
{% endblock %}
{% block head %}
<script src="{% versioned_script 'dist/standalone/api-browser/index-%v.js' %}" type="module"></script>
{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
{% endblock %}

View File

@ -27,8 +27,7 @@ def blueprint_tester(file_name: Path) -> Callable:
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
validation, logs = importer.validate()
self.assertTrue(validation, logs)
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
return tester

View File

@ -51,10 +51,6 @@ from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
from authentik.events.utils import cleanse_dict
@ -123,8 +119,6 @@ def excluded_models() -> list[type[Model]]:
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
)

View File

@ -4,7 +4,7 @@ from collections.abc import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import override
from django.utils.translation import activate
from authentik.brands.utils import get_brand_for_request
@ -18,12 +18,10 @@ class BrandMiddleware:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
locale_to_set = None
if not hasattr(request, "brand"):
brand = get_brand_for_request(request)
request.brand = brand
locale = brand.default_locale
if locale != "":
locale_to_set = locale
with override(locale_to_set):
return self.get_response(request)
activate(locale)
return self.get_response(request)

View File

@ -1,55 +1,39 @@
"""Authenticator Devices API Views"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import (
BooleanField,
CharField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.rbac.decorators import permission_required
from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceSerializer(MetaNameSerializer):
"""Serializer for Duo authenticator devices"""
pk = CharField()
pk = IntegerField()
name = CharField()
type = SerializerMethodField()
confirmed = BooleanField()
created = DateTimeField(read_only=True)
last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True)
extra_description = SerializerMethodField()
def get_type(self, instance: Device) -> str:
"""Get type of device"""
return instance._meta.label
def get_extra_description(self, instance: Device) -> str:
"""Get extra description"""
if isinstance(instance, WebAuthnDevice):
return (
instance.device_type.description
if instance.device_type
else _("Extra description not available")
)
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return ""
class DeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
@ -68,7 +52,7 @@ class AdminDeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = []
permission_classes = [IsAdminUser]
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
@ -86,10 +70,6 @@ class AdminDeviceViewSet(ViewSet):
],
responses={200: DeviceSerializer(many=True)},
)
@permission_required(
None,
[f"{model._meta.app_label}.view_{model._meta.model_name}" for model in device_classes()],
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}

View File

@ -4,7 +4,6 @@ import code
import platform
import sys
import traceback
from pprint import pprint
from django.apps import apps
from django.core.management.base import BaseCommand
@ -35,9 +34,7 @@ class Command(BaseCommand):
def get_namespace(self):
"""Prepare namespace with all models"""
namespace = {
"pprint": pprint,
}
namespace = {}
# Gather Django models and constants from each app
for app in apps.get_app_configs():

View File

@ -5,7 +5,7 @@ from contextvars import ContextVar
from uuid import uuid4
from django.http import HttpRequest, HttpResponse
from django.utils.translation import override
from django.utils.translation import activate
from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@ -31,19 +31,17 @@ class ImpersonateMiddleware:
def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before
# SESSION_KEY_IMPERSONATE_USER is set.
locale_to_set = None
if request.user.is_authenticated:
locale = request.user.locale(request)
if locale != "":
locale_to_set = locale
activate(locale)
if SESSION_KEY_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
# Ensure that the user is active, otherwise nothing will work
request.user.is_active = True
with override(locale_to_set):
return self.get_response(request)
return self.get_response(request)
class RequestIDMiddleware:

View File

@ -330,13 +330,11 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser # type: ignore
def set_password(self, raw_password, signal=True, sender=None):
def set_password(self, raw_password, signal=True):
if self.pk and signal:
from authentik.core.signals import password_changed
if not sender:
sender = self
password_changed.send(sender=sender, user=self, password=raw_password)
password_changed.send(sender=self, user=self, password=raw_password)
self.password_change_date = now()
return super().set_password(raw_password)
@ -804,12 +802,25 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
def _not_expired_filter(cls):
return Q(expires__gt=now(), expiring=True) | Q(expiring=False)
@classmethod
def filter_not_expired(cls, delete_expired=False, **kwargs) -> QuerySet["ExpiringModel"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
obj.delete()
return cls.objects.filter(**kwargs)
if delete_expired:
cls.delete_expired(**kwargs)
return cls.objects.filter(cls._not_expired_filter()).filter(**kwargs)
@classmethod
def delete_expired(cls, **kwargs) -> int:
objects = cls.objects.all().exclude(cls._not_expired_filter()).filter(**kwargs)
amount = 0
for obj in objects:
obj.expire_action()
amount += 1
return amount
@property
def is_expired(self) -> bool:

View File

@ -1,9 +1,11 @@
"""Source decision helper"""
from enum import Enum
from typing import Any
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.db.models.query_utils import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
@ -14,11 +16,12 @@ from authentik.core.models import (
Group,
GroupSourceConnection,
Source,
SourceGroupMatchingModes,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
from authentik.core.sources.mapper import SourceMapper
from authentik.core.sources.matcher import Action, SourceMatcher
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostSourceStage,
@ -51,6 +54,16 @@ SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
class Action(Enum):
"""Actions that can be decided based on the request
and source settings"""
LINK = "link"
AUTH = "auth"
ENROLL = "enroll"
DENY = "deny"
class MessageStage(StageView):
"""Show a pre-configured message after the flow is done"""
@ -73,7 +86,6 @@ class SourceFlowManager:
source: Source
mapper: SourceMapper
matcher: SourceMatcher
request: HttpRequest
identifier: str
@ -96,9 +108,6 @@ class SourceFlowManager:
) -> None:
self.source = source
self.mapper = SourceMapper(self.source)
self.matcher = SourceMatcher(
self.source, self.user_connection_type, self.group_connection_type
)
self.request = request
self.identifier = identifier
self.user_info = user_info
@ -122,24 +131,66 @@ class SourceFlowManager:
def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
"""decide which action should be taken"""
new_connection = self.user_connection_type(source=self.source, identifier=self.identifier)
# When request is authenticated, always link
if self.request.user.is_authenticated:
new_connection = self.user_connection_type(
source=self.source, identifier=self.identifier
)
new_connection.user = self.request.user
new_connection = self.update_user_connection(new_connection, **kwargs)
if existing := self.user_connection_type.objects.filter(
source=self.source, identifier=self.identifier
).first():
existing = self.update_user_connection(existing)
return Action.AUTH, existing
return Action.LINK, new_connection
action, connection = self.matcher.get_user_action(self.identifier, self.user_properties)
if connection:
connection = self.update_user_connection(connection, **kwargs)
return action, connection
existing_connections = self.user_connection_type.objects.filter(
source=self.source, identifier=self.identifier
)
if existing_connections.exists():
connection = existing_connections.first()
return Action.AUTH, self.update_user_connection(connection, **kwargs)
# No connection exists, but we match on identifier, so enroll
if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
# Check for existing users with matching attributes
query = Q()
# Either query existing user based on email or username
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.EMAIL_DENY,
]:
if not self.user_properties.get("email", None):
self._logger.warning("Refusing to use none email")
return Action.DENY, None
query = Q(email__exact=self.user_properties.get("email", None))
if self.source.user_matching_mode in [
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
]:
if not self.user_properties.get("username", None):
self._logger.warning("Refusing to use none username")
return Action.DENY, None
query = Q(username__exact=self.user_properties.get("username", None))
self._logger.debug("trying to link with existing user", query=query)
matching_users = User.objects.filter(query)
# No matching users, always enroll
if not matching_users.exists():
self._logger.debug("no matching users found, enrolling")
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
user = matching_users.first()
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.USERNAME_LINK,
]:
new_connection.user = user
new_connection = self.update_user_connection(new_connection, **kwargs)
return Action.LINK, new_connection
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_DENY,
SourceUserMatchingModes.USERNAME_DENY,
]:
self._logger.info("denying source because user exists", user=user)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def update_user_connection(
self, connection: UserSourceConnection, **kwargs
@ -277,6 +328,7 @@ class SourceFlowManager:
connection: UserSourceConnection,
) -> HttpResponse:
"""Login user and redirect."""
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
return self._prepare_flow(
self.source.authentication_flow,
connection,
@ -290,11 +342,7 @@ class SourceFlowManager:
),
)
],
**{
PLAN_CONTEXT_PENDING_USER: connection.user,
PLAN_CONTEXT_PROMPT: delete_none_values(self.user_properties),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
},
**flow_kwargs,
)
def handle_existing_link(
@ -360,16 +408,74 @@ class SourceFlowManager:
class GroupUpdateStage(StageView):
"""Dynamically injected stage which updates the user after enrollment/authentication."""
def get_action(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, GroupSourceConnection | None]:
"""decide which action should be taken"""
new_connection = self.group_connection_type(source=self.source, identifier=group_id)
existing_connections = self.group_connection_type.objects.filter(
source=self.source, identifier=group_id
)
if existing_connections.exists():
return Action.LINK, existing_connections.first()
# No connection exists, but we match on identifier, so enroll
if self.source.group_matching_mode == SourceGroupMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, new_connection
# Check for existing groups with matching attributes
query = Q()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
SourceGroupMatchingModes.NAME_DENY,
]:
if not group_properties.get("name", None):
LOGGER.warning(
"Refusing to use none group name", source=self.source, group_id=group_id
)
return Action.DENY, None
query = Q(name__exact=group_properties.get("name"))
LOGGER.debug(
"trying to link with existing group", source=self.source, query=query, group_id=group_id
)
matching_groups = Group.objects.filter(query)
# No matching groups, always enroll
if not matching_groups.exists():
LOGGER.debug(
"no matching groups found, enrolling", source=self.source, group_id=group_id
)
return Action.ENROLL, new_connection
group = matching_groups.first()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
]:
new_connection.group = group
return Action.LINK, new_connection
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_DENY,
]:
LOGGER.info(
"denying source because group exists",
source=self.source,
group=group,
group_id=group_id,
)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def handle_group(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> Group | None:
action, connection = self.matcher.get_group_action(group_id, group_properties)
action, connection = self.get_action(group_id, group_properties)
if action == Action.ENROLL:
group = Group.objects.create(**group_properties)
connection.group = group
connection.save()
return group
elif action in (Action.LINK, Action.AUTH):
elif action == Action.LINK:
group = connection.group
group.update_attributes(group_properties)
connection.save()
@ -383,7 +489,6 @@ class GroupUpdateStage(StageView):
self.group_connection_type: GroupSourceConnection = (
self.executor.current_stage.group_connection_type
)
self.matcher = SourceMatcher(self.source, None, self.group_connection_type)
raw_groups: dict[str, dict[str, Any | dict[str, Any]]] = self.executor.plan.context[
PLAN_CONTEXT_SOURCE_GROUPS

View File

@ -1,152 +0,0 @@
"""Source user and group matching"""
from dataclasses import dataclass
from enum import Enum
from typing import Any
from django.db.models import Q
from structlog import get_logger
from authentik.core.models import (
Group,
GroupSourceConnection,
Source,
SourceGroupMatchingModes,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
class Action(Enum):
"""Actions that can be decided based on the request and source settings"""
LINK = "link"
AUTH = "auth"
ENROLL = "enroll"
DENY = "deny"
@dataclass
class MatchableProperty:
property: str
link_mode: SourceUserMatchingModes | SourceGroupMatchingModes
deny_mode: SourceUserMatchingModes | SourceGroupMatchingModes
class SourceMatcher:
def __init__(
self,
source: Source,
user_connection_type: type[UserSourceConnection],
group_connection_type: type[GroupSourceConnection],
):
self.source = source
self.user_connection_type = user_connection_type
self.group_connection_type = group_connection_type
self._logger = get_logger().bind(source=self.source)
def get_action(
self,
object_type: type[User | Group],
matchable_properties: list[MatchableProperty],
identifier: str,
properties: dict[str, Any | dict[str, Any]],
) -> tuple[Action, UserSourceConnection | GroupSourceConnection | None]:
connection_type = None
matching_mode = None
identifier_matching_mode = None
if object_type == User:
connection_type = self.user_connection_type
matching_mode = self.source.user_matching_mode
identifier_matching_mode = SourceUserMatchingModes.IDENTIFIER
if object_type == Group:
connection_type = self.group_connection_type
matching_mode = self.source.group_matching_mode
identifier_matching_mode = SourceGroupMatchingModes.IDENTIFIER
if not connection_type or not matching_mode or not identifier_matching_mode:
return Action.DENY, None
new_connection = connection_type(source=self.source, identifier=identifier)
existing_connections = connection_type.objects.filter(
source=self.source, identifier=identifier
)
if existing_connections.exists():
return Action.AUTH, existing_connections.first()
# No connection exists, but we match on identifier, so enroll
if matching_mode == identifier_matching_mode:
# We don't save the connection here cause it doesn't have a user/group assigned yet
return Action.ENROLL, new_connection
# Check for existing users with matching attributes
query = Q()
for matchable_property in matchable_properties:
property = matchable_property.property
if matching_mode in [matchable_property.link_mode, matchable_property.deny_mode]:
if not properties.get(property, None):
self._logger.warning(
"Refusing to use none property", identifier=identifier, property=property
)
return Action.DENY, None
query_args = {
f"{property}__exact": properties[property],
}
query = Q(**query_args)
self._logger.debug(
"Trying to link with existing object", query=query, identifier=identifier
)
matching_objects = object_type.objects.filter(query)
# Not matching objects, always enroll
if not matching_objects.exists():
self._logger.debug("No matching objects found, enrolling")
return Action.ENROLL, new_connection
obj = matching_objects.first()
if matching_mode in [mp.link_mode for mp in matchable_properties]:
attr = None
if object_type == User:
attr = "user"
if object_type == Group:
attr = "group"
setattr(new_connection, attr, obj)
return Action.LINK, new_connection
if matching_mode in [mp.deny_mode for mp in matchable_properties]:
self._logger.info("Denying source because object exists", obj=obj)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def get_user_action(
self, identifier: str, properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, UserSourceConnection | None]:
return self.get_action(
User,
[
MatchableProperty(
"username",
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
),
MatchableProperty(
"email", SourceUserMatchingModes.EMAIL_LINK, SourceUserMatchingModes.EMAIL_DENY
),
],
identifier,
properties,
)
def get_group_action(
self, identifier: str, properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, GroupSourceConnection | None]:
return self.get_action(
Group,
[
MatchableProperty(
"name", SourceGroupMatchingModes.NAME_LINK, SourceGroupMatchingModes.NAME_DENY
),
],
identifier,
properties,
)

View File

@ -30,12 +30,7 @@ def clean_expired_models(self: SystemTask):
messages = []
for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel
objects = (
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
)
amount = objects.count()
for obj in objects:
obj.expire_action()
amount = cls.delete_expired()
LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case

View File

@ -15,8 +15,8 @@
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
{% versioned_script "dist/poly-%v.js" %}
{% versioned_script "dist/standalone/loading/index-%v.js" %}
{% block head %}
{% endblock %}
<meta name="sentry-trace" content="{{ sentry_trace }}" />

View File

@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
{% versioned_script "dist/admin/AdminInterface-%v.js" %}
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
{% include "base/header_js.html" %}

View File

@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
<script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script>
{% versioned_script "dist/user/UserInterface-%v.js" %}
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
{% include "base/header_js.html" %}

View File

@ -2,6 +2,7 @@
from django import template
from django.templatetags.static import static as static_loader
from django.utils.safestring import mark_safe
from authentik import get_full_version
@ -11,4 +12,10 @@ register = template.Library()
@register.simple_tag()
def versioned_script(path: str) -> str:
"""Wrapper around {% static %} tag that supports setting the version"""
return static_loader(path.replace("%v", get_full_version()))
returned_lines = [
(
f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
'" type="module"></script>'
),
]
return mark_safe("".join(returned_lines)) # nosec

View File

@ -12,7 +12,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.saml.models import SAMLProvider
@ -24,7 +24,7 @@ class TestApplicationsAPI(APITestCase):
self.user = create_test_admin_user()
self.provider = OAuth2Provider.objects.create(
name="test",
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
redirect_uris="http://some-other-domain",
authorization_flow=create_test_flow(),
)
self.allowed: Application = Application.objects.create(

View File

@ -1,59 +0,0 @@
"""Test Devices API"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_user
class TestDevicesAPI(APITestCase):
"""Test applications API"""
def setUp(self) -> None:
self.admin = create_test_admin_user()
self.user1 = create_test_user()
self.device1 = self.user1.staticdevice_set.create()
self.user2 = create_test_user()
self.device2 = self.user2.staticdevice_set.create()
def test_user_api(self):
"""Test user API"""
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"authentik_api:device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["pk"], str(self.device1.pk))
def test_user_api_as_admin(self):
"""Test user API"""
self.client.force_login(self.admin)
response = self.client.get(
reverse(
"authentik_api:device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 0)
def test_admin_api(self):
"""Test admin API"""
self.client.force_login(self.admin)
response = self.client.get(
reverse(
"authentik_api:admin-device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 2)
self.assertEqual(
{body[0]["pk"], body[1]["pk"]}, {str(self.device1.pk), str(self.device2.pk)}
)

View File

@ -81,22 +81,6 @@ class TestSourceFlowManager(TestCase):
reverse("authentik_core:if-user") + "#/settings;page-sources",
)
def test_authenticated_auth(self):
"""Test authenticated user linking"""
user = User.objects.create(username="foo", email="foo@bar.baz")
UserOAuthSourceConnection.objects.create(
user=user, source=self.source, identifier=self.identifier
)
request = get_request("/", user=user)
flow_manager = OAuthSourceFlowManager(
self.source, request, self.identifier, {"info": {}}, {}
)
action, connection = flow_manager.get_action()
self.assertEqual(action, Action.AUTH)
self.assertIsNotNone(connection.pk)
response = flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
def test_unauthenticated_link(self):
"""Test un-authenticated user linking"""
flow_manager = OAuthSourceFlowManager(

View File

@ -31,7 +31,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
"name": uid,
"authorization_flow": str(create_test_flow().pk),
"invalidation_flow": str(create_test_flow().pk),
"redirect_uris": [],
},
},
)
@ -58,7 +57,6 @@ class TestTransactionalApplicationsAPI(APITestCase):
"name": uid,
"authorization_flow": "",
"invalidation_flow": "",
"redirect_uris": [],
},
},
)

View File

@ -5,6 +5,7 @@ from channels.sessions import CookieMiddleware
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
@ -43,19 +44,19 @@ urlpatterns = [
# Interfaces
path(
"if/admin/",
BrandDefaultRedirectView.as_view(template_name="if/admin.html"),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
BrandDefaultRedirectView.as_view(template_name="if/user.html"),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")),
name="if-user",
),
path(
"if/flow/<slug:flow_slug>/",
# FIXME: move this url to the flows app...also will cause all
# of the reverse calls to be adjusted
FlowInterfaceView.as_view(),
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
# Fallback for WS

View File

@ -24,7 +24,6 @@ from rest_framework.fields import (
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
@ -182,10 +181,7 @@ class CertificateDataSerializer(PassiveSerializer):
class CertificateGenerationSerializer(PassiveSerializer):
"""Certificate generation parameters"""
common_name = CharField(
validators=[UniqueValidator(queryset=CertificateKeyPair.objects.all())],
source="name",
)
common_name = CharField()
subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name"))
validity_days = IntegerField(initial=365)
alg = ChoiceField(default=PrivateKeyAlg.RSA, choices=PrivateKeyAlg.choices)
@ -246,10 +242,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def generate(self, request: Request) -> Response:
"""Generate a new, self-signed certificate-key pair"""
data = CertificateGenerationSerializer(data=request.data)
data.is_valid(raise_exception=True)
if not data.is_valid():
return Response(data.errors, status=400)
raw_san = data.validated_data.get("subject_alt_name", "")
sans = raw_san.split(",") if raw_san != "" else []
builder = CertificateBuilder(data.validated_data["name"])
builder = CertificateBuilder(data.validated_data["common_name"])
builder.alg = data.validated_data["alg"]
builder.build(
subject_alt_names=sans,

View File

@ -18,7 +18,7 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id, generate_key
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.oauth2.models import OAuth2Provider
class TestCrypto(APITestCase):
@ -89,17 +89,6 @@ class TestCrypto(APITestCase):
self.assertIsInstance(ext[1], DNSName)
self.assertEqual(ext[1].value, "baz")
def test_builder_api_duplicate(self):
"""Test Builder (via API)"""
cert = create_test_cert()
self.client.force_login(create_test_admin_user())
res = self.client.post(
reverse("authentik_api:certificatekeypair-generate"),
data={"common_name": cert.name, "subject_alt_name": "bar,baz", "validity_days": 3},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(res.content, {"common_name": ["This field must be unique."]})
def test_builder_api_empty_san(self):
"""Test Builder (via API)"""
self.client.force_login(create_test_admin_user())
@ -274,7 +263,7 @@ class TestCrypto(APITestCase):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=keypair,
)
response = self.client.get(
@ -306,7 +295,7 @@ class TestCrypto(APITestCase):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=keypair,
)
response = self.client.get(

View File

@ -16,28 +16,13 @@ class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
class Meta:
model = RACProvider
fields = [
"pk",
"name",
"authentication_flow",
"authorization_flow",
"property_mappings",
"component",
"assigned_application_slug",
"assigned_application_name",
"assigned_backchannel_application_slug",
"assigned_backchannel_application_name",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
fields = ProviderSerializer.Meta.fields + [
"settings",
"outpost_set",
"connection_expiry",
"delete_token_on_disconnect",
]
extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
}
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
class RACProviderViewSet(UsedByMixin, ModelViewSet):

View File

@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
{% versioned_script "dist/enterprise/rac/index-%v.js" %}
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon }}">

View File

@ -1,46 +0,0 @@
"""Test RAC Provider"""
from datetime import timedelta
from time import mktime
from unittest.mock import MagicMock, patch
from django.urls import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import License
from authentik.lib.generators import generate_id
class TestAPI(APITestCase):
"""Test Provider API"""
def setUp(self) -> None:
self.user = create_test_admin_user()
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
def test_create(self):
"""Test creation of RAC Provider"""
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:racprovider-list"),
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
},
)
self.assertEqual(response.status_code, 201)

View File

@ -68,6 +68,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
@ -120,6 +121,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
@ -149,6 +151,7 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,

View File

@ -3,6 +3,7 @@
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
@ -18,12 +19,12 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
path(
"application/rac/<slug:app>/<uuid:endpoint>/",
RACStartView.as_view(),
ensure_csrf_cookie(RACStartView.as_view()),
name="start",
),
path(
"if/rac/<str:token>/",
RACInterface.as_view(),
ensure_csrf_cookie(RACInterface.as_view()),
name="if-rac",
),
]

View File

@ -17,7 +17,6 @@ TENANT_APPS = [
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.source",
]

View File

@ -1,82 +0,0 @@
"""AuthenticatorEndpointGDTCStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAdminUser
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,
EndpointDevice,
)
from authentik.flows.api.stages import StageSerializer
LOGGER = get_logger()
class AuthenticatorEndpointGDTCStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""AuthenticatorEndpointGDTCStage Serializer"""
class Meta:
model = AuthenticatorEndpointGDTCStage
fields = StageSerializer.Meta.fields + [
"configure_flow",
"friendly_name",
"credentials",
]
class AuthenticatorEndpointGDTCStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticatorEndpointGDTCStage Viewset"""
queryset = AuthenticatorEndpointGDTCStage.objects.all()
serializer_class = AuthenticatorEndpointGDTCStageSerializer
filterset_fields = [
"name",
"configure_flow",
]
search_fields = ["name"]
ordering = ["name"]
class EndpointDeviceSerializer(ModelSerializer):
"""Serializer for Endpoint authenticator devices"""
class Meta:
model = EndpointDevice
fields = ["pk", "name"]
depth = 2
class EndpointDeviceViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
UsedByMixin,
GenericViewSet,
):
"""Viewset for Endpoint authenticator devices"""
queryset = EndpointDevice.objects.all()
serializer_class = EndpointDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
class EndpointAdminDeviceViewSet(ModelViewSet):
"""Viewset for Endpoint authenticator devices (for admins)"""
permission_classes = [IsAdminUser]
queryset = EndpointDevice.objects.all()
serializer_class = EndpointDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]

View File

@ -1,13 +0,0 @@
"""authentik Endpoint app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikStageAuthenticatorEndpointConfig(EnterpriseConfig):
"""authentik endpoint config"""
name = "authentik.enterprise.stages.authenticator_endpoint_gdtc"
label = "authentik_stages_authenticator_endpoint_gdtc"
verbose_name = "authentik Enterprise.Stages.Authenticator.Endpoint GDTC"
default = True
mountpoint = "endpoint/gdtc/"

View File

@ -1,115 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-22 11:40
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0027_auto_20231028_1424"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AuthenticatorEndpointGDTCStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
("friendly_name", models.TextField(null=True)),
("credentials", models.JSONField()),
(
"configure_flow",
models.ForeignKey(
blank=True,
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Endpoint Authenticator Google Device Trust Connector Stage",
"verbose_name_plural": "Endpoint Authenticator Google Device Trust Connector Stages",
},
bases=("authentik_flows.stage", models.Model),
),
migrations.CreateModel(
name="EndpointDevice",
fields=[
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
help_text="The human-readable name of this device.", max_length=64
),
),
(
"confirmed",
models.BooleanField(default=True, help_text="Is this device ready for use?"),
),
("last_used", models.DateTimeField(null=True)),
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
(
"host_identifier",
models.TextField(
help_text="A unique identifier for the endpoint device, usually the device serial number",
unique=True,
),
),
("data", models.JSONField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Endpoint Device",
"verbose_name_plural": "Endpoint Devices",
},
),
migrations.CreateModel(
name="EndpointDeviceConnection",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("attributes", models.JSONField()),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_stages_authenticator_endpoint_gdtc.endpointdevice",
),
),
(
"stage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
),
),
],
),
]

View File

@ -1,101 +0,0 @@
"""Endpoint stage"""
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from google.oauth2.service_account import Credentials
from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.flows.stage import StageView
from authentik.lib.models import SerializerModel
from authentik.stages.authenticator.models import Device
class AuthenticatorEndpointGDTCStage(ConfigurableStage, FriendlyNamedStage, Stage):
"""Setup Google Chrome Device-trust connection"""
credentials = models.JSONField()
def google_credentials(self):
return {
"credentials": Credentials.from_service_account_info(
self.credentials, scopes=["https://www.googleapis.com/auth/verifiedaccess"]
),
}
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
AuthenticatorEndpointGDTCStageSerializer,
)
return AuthenticatorEndpointGDTCStageSerializer
@property
def view(self) -> type[StageView]:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.stage import (
AuthenticatorEndpointStageView,
)
return AuthenticatorEndpointStageView
@property
def component(self) -> str:
return "ak-stage-authenticator-endpoint-gdtc-form"
def ui_user_settings(self) -> UserSettingSerializer | None:
return UserSettingSerializer(
data={
"title": self.friendly_name or str(self._meta.verbose_name),
"component": "ak-user-settings-authenticator-endpoint",
}
)
def __str__(self) -> str:
return f"Endpoint Authenticator Google Device Trust Connector Stage {self.name}"
class Meta:
verbose_name = _("Endpoint Authenticator Google Device Trust Connector Stage")
verbose_name_plural = _("Endpoint Authenticator Google Device Trust Connector Stages")
class EndpointDevice(SerializerModel, Device):
"""Endpoint Device for a single user"""
uuid = models.UUIDField(primary_key=True, default=uuid4)
host_identifier = models.TextField(
unique=True,
help_text="A unique identifier for the endpoint device, usually the device serial number",
)
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
data = models.JSONField()
@property
def serializer(self) -> Serializer:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
EndpointDeviceSerializer,
)
return EndpointDeviceSerializer
def __str__(self):
return str(self.name) or str(self.user_id)
class Meta:
verbose_name = _("Endpoint Device")
verbose_name_plural = _("Endpoint Devices")
class EndpointDeviceConnection(models.Model):
device = models.ForeignKey(EndpointDevice, on_delete=models.CASCADE)
stage = models.ForeignKey(AuthenticatorEndpointGDTCStage, on_delete=models.CASCADE)
attributes = models.JSONField()
def __str__(self) -> str:
return f"Endpoint device connection {self.device_id} to {self.stage_id}"

View File

@ -1,32 +0,0 @@
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
FrameChallenge,
FrameChallengeResponse,
)
from authentik.flows.stage import ChallengeStageView
class AuthenticatorEndpointStageView(ChallengeStageView):
"""Endpoint stage"""
response_class = FrameChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
return FrameChallenge(
data={
"component": "xak-flow-frame",
"url": self.request.build_absolute_uri(
reverse("authentik_stages_authenticator_endpoint_gdtc:chrome")
),
"loading_overlay": True,
"loading_text": _("Verifying your browser..."),
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@ -1,9 +0,0 @@
<html>
<script>
window.parent.postMessage({
message: "submit",
source: "goauthentik.io",
context: "flow-executor"
});
</script>
</html>

View File

@ -1,26 +0,0 @@
"""API URLs"""
from django.urls import path
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
AuthenticatorEndpointGDTCStageViewSet,
EndpointAdminDeviceViewSet,
EndpointDeviceViewSet,
)
from authentik.enterprise.stages.authenticator_endpoint_gdtc.views.dtc import (
GoogleChromeDeviceTrustConnector,
)
urlpatterns = [
path("chrome/", GoogleChromeDeviceTrustConnector.as_view(), name="chrome"),
]
api_urlpatterns = [
("authenticators/endpoint", EndpointDeviceViewSet),
(
"authenticators/admin/endpoint",
EndpointAdminDeviceViewSet,
"admin-endpointdevice",
),
("stages/authenticator/endpoint_gdtc", AuthenticatorEndpointGDTCStageViewSet),
]

View File

@ -1,84 +0,0 @@
from json import dumps, loads
from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.views import View
from googleapiclient.discovery import build
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
# Header we get from chrome that initiates verified access
HEADER_DEVICE_TRUST = "X-Device-Trust"
# Header we send to the client with the challenge
HEADER_ACCESS_CHALLENGE = "X-Verified-Access-Challenge"
# Header we get back from the client that we verify with google
HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
# Header value for x-device-trust that initiates the flow
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
class GoogleChromeDeviceTrustConnector(View):
"""Google Chrome Device-trust connector based endpoint authenticator"""
def get_flow_plan(self) -> FlowPlan:
flow_plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
return flow_plan
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
stage: AuthenticatorEndpointGDTCStage = self.get_flow_plan().bindings[0].stage
self.google_client = build(
"verifiedaccess",
"v2",
cache_discovery=False,
**stage.google_credentials(),
)
def get(self, request: HttpRequest) -> HttpResponse:
x_device_trust = request.headers.get(HEADER_DEVICE_TRUST)
x_access_challenge_response = request.headers.get(HEADER_ACCESS_CHALLENGE_RESPONSE)
if x_device_trust == "VerifiedAccess" and x_access_challenge_response is None:
challenge = self.google_client.challenge().generate().execute()
res = HttpResponseRedirect(
self.request.build_absolute_uri(
reverse("authentik_stages_authenticator_endpoint_gdtc:chrome")
)
)
res[HEADER_ACCESS_CHALLENGE] = dumps(challenge)
return res
if x_access_challenge_response:
response = (
self.google_client.challenge()
.verify(body=loads(x_access_challenge_response))
.execute()
)
# Remove deprecated string representation of deviceSignals
response.pop("deviceSignal", None)
flow_plan: FlowPlan = self.get_flow_plan()
device, _ = EndpointDevice.objects.update_or_create(
host_identifier=response["deviceSignals"]["serialNumber"],
user=flow_plan.context.get(PLAN_CONTEXT_PENDING_USER),
defaults={"name": response["deviceSignals"]["hostname"], "data": response},
)
EndpointDeviceConnection.objects.update_or_create(
device=device,
stage=flow_plan.bindings[0].stage,
defaults={
"attributes": response,
},
)
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, "trusted_endpoint")
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault("endpoints", [])
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS]["endpoints"].append(response)
request.session[SESSION_KEY_PLAN] = flow_plan
return TemplateResponse(request, "stages/authenticator_endpoint/google_chrome_dtc.html")

View File

@ -1,16 +1,13 @@
"""authentik events signal listener"""
from importlib import import_module
from typing import Any
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User
from authentik.core.models import User
from authentik.core.signals import login_failed, password_changed
from authentik.events.apps import SYSTEM_TASK_STATUS
from authentik.events.models import Event, EventAction, SystemTask
@ -26,7 +23,6 @@ from authentik.stages.user_write.signals import user_write
from authentik.tenants.utils import get_current_tenant
SESSION_LOGIN_EVENT = "login_event"
_session_engine = import_module(settings.SESSION_ENGINE)
@receiver(user_logged_in)
@ -47,20 +43,11 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
kwargs[PLAN_CONTEXT_OUTPOST] = flow_plan.context[PLAN_CONTEXT_OUTPOST]
event = Event.new(EventAction.LOGIN, **kwargs).from_http(request, user=user)
request.session[SESSION_LOGIN_EVENT] = event
request.session.save()
def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | None) -> Event | None:
def get_login_event(request: HttpRequest) -> Event | None:
"""Wrapper to get login event that can be mocked in tests"""
session = None
if not request_or_session:
return None
if isinstance(request_or_session, HttpRequest | Request):
session = request_or_session.session
if isinstance(request_or_session, AuthenticatedSession):
SessionStore = _session_engine.SessionStore
session = SessionStore(request_or_session.session_key)
return session.get(SESSION_LOGIN_EVENT, None)
return request.session.get(SESSION_LOGIN_EVENT, None)
@receiver(user_logged_out)

View File

@ -8,7 +8,7 @@ from uuid import UUID
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.http import JsonResponse
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField
from rest_framework.fields import CharField, ChoiceField, DictField
from rest_framework.request import Request
from authentik.core.api.utils import PassiveSerializer
@ -160,20 +160,6 @@ class AutoSubmitChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-autosubmit")
class FrameChallenge(Challenge):
"""Challenge type to render a frame"""
component = CharField(default="xak-flow-frame")
url = CharField()
loading_overlay = BooleanField(default=False)
loading_text = CharField()
class FrameChallengeResponse(ChallengeResponse):
component = CharField(default="xak-flow-frame")
class DataclassEncoder(DjangoJSONEncoder):
"""Convert any dataclass to json"""

View File

@ -18,7 +18,7 @@ window.authentik.flow = {
{% endblock %}
{% block head %}
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
{% versioned_script "dist/flow/FlowInterface-%v.js" %}
<style>
:root {
--ak-flow-background: url("{{ flow.background_url }}");

View File

@ -46,7 +46,6 @@ class TestFlowInspector(APITestCase):
res.content,
{
"allow_show_password": False,
"captcha_stage": None,
"component": "ak-stage-identification",
"flow_info": {
"background": flow.background_url,

View File

@ -105,10 +105,6 @@ ldap:
tls:
ciphers: null
sources:
kerberos:
task_timeout_hours: 2
reputation:
expiry: 86400

View File

@ -21,14 +21,7 @@ class DebugSession(Session):
def send(self, req: PreparedRequest, *args, **kwargs):
request_id = str(uuid4())
LOGGER.debug(
"HTTP request sent",
uid=request_id,
url=req.url,
method=req.method,
headers=req.headers,
body=req.body,
)
LOGGER.debug("HTTP request sent", uid=request_id, path=req.path_url, headers=req.headers)
resp = super().send(req, *args, **kwargs)
LOGGER.debug(
"HTTP response received",

View File

@ -9,7 +9,7 @@ from uuid import uuid4
from dacite.core import from_dict
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db import IntegrityError, models, transaction
from django.db import models, transaction
from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission
@ -380,26 +380,22 @@ class Outpost(SerializerModel, ManagedModel):
"""Get/create token for auto-generated user"""
managed = f"goauthentik.io/outpost/{self.token_identifier}"
tokens = Token.filter_not_expired(
delete_expired=True,
identifier=self.token_identifier,
intent=TokenIntents.INTENT_API,
managed=managed,
)
if tokens.exists():
return tokens.first()
try:
return Token.objects.create(
user=self.user,
identifier=self.token_identifier,
intent=TokenIntents.INTENT_API,
description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False,
managed=managed,
)
except IntegrityError:
# Integrity error happens mostly when managed is reused
Token.objects.filter(managed=managed).delete()
Token.objects.filter(identifier=self.token_identifier).delete()
return self.token
token: Token | None = tokens.first()
if token:
return token
return Token.objects.create(
user=self.user,
identifier=self.token_identifier,
intent=TokenIntents.INTENT_API,
description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False,
managed=managed,
)
def get_required_objects(self) -> Iterable[models.Model | str]:
"""Get an iterator of all objects the user needs read access to"""

View File

@ -108,7 +108,7 @@ class EventMatcherPolicy(Policy):
result=result,
)
matches.append(result)
passing = all(x.passing for x in matches)
passing = any(x.passing for x in matches)
messages = chain(*[x.messages for x in matches])
result = PolicyResult(passing, *messages)
result.source_results = matches

View File

@ -77,24 +77,11 @@ class TestEventMatcherPolicy(TestCase):
request = PolicyRequest(get_anonymous_user())
request.context["event"] = event
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
client_ip="1.2.3.5", app="foo"
client_ip="1.2.3.5", app="bar"
)
response = policy.passes(request)
self.assertFalse(response.passing)
def test_multiple(self):
"""Test multiple"""
event = Event.new(EventAction.LOGIN)
event.app = "foo"
event.client_ip = "1.2.3.4"
request = PolicyRequest(get_anonymous_user())
request.context["event"] = event
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
client_ip="1.2.3.4", app="foo"
)
response = policy.passes(request)
self.assertTrue(response.passing)
def test_invalid(self):
"""Test passing event"""
request = PolicyRequest(get_anonymous_user())

View File

@ -89,10 +89,6 @@ class PasswordPolicy(Policy):
def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult:
"""Check static rules"""
error_message = self.error_message
if error_message == "":
error_message = _("Invalid password.")
if len(password) < self.length_min:
LOGGER.debug("password failed", check="static", reason="length")
return PolicyResult(False, self.error_message)

View File

@ -159,10 +159,7 @@ class LDAPOutpostConfigViewSet(ListModelMixin, GenericViewSet):
access_response = PolicyResult(result.passing)
response = self.LDAPCheckAccessSerializer(
instance={
"has_search_permission": (
request.user.has_perm("search_full_directory", provider)
or request.user.has_perm("authentik_providers_ldap.search_full_directory")
),
"has_search_permission": request.user.has_perm("search_full_directory", provider),
"access": access_response,
}
)

View File

@ -1,18 +1,15 @@
"""OAuth2Provider API Views"""
from copy import copy
from re import compile
from re import error as RegexError
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ChoiceField
from rest_framework.fields import CharField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response
@ -23,39 +20,13 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
from authentik.core.models import Provider
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
from authentik.rbac.decorators import permission_required
class RedirectURISerializer(PassiveSerializer):
"""A single allowed redirect URI entry"""
matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices)
url = CharField()
class OAuth2ProviderSerializer(ProviderSerializer):
"""OAuth2Provider Serializer"""
redirect_uris = RedirectURISerializer(many=True, source="_redirect_uris")
def validate_redirect_uris(self, data: list) -> list:
for entry in data:
if entry.get("matching_mode") == RedirectURIMatchingMode.REGEX:
url = entry.get("url")
try:
compile(url)
except RegexError:
raise ValidationError(
_("Invalid Regex Pattern: {url}".format(url=url))
) from None
return data
class Meta:
model = OAuth2Provider
fields = ProviderSerializer.Meta.fields + [
@ -68,7 +39,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"refresh_token_validity",
"include_claims_in_id_token",
"signing_key",
"encryption_key",
"redirect_uris",
"sub_mode",
"property_mappings",
@ -108,6 +78,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
"refresh_token_validity",
"include_claims_in_id_token",
"signing_key",
"redirect_uris",
"sub_mode",
"property_mappings",
"issuer_mode",

View File

@ -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 GrantTypes
class OAuth2Error(SentryIgnoredException):
@ -46,9 +46,9 @@ class RedirectUriError(OAuth2Error):
)
provided_uri: str
allowed_uris: list[RedirectURI]
allowed_uris: list[str]
def __init__(self, provided_uri: str, allowed_uris: list[RedirectURI]) -> None:
def __init__(self, provided_uri: str, allowed_uris: list[str]) -> None:
super().__init__()
self.provided_uri = provided_uri
self.allowed_uris = allowed_uris

View File

@ -1,7 +1,6 @@
"""id_token utils"""
from dataclasses import asdict, dataclass, field
from hashlib import sha256
from typing import TYPE_CHECKING, Any
from django.db import models
@ -24,13 +23,8 @@ if TYPE_CHECKING:
from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider
def hash_session_key(session_key: str) -> str:
"""Hash the session key for inclusion in JWTs as `sid`"""
return sha256(session_key.encode("ascii")).hexdigest()
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_ID = "user_id", _("Based on user ID")
@ -57,8 +51,7 @@ class IDToken:
and potentially other requested Claims. The ID Token is represented as a
JSON Web Token (JWT) [JWT].
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
https://www.iana.org/assignments/jwt/jwt.xhtml"""
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
# Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
iss: str | None = None
@ -86,8 +79,6 @@ class IDToken:
nonce: str | None = None
# Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html
at_hash: str | None = None
# Session ID, https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents
sid: str | None = None
claims: dict[str, Any] = field(default_factory=dict)
@ -125,11 +116,9 @@ class IDToken:
now = timezone.now()
id_token.iat = int(now.timestamp())
id_token.auth_time = int(token.auth_time.timestamp())
if token.session:
id_token.sid = hash_session_key(token.session.session_key)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(token.session)
auth_event = get_login_event(request)
if auth_event:
# Also check which method was used for authentication
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")

View File

@ -3,7 +3,6 @@
import django.db.models.deletion
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.lib.utils.time
@ -15,7 +14,7 @@ scope_uid_map = {
}
def set_managed_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
def set_managed_flag(apps: Apps, schema_editor):
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
db_alias = schema_editor.connection.alias
for mapping in ScopeMapping.objects.using(db_alias).filter(name__startswith="Autogenerated "):

View File

@ -11,16 +11,13 @@ class Migration(migrations.Migration):
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# Original preserved
# See https://github.com/goauthentik/authentik/issues/11874
# operations = [
# migrations.AddIndex(
# model_name="accesstoken",
# index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
# ),
# migrations.AddIndex(
# model_name="refreshtoken",
# index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
# ),
# ]
operations = []
operations = [
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
),
]

View File

@ -11,24 +11,21 @@ class Migration(migrations.Migration):
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# Original preserved
# See https://github.com/goauthentik/authentik/issues/11874
# operations = [
# migrations.RemoveIndex(
# model_name="accesstoken",
# name="authentik_p_token_4bc870_idx",
# ),
# migrations.RemoveIndex(
# model_name="refreshtoken",
# name="authentik_p_token_1a841f_idx",
# ),
# migrations.AddIndex(
# model_name="accesstoken",
# index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
# ),
# migrations.AddIndex(
# model_name="refreshtoken",
# index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
# ),
# ]
operations = []
operations = [
migrations.RemoveIndex(
model_name="accesstoken",
name="authentik_p_token_4bc870_idx",
),
migrations.RemoveIndex(
model_name="refreshtoken",
name="authentik_p_token_1a841f_idx",
),
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
),
]

View File

@ -1,42 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-16 14:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
(
"authentik_providers_oauth2",
"0020_remove_accesstoken_authentik_p_token_4bc870_idx_and_more",
),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="encryption_key",
field=models.ForeignKey(
help_text="Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="oauth2provider_encryption_key_set",
to="authentik_crypto.certificatekeypair",
verbose_name="Encryption Key",
),
),
migrations.AlterField(
model_name="oauth2provider",
name="signing_key",
field=models.ForeignKey(
help_text="Key used to sign the tokens.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="oauth2provider_signing_key_set",
to="authentik_crypto.certificatekeypair",
verbose_name="Signing Key",
),
),
]

View File

@ -1,113 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-23 13:38
from hashlib import sha256
import django.db.models.deletion
from django.db import migrations, models
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.lib.migrations import progress_bar
def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
AuthenticatedSession = apps.get_model("authentik_core", "authenticatedsession")
AuthorizationCode = apps.get_model("authentik_providers_oauth2", "authorizationcode")
AccessToken = apps.get_model("authentik_providers_oauth2", "accesstoken")
RefreshToken = apps.get_model("authentik_providers_oauth2", "refreshtoken")
db_alias = schema_editor.connection.alias
print(f"\nFetching session keys, this might take a couple of minutes...")
session_ids = {}
for session in progress_bar(AuthenticatedSession.objects.using(db_alias).all()):
session_ids[sha256(session.session_key.encode("ascii")).hexdigest()] = session.session_key
for model in [AuthorizationCode, AccessToken, RefreshToken]:
print(
f"\nAdding session to {model._meta.verbose_name}, this might take a couple of minutes..."
)
for code in progress_bar(model.objects.using(db_alias).all()):
if code.session_id_old not in session_ids:
continue
code.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session_key=session_ids[code.session_id_old])
.first()
)
code.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
]
operations = [
migrations.RenameField(
model_name="accesstoken",
old_name="session_id",
new_name="session_id_old",
),
migrations.RenameField(
model_name="authorizationcode",
old_name="session_id",
new_name="session_id_old",
),
migrations.RenameField(
model_name="refreshtoken",
old_name="session_id",
new_name="session_id_old",
),
migrations.AddField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="devicetoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="refreshtoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(migrate_session),
migrations.RemoveField(
model_name="accesstoken",
name="session_id_old",
),
migrations.RemoveField(
model_name="authorizationcode",
name="session_id_old",
),
migrations.RemoveField(
model_name="refreshtoken",
name="session_id_old",
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-31 14:28
import django.contrib.postgres.indexes
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_f99422_idx;"),
migrations.RunSQL("DROP INDEX IF EXISTS authentik_p_token_a1d921_idx;"),
migrations.AddIndex(
model_name="accesstoken",
index=django.contrib.postgres.indexes.HashIndex(
fields=["token"], name="authentik_p_token_e00883_hash"
),
),
migrations.AddIndex(
model_name="refreshtoken",
index=django.contrib.postgres.indexes.HashIndex(
fields=["token"], name="authentik_p_token_32e2b7_hash"
),
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.0.9 on 2024-11-04 12:56
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db import migrations, models
def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode
OAuth2Provider = apps.get_model("authentik_providers_oauth2", "oauth2provider")
db_alias = schema_editor.connection.alias
for provider in OAuth2Provider.objects.using(db_alias).all():
uris = []
for old in provider.old_redirect_uris.split("\n"):
mode = RedirectURIMatchingMode.STRICT
if old == "*" or old == ".*":
mode = RedirectURIMatchingMode.REGEX
uris.append(RedirectURI(mode, url=old))
provider.redirect_uris = uris
provider.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"),
]
operations = [
migrations.RenameField(
model_name="oauth2provider",
old_name="redirect_uris",
new_name="old_redirect_uris",
),
migrations.AddField(
model_name="oauth2provider",
name="_redirect_uris",
field=models.JSONField(default=dict, verbose_name="Redirect URIs"),
),
migrations.RunPython(migrate_redirect_uris, lambda *args: ...),
migrations.RemoveField(
model_name="oauth2provider",
name="old_redirect_uris",
),
]

View File

@ -3,7 +3,7 @@
import base64
import binascii
import json
from dataclasses import asdict, dataclass
from dataclasses import asdict
from functools import cached_property
from hashlib import sha256
from typing import Any
@ -12,29 +12,18 @@ from urllib.parse import urlparse, urlunparse
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
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.indexes import HashIndex
from django.db import models
from django.http import HttpRequest
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jwcrypto.common import json_encode
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import encode
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.brands.models import WebfingerProvider
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
PropertyMapping,
Provider,
User,
)
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import SerializerModel
@ -78,25 +67,11 @@ class IssuerMode(models.TextChoices):
"""Configure how the `iss` field is created."""
GLOBAL = "global", _("Same identifier is used for all providers")
PER_PROVIDER = (
"per_provider",
_("Each provider has a different issuer, based on the application slug."),
PER_PROVIDER = "per_provider", _(
"Each provider has a different issuer, based on the application slug."
)
class RedirectURIMatchingMode(models.TextChoices):
STRICT = "strict", _("Strict URL comparison")
REGEX = "regex", _("Regular Expression URL matching")
@dataclass
class RedirectURI:
"""A single redirect URI entry"""
matching_mode: RedirectURIMatchingMode
url: str
class ResponseTypes(models.TextChoices):
"""Response Type required by the client."""
@ -171,9 +146,11 @@ class OAuth2Provider(WebfingerProvider, Provider):
verbose_name=_("Client Secret"),
default=generate_client_secret,
)
_redirect_uris = models.JSONField(
default=dict,
redirect_uris = models.TextField(
default="",
blank=True,
verbose_name=_("Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)
include_claims_in_id_token = models.BooleanField(
@ -229,19 +206,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
verbose_name=_("Signing Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_("Key used to sign the tokens."),
related_name="oauth2provider_signing_key_set",
)
encryption_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("Encryption Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_(
"Key used to encrypt the tokens. When set, "
"tokens will be encrypted and returned as JWEs."
"Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
),
related_name="oauth2provider_encryption_key_set",
)
jwks_sources = models.ManyToManyField(
@ -284,33 +251,12 @@ class OAuth2Provider(WebfingerProvider, Provider):
except Provider.application.RelatedObjectDoesNotExist:
return None
@property
def redirect_uris(self) -> list[RedirectURI]:
uris = []
for entry in self._redirect_uris:
uris.append(
from_dict(
RedirectURI,
entry,
config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}),
)
)
return uris
@redirect_uris.setter
def redirect_uris(self, value: list[RedirectURI]):
cleansed = []
for entry in value:
cleansed.append(asdict(entry))
self._redirect_uris = cleansed
@property
def launch_url(self) -> str | None:
"""Guess launch_url based on first redirect_uri"""
redirects = self.redirect_uris
if len(redirects) < 1:
if self.redirect_uris == "":
return None
main_url = redirects[0].url
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
try:
launch_url = urlparse(main_url)._replace(path="")
return urlunparse(launch_url)
@ -341,27 +287,7 @@ class OAuth2Provider(WebfingerProvider, Provider):
if self.signing_key:
headers["kid"] = self.signing_key.kid
key, alg = self.jwt_key
encoded = encode(payload, key, algorithm=alg, headers=headers)
if self.encryption_key:
return self.encrypt(encoded)
return encoded
def encrypt(self, raw: str) -> str:
"""Encrypt JWT"""
key = JWK.from_pem(self.encryption_key.certificate_data.encode())
jwe = JWE(
raw,
json_encode(
{
"alg": "RSA-OAEP-256",
"enc": "A256CBC-HS512",
"typ": "JWE",
"kid": self.encryption_key.kid,
}
),
)
jwe.add_recipient(key)
return jwe.serialize(compact=True)
return encode(payload, key, algorithm=alg, headers=headers)
def webfinger(self, resource: str, request: HttpRequest):
return {
@ -394,9 +320,7 @@ class BaseGrantModel(models.Model):
revoked = models.BooleanField(default=False)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
auth_time = models.DateTimeField(verbose_name="Authentication time")
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
session_id = models.CharField(default="", blank=True)
class Meta:
abstract = True
@ -453,7 +377,7 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
class Meta:
indexes = [
HashIndex(fields=["token"]),
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Access Token")
verbose_name_plural = _("OAuth2 Access Tokens")
@ -499,7 +423,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
class Meta:
indexes = [
HashIndex(fields=["token"]),
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Refresh Token")
verbose_name_plural = _("OAuth2 Refresh Tokens")
@ -534,9 +458,6 @@ class DeviceToken(ExpiringModel):
device_code = models.TextField(default=generate_key)
user_code = models.TextField(default=generate_code_fixed_length)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
@property
def scope(self) -> list[str]:

View File

@ -1,3 +1,5 @@
from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.http import HttpRequest
@ -11,4 +13,5 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User,
"""Revoke access tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()

View File

@ -10,13 +10,7 @@ from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
class TestAPI(APITestCase):
@ -27,7 +21,7 @@ class TestAPI(APITestCase):
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
@ -56,29 +50,9 @@ class TestAPI(APITestCase):
@skipUnless(version_info >= (3, 11, 4), "This behaviour is only Python 3.11.4 and up")
def test_launch_url(self):
"""Test launch_url"""
self.provider.redirect_uris = [
RedirectURI(
RedirectURIMatchingMode.REGEX,
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/",
),
]
self.provider.redirect_uris = (
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/\n"
)
self.provider.save()
self.provider.refresh_from_db()
self.assertIsNone(self.provider.launch_url)
def test_validate_redirect_uris(self):
"""Test redirect_uris API"""
response = self.client.post(
reverse("authentik_api:oauth2provider-list"),
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"redirect_uris": [
{"matching_mode": "strict", "url": "http://goauthentik.io"},
{"matching_mode": "regex", "url": "**"},
],
},
)
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
self.assertEqual(response.status_code, 400)

View File

@ -19,8 +19,6 @@ from authentik.providers.oauth2.models import (
AuthorizationCode,
GrantTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -41,7 +39,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
redirect_uris="http://local.invalid/Foo",
)
with self.assertRaises(AuthorizeError):
request = self.factory.get(
@ -66,7 +64,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
redirect_uris="http://local.invalid/Foo",
)
with self.assertRaises(AuthorizeError):
request = self.factory.get(
@ -86,7 +84,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
)
with self.assertRaises(RedirectUriError):
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
@ -108,7 +106,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "data:local.invalid")],
redirect_uris="data:local.invalid",
)
with self.assertRaises(RedirectUriError):
request = self.factory.get(
@ -127,7 +125,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[],
redirect_uris="",
)
with self.assertRaises(RedirectUriError):
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
@ -142,7 +140,7 @@ class TestAuthorize(OAuthTestCase):
)
OAuthAuthorizationParams.from_request(request)
provider.refresh_from_db()
self.assertEqual(provider.redirect_uris, [RedirectURI(RedirectURIMatchingMode.STRICT, "+")])
self.assertEqual(provider.redirect_uris, "+")
def test_invalid_redirect_uri_regex(self):
"""test missing/invalid redirect URI"""
@ -150,7 +148,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid?")],
redirect_uris="http://local.invalid?",
)
with self.assertRaises(RedirectUriError):
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
@ -172,7 +170,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "+")],
redirect_uris="+",
)
with self.assertRaises(RedirectUriError):
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
@ -215,7 +213,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/Foo")],
redirect_uris="http://local.invalid/Foo",
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
@ -303,7 +301,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
redirect_uris="foo://localhost",
access_code_validity="seconds=100",
)
Application.objects.create(name="app", slug="app", provider=provider)
@ -345,7 +343,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=self.keypair,
)
provider.property_mappings.set(
@ -414,73 +412,6 @@ class TestAuthorize(OAuthTestCase):
delta=5,
)
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_implicit_enc(self):
"""Test full authorization with encryption"""
flow = create_test_flow()
provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=self.keypair,
encryption_key=self.keypair,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
provider.property_mappings.add(
ScopeMapping.objects.create(
name=generate_id(), scope_name="test", expression="""return {"sub": "foo"}"""
)
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id()
user = create_test_admin_user()
self.client.force_login(user)
with patch(
"authentik.providers.oauth2.id_token.get_login_event",
MagicMock(
return_value=Event(
action=EventAction.LOGIN,
context={PLAN_CONTEXT_METHOD: "password"},
created=now(),
)
),
):
# Step 1, initiate params and get redirect to flow
self.client.get(
reverse("authentik_providers_oauth2:authorize"),
data={
"response_type": "id_token",
"client_id": "test",
"state": state,
"scope": "openid test",
"redirect_uri": "http://localhost",
"nonce": generate_id(),
},
)
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
token: AccessToken = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds()
jwt = self.validate_jwe(token, provider)
self.assertEqual(jwt["amr"], ["pwd"])
self.assertEqual(jwt["sub"], "foo")
self.assertAlmostEqual(
jwt["exp"] - now().timestamp(),
expires,
delta=5,
)
def test_full_fragment_code(self):
"""Test full authorization"""
flow = create_test_flow()
@ -488,7 +419,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=self.keypair,
)
Application.objects.create(name="app", slug="app", provider=provider)
@ -543,7 +474,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id=generate_id(),
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=self.keypair,
)
provider.property_mappings.set(
@ -601,7 +532,7 @@ class TestAuthorize(OAuthTestCase):
name=generate_id(),
client_id=generate_id(),
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
redirect_uris="http://localhost",
signing_key=self.keypair,
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)

View File

@ -3,7 +3,6 @@
from urllib.parse import urlencode
from django.urls import reverse
from rest_framework.test import APIClient
from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
@ -35,10 +34,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
self.brand.flow_device_code = self.device_flow
self.brand.save()
self.api_client = APIClient()
self.api_client.force_login(self.user)
def test_device_init_get(self):
def test_device_init(self):
"""Test device init"""
res = self.client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 302)
@ -52,76 +48,6 @@ class TesOAuth2DeviceInit(OAuthTestCase):
),
)
def test_device_init_post(self):
"""Test device init"""
res = self.api_client.get(reverse("authentik_providers_oauth2_root:device-login"))
self.assertEqual(res.status_code, 302)
self.assertEqual(
res.url,
reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.device_flow.slug,
},
),
)
res = self.api_client.get(
reverse(
"authentik_api:flow-executor",
kwargs={
"flow_slug": self.device_flow.slug,
},
),
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content,
{
"component": "ak-provider-oauth2-device-code",
"flow_info": {
"background": "/static/dist/assets/images/flow_background.jpg",
"cancel_url": "/flows/-/cancel/",
"layout": "stacked",
"title": self.device_flow.title,
},
},
)
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
token = DeviceToken.objects.create(
provider=provider,
)
res = self.api_client.post(
reverse(
"authentik_api:flow-executor",
kwargs={
"flow_slug": self.device_flow.slug,
},
),
data={
"component": "ak-provider-oauth2-device-code",
"code": token.user_code,
},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content,
{
"component": "xak-flow-redirect",
"to": reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": provider.authorization_flow.slug,
},
),
},
)
def test_no_flow(self):
"""Test no flow"""
self.brand.flow_device_code = None

View File

@ -11,14 +11,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.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
from authentik.providers.oauth2.models import (
AccessToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -30,7 +23,7 @@ class TesOAuth2Introspection(OAuthTestCase):
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
redirect_uris="",
signing_key=create_test_cert(),
)
self.app = Application.objects.create(
@ -125,7 +118,7 @@ class TesOAuth2Introspection(OAuthTestCase):
provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
redirect_uris="",
signing_key=create_test_cert(),
)
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()

View File

@ -13,7 +13,7 @@ from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.crypto.builder import PrivateKeyAlg
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
TEST_CORDS_CERT = """
@ -49,7 +49,7 @@ class TestJWKS(OAuthTestCase):
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=create_test_cert(),
)
app = Application.objects.create(name="test", slug="test", provider=provider)
@ -68,7 +68,7 @@ class TestJWKS(OAuthTestCase):
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
)
app = Application.objects.create(name="test", slug="test", provider=provider)
response = self.client.get(
@ -82,7 +82,7 @@ class TestJWKS(OAuthTestCase):
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
)
app = Application.objects.create(name="test", slug="test", provider=provider)
@ -93,24 +93,6 @@ class TestJWKS(OAuthTestCase):
self.assertEqual(len(body["keys"]), 1)
PyJWKSet.from_dict(body)
def test_enc(self):
"""Test with JWE"""
provider = OAuth2Provider.objects.create(
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
encryption_key=create_test_cert(PrivateKeyAlg.ECDSA),
)
app = Application.objects.create(name="test", slug="test", provider=provider)
response = self.client.get(
reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug})
)
body = json.loads(response.content.decode())
self.assertEqual(len(body["keys"]), 2)
PyJWKSet.from_dict(body)
def test_ecdsa_coords_mismatched(self):
"""Test JWKS request with ES256"""
cert = CertificateKeyPair.objects.create(
@ -122,7 +104,7 @@ class TestJWKS(OAuthTestCase):
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=cert,
)
app = Application.objects.create(name="test", slug="test", provider=provider)

View File

@ -10,14 +10,7 @@ from django.utils import timezone
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.providers.oauth2.models import (
AccessToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -29,7 +22,7 @@ class TesOAuth2Revoke(OAuthTestCase):
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
redirect_uris="",
signing_key=create_test_cert(),
)
self.app = Application.objects.create(

View File

@ -22,8 +22,6 @@ from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
ScopeMapping,
)
@ -44,7 +42,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://TestServer")],
redirect_uris="http://TestServer",
signing_key=self.keypair,
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
@ -71,7 +69,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=self.keypair,
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
@ -92,7 +90,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=self.keypair,
)
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
@ -120,7 +118,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=self.keypair,
)
# Needs to be assigned to an application for iss to be set
@ -154,43 +152,13 @@ class TestToken(OAuthTestCase):
)
self.validate_jwt(access, provider)
def test_auth_code_enc(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
signing_key=self.keypair,
encryption_key=self.keypair,
)
# Needs to be assigned to an application for iss to be set
self.app.provider = provider
self.app.save()
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()
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
"code": code.code,
"redirect_uri": "http://local.invalid",
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
self.assertEqual(response.status_code, 200)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
self.validate_jwe(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=self.keypair,
)
provider.property_mappings.set(
@ -252,7 +220,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid")],
redirect_uris="http://local.invalid",
signing_key=self.keypair,
)
provider.property_mappings.set(
@ -310,7 +278,7 @@ class TestToken(OAuthTestCase):
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=self.keypair,
)
provider.property_mappings.set(

View File

@ -19,12 +19,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID_PROFILE,
TOKEN_TYPE,
)
from authentik.providers.oauth2.models import (
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.jwks import JWKSView
from authentik.sources.oauth.models import OAuthSource
@ -39,7 +34,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
self.factory = RequestFactory()
self.cert = create_test_cert()
jwk = JWKSView().get_jwk_for_key(self.cert, "sig")
jwk = JWKSView().get_jwk_for_key(self.cert)
self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
@ -59,7 +54,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=self.cert,
)
self.provider.jwks_sources.add(self.source)

View File

@ -19,13 +19,7 @@ from authentik.providers.oauth2.constants import (
TOKEN_TYPE,
)
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -39,7 +33,7 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
self.provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
@ -113,48 +107,6 @@ class TestTokenClientCredentialsStandard(OAuthTestCase):
{"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]},
)
def test_incorrect_scopes(self):
"""test scope that isn't configured"""
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE} extra_scope",
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["token_type"], TOKEN_TYPE)
token = AccessToken.objects.filter(
provider=self.provider, token=body["access_token"]
).first()
self.assertSetEqual(
set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE}
)
_, alg = self.provider.jwt_key
jwt = decode(
body["access_token"],
key=self.provider.signing_key.public_key,
algorithms=[alg],
audience=self.provider.client_id,
)
self.assertEqual(
jwt["given_name"], "Autogenerated user from application test (client credentials)"
)
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
jwt = decode(
body["id_token"],
key=self.provider.signing_key.public_key,
algorithms=[alg],
audience=self.provider.client_id,
)
self.assertEqual(
jwt["given_name"], "Autogenerated user from application test (client credentials)"
)
self.assertEqual(jwt["preferred_username"], "ak-test-client_credentials")
def test_successful(self):
"""test successful"""
response = self.client.post(

View File

@ -20,12 +20,7 @@ from authentik.providers.oauth2.constants import (
TOKEN_TYPE,
)
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -39,7 +34,7 @@ class TestTokenClientCredentialsStandardCompat(OAuthTestCase):
self.provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@ -19,12 +19,7 @@ from authentik.providers.oauth2.constants import (
TOKEN_TYPE,
)
from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import (
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -38,7 +33,7 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
self.provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@ -9,19 +9,8 @@ from authentik.blueprints.tests import apply_blueprint
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_code_fixed_length, generate_id
from authentik.providers.oauth2.constants import (
GRANT_TYPE_DEVICE_CODE,
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
)
from authentik.providers.oauth2.models import (
AccessToken,
DeviceToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -35,7 +24,7 @@ class TestTokenDeviceCode(OAuthTestCase):
self.provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
redirect_uris="http://testserver",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
@ -91,28 +80,3 @@ class TestTokenDeviceCode(OAuthTestCase):
},
)
self.assertEqual(res.status_code, 200)
def test_code_mismatched_scope(self):
"""Test code with user (mismatched scopes)"""
device_token = DeviceToken.objects.create(
provider=self.provider,
user_code=generate_code_fixed_length(),
device_code=generate_id(),
user=self.user,
scope=[SCOPE_OPENID, SCOPE_OPENID_EMAIL],
)
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",
},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content)
token = AccessToken.objects.filter(
provider=self.provider, token=body["access_token"]
).first()
self.assertSetEqual(set(token.scope), {SCOPE_OPENID, SCOPE_OPENID_EMAIL})

View File

@ -10,12 +10,7 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
from authentik.providers.oauth2.models import (
AuthorizationCode,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
)
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -35,7 +30,7 @@ class TestTokenPKCE(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
redirect_uris="foo://localhost",
access_code_validity="seconds=100",
)
Application.objects.create(name="app", slug="app", provider=provider)
@ -98,7 +93,7 @@ class TestTokenPKCE(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
redirect_uris="foo://localhost",
access_code_validity="seconds=100",
)
Application.objects.create(name="app", slug="app", provider=provider)
@ -159,7 +154,7 @@ class TestTokenPKCE(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
redirect_uris="foo://localhost",
access_code_validity="seconds=100",
)
Application.objects.create(name="app", slug="app", provider=provider)
@ -215,7 +210,7 @@ class TestTokenPKCE(OAuthTestCase):
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "foo://localhost")],
redirect_uris="foo://localhost",
access_code_validity="seconds=100",
)
Application.objects.create(name="app", slug="app", provider=provider)

View File

@ -11,14 +11,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.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
AccessToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -32,7 +25,7 @@ class TestUserinfo(OAuthTestCase):
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "")],
redirect_uris="",
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())

View File

@ -3,8 +3,6 @@
from typing import Any
from django.test import TestCase
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import decode
from authentik.core.tests.utils import create_test_cert
@ -34,15 +32,6 @@ class OAuthTestCase(TestCase):
if key in container:
self.assertIsNotNone(container[key])
def validate_jwe(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]:
"""Validate JWEs"""
private_key = JWK.from_pem(provider.encryption_key.key_data.encode())
jwetoken = JWE()
jwetoken.deserialize(token.token, key=private_key)
token.token = jwetoken.payload.decode()
return self.validate_jwt(token, provider)
def validate_jwt(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]:
"""Validate that all required fields are set"""
key, alg = provider.jwt_key

View File

@ -2,6 +2,7 @@
from dataclasses import InitVar, dataclass, field
from datetime import timedelta
from hashlib import sha256
from json import dumps
from re import error as RegexError
from re import fullmatch
@ -15,7 +16,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event
from authentik.flows.challenge import (
@ -56,8 +57,6 @@ from authentik.providers.oauth2.models import (
AuthorizationCode,
GrantTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ResponseMode,
ResponseTypes,
ScopeMapping,
@ -189,39 +188,40 @@ class OAuthAuthorizationParams:
def check_redirect_uri(self):
"""Redirect URI validation."""
allowed_redirect_urls = self.provider.redirect_uris
allowed_redirect_urls = self.provider.redirect_uris.split()
if not self.redirect_uri:
LOGGER.warning("Missing redirect uri.")
raise RedirectUriError("", allowed_redirect_urls)
if len(allowed_redirect_urls) < 1:
if self.provider.redirect_uris == "":
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
self.provider.redirect_uris = [
RedirectURI(RedirectURIMatchingMode.STRICT, self.redirect_uri)
]
self.provider.redirect_uris = self.redirect_uri
self.provider.save()
allowed_redirect_urls = self.provider.redirect_uris
allowed_redirect_urls = self.provider.redirect_uris.split()
match_found = False
for allowed in allowed_redirect_urls:
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
if self.redirect_uri == allowed.url:
match_found = True
break
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
try:
if fullmatch(allowed.url, self.redirect_uri):
match_found = True
break
except RegexError as exc:
LOGGER.warning(
"Failed to parse regular expression",
exc=exc,
url=allowed.url,
provider=self.provider,
)
if not match_found:
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
if self.provider.redirect_uris == "*":
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
self.provider.redirect_uris = ".*"
self.provider.save()
allowed_redirect_urls = self.provider.redirect_uris.split()
try:
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (regex comparison)",
redirect_uri_given=self.redirect_uri,
redirect_uri_expected=allowed_redirect_urls,
)
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
except RegexError as exc:
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (strict comparison)",
redirect_uri_given=self.redirect_uri,
redirect_uri_expected=allowed_redirect_urls,
)
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) from None
# Check against forbidden schemes
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
@ -318,9 +318,7 @@ class OAuthAuthorizationParams:
expires=now + timedelta_from_string(self.provider.access_code_validity),
scope=self.scope,
nonce=self.nonce,
session=AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first(),
session_id=sha256(request.session.session_key.encode("ascii")).hexdigest(),
)
if self.code_challenge and self.code_challenge_method:
@ -612,9 +610,7 @@ class OAuthFulfillmentStage(StageView):
expires=access_token_expiry,
provider=self.provider,
auth_time=auth_event.created if auth_event else now,
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
session_id=sha256(self.request.session.session_key.encode("ascii")).hexdigest(),
)
id_token = IDToken.new(self.provider, token, self.request)

View File

@ -5,7 +5,7 @@ from typing import Any
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
from rest_framework.fields import CharField, IntegerField
from structlog.stdlib import get_logger
from authentik.brands.models import Brand
@ -47,9 +47,6 @@ class CodeValidatorView(PolicyAccessView):
self.provider = self.token.provider
self.application = self.token.provider.application
def post(self, request: HttpRequest, *args, **kwargs):
return self.get(request, *args, **kwargs)
def get(self, request: HttpRequest, *args, **kwargs):
scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
planner = FlowPlanner(self.provider.authorization_flow)
@ -125,7 +122,7 @@ class OAuthDeviceCodeChallenge(Challenge):
class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
"""Response that includes the user-entered device code"""
code = CharField()
code = IntegerField()
component = CharField(default="ak-provider-oauth2-device-code")
def validate_code(self, code: int) -> HttpResponse | None:

View File

@ -64,42 +64,36 @@ def to_base64url_uint(val: int, min_length: int = 0) -> bytes:
class JWKSView(View):
"""Show RSA Key data for Provider"""
def get_jwk_for_key(self, key: CertificateKeyPair, use: str) -> dict | None:
def get_jwk_for_key(self, key: CertificateKeyPair) -> dict | None:
"""Convert a certificate-key pair into JWK"""
private_key = key.private_key
key_data = None
if not private_key:
return key_data
key_data = {}
if use == "sig":
if isinstance(private_key, RSAPrivateKey):
key_data["alg"] = JWTAlgorithms.RS256
elif isinstance(private_key, EllipticCurvePrivateKey):
key_data["alg"] = JWTAlgorithms.ES256
elif use == "enc":
key_data["alg"] = "RSA-OAEP-256"
key_data["enc"] = "A256CBC-HS512"
if isinstance(private_key, RSAPrivateKey):
public_key: RSAPublicKey = private_key.public_key()
public_numbers = public_key.public_numbers()
key_data["kid"] = key.kid
key_data["kty"] = "RSA"
key_data["use"] = use
key_data["n"] = to_base64url_uint(public_numbers.n).decode()
key_data["e"] = to_base64url_uint(public_numbers.e).decode()
key_data = {
"kid": key.kid,
"kty": "RSA",
"alg": JWTAlgorithms.RS256,
"use": "sig",
"n": to_base64url_uint(public_numbers.n).decode(),
"e": to_base64url_uint(public_numbers.e).decode(),
}
elif isinstance(private_key, EllipticCurvePrivateKey):
public_key: EllipticCurvePublicKey = private_key.public_key()
public_numbers = public_key.public_numbers()
curve_type = type(public_key.curve)
key_data["kid"] = key.kid
key_data["kty"] = "EC"
key_data["use"] = use
key_data["x"] = to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode()
key_data["y"] = to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode()
key_data["crv"] = ec_crv_map.get(curve_type, public_key.curve.name)
key_data = {
"kid": key.kid,
"kty": "EC",
"alg": JWTAlgorithms.ES256,
"use": "sig",
"x": to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode(),
"y": to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode(),
"crv": ec_crv_map.get(curve_type, public_key.curve.name),
}
else:
return key_data
key_data["x5c"] = [b64encode(key.certificate.public_bytes(Encoding.DER)).decode("utf-8")]
@ -119,19 +113,14 @@ class JWKSView(View):
"""Show JWK Key data for Provider"""
application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
signing_key: CertificateKeyPair = provider.signing_key
response_data = {}
if signing_key := provider.signing_key:
jwk = self.get_jwk_for_key(signing_key, "sig")
if signing_key:
jwk = self.get_jwk_for_key(signing_key)
if jwk:
response_data.setdefault("keys", [])
response_data["keys"].append(jwk)
if encryption_key := provider.encryption_key:
jwk = self.get_jwk_for_key(encryption_key, "enc")
if jwk:
response_data.setdefault("keys", [])
response_data["keys"].append(jwk)
response_data["keys"] = [jwk]
response = JsonResponse(response_data)
response["Access-Control-Allow-Origin"] = "*"

View File

@ -46,7 +46,7 @@ class ProviderInfoView(View):
if SCOPE_OPENID not in scopes:
scopes.append(SCOPE_OPENID)
_, supported_alg = provider.jwt_key
config = {
return {
"issuer": provider.get_issuer(self.request),
"authorization_endpoint": self.request.build_absolute_uri(
reverse("authentik_providers_oauth2:authorize")
@ -114,10 +114,6 @@ class ProviderInfoView(View):
"claims_parameter_supported": False,
"code_challenge_methods_supported": [PKCE_METHOD_PLAIN, PKCE_METHOD_S256],
}
if provider.encryption_key:
config["id_token_encryption_alg_values_supported"] = ["RSA-OAEP-256"]
config["id_token_encryption_enc_values_supported"] = ["A256CBC-HS512"]
return config
def get_claims(self, provider: OAuth2Provider) -> list[str]:
"""Get a list of supported claims based on configured scope mappings"""
@ -162,5 +158,5 @@ class ProviderInfoView(View):
OAuth2Provider, pk=application.provider_id
)
response = super().dispatch(request, *args, **kwargs)
cors_allow(request, response, *[x.url for x in self.provider.redirect_uris])
cors_allow(request, response, *self.provider.redirect_uris.split("\n"))
return response

View File

@ -58,9 +58,7 @@ from authentik.providers.oauth2.models import (
ClientTypes,
DeviceToken,
OAuth2Provider,
RedirectURIMatchingMode,
RefreshToken,
ScopeMapping,
)
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
@ -79,7 +77,7 @@ class TokenParams:
redirect_uri: str
grant_type: str
state: str
scope: set[str]
scope: list[str]
provider: OAuth2Provider
@ -114,26 +112,11 @@ class TokenParams:
redirect_uri=request.POST.get("redirect_uri", ""),
grant_type=request.POST.get("grant_type", ""),
state=request.POST.get("state", ""),
scope=set(request.POST.get("scope", "").split()),
scope=request.POST.get("scope", "").split(),
# PKCE parameter.
code_verifier=request.POST.get("code_verifier"),
)
def __check_scopes(self):
allowed_scope_names = set(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
"scope_name", flat=True
)
)
scopes_to_check = self.scope
if not scopes_to_check.issubset(allowed_scope_names):
LOGGER.info(
"Application requested scopes not configured, setting to overlap",
scope_allowed=allowed_scope_names,
scope_given=self.scope,
)
self.scope = self.scope.intersection(allowed_scope_names)
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
with start_span(
op="authentik.providers.oauth2.token.policy",
@ -166,7 +149,7 @@ class TokenParams:
client_id=self.provider.client_id,
)
raise TokenError("invalid_client")
self.__check_scopes()
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
with start_span(
op="authentik.providers.oauth2.post.parse.code",
@ -196,7 +179,42 @@ class TokenParams:
LOGGER.warning("Missing authorization code")
raise TokenError("invalid_grant")
self.__check_redirect_uri(request)
allowed_redirect_urls = self.provider.redirect_uris.split()
# At this point, no provider should have a blank redirect_uri, in case they do
# this will check an empty array and raise an error
try:
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (regex comparison)",
redirect_uri=self.redirect_uri,
expected=allowed_redirect_urls,
)
Event.new(
EventAction.CONFIGURATION_ERROR,
message="Invalid redirect URI used by provider",
provider=self.provider,
redirect_uri=self.redirect_uri,
expected=allowed_redirect_urls,
).from_http(request)
raise TokenError("invalid_client")
except RegexError as exc:
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (strict comparison)",
redirect_uri=self.redirect_uri,
expected=allowed_redirect_urls,
)
Event.new(
EventAction.CONFIGURATION_ERROR,
message="Invalid redirect_uri configured",
provider=self.provider,
).from_http(request)
raise TokenError("invalid_client") from None
# Check against forbidden schemes
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
raise TokenError("invalid_request")
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
if not self.authorization_code:
@ -236,48 +254,6 @@ class TokenParams:
if not self.authorization_code.code_challenge and self.code_verifier:
raise TokenError("invalid_grant")
def __check_redirect_uri(self, request: HttpRequest):
allowed_redirect_urls = self.provider.redirect_uris
# At this point, no provider should have a blank redirect_uri, in case they do
# this will check an empty array and raise an error
match_found = False
for allowed in allowed_redirect_urls:
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
if self.redirect_uri == allowed.url:
match_found = True
break
if allowed.matching_mode == RedirectURIMatchingMode.REGEX:
try:
if fullmatch(allowed.url, self.redirect_uri):
match_found = True
break
except RegexError as exc:
LOGGER.warning(
"Failed to parse regular expression",
exc=exc,
url=allowed.url,
provider=self.provider,
)
Event.new(
EventAction.CONFIGURATION_ERROR,
message="Invalid redirect_uri configured",
provider=self.provider,
).from_http(request)
if not match_found:
Event.new(
EventAction.CONFIGURATION_ERROR,
message="Invalid redirect URI used by provider",
provider=self.provider,
redirect_uri=self.redirect_uri,
expected=allowed_redirect_urls,
).from_http(request)
raise TokenError("invalid_client")
# Check against forbidden schemes
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
raise TokenError("invalid_request")
def __post_init_refresh(self, raw_token: str, request: HttpRequest):
if not raw_token:
LOGGER.warning("Missing refresh token")
@ -463,14 +439,15 @@ class TokenParams:
# (22 chars being the length of the "template")
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
"last_login": timezone.now(),
"name": f"Autogenerated user from application {app.name} (client credentials)",
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
"type": UserTypes.SERVICE_ACCOUNT,
},
)
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
self.user.save()
self.__check_policy_access(app, request)
Event.new(
@ -494,6 +471,9 @@ class TokenParams:
self.user, created = User.objects.update_or_create(
username=f"{self.provider.name}-{token.get('sub')}",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
"last_login": timezone.now(),
"name": (
f"Autogenerated user from application {app.name} (client credentials JWT)"
@ -502,8 +482,6 @@ class TokenParams:
"type": UserTypes.SERVICE_ACCOUNT,
},
)
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
self.user.save()
exp = token.get("exp")
if created and exp:
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
@ -521,7 +499,7 @@ class TokenView(View):
response = super().dispatch(request, *args, **kwargs)
allowed_origins = []
if self.provider:
allowed_origins = [x.url for x in self.provider.redirect_uris]
allowed_origins = self.provider.redirect_uris.split("\n")
cors_allow(self.request, response, *allowed_origins)
return response
@ -574,7 +552,7 @@ class TokenView(View):
# Keep same scopes as previous token
scope=self.params.authorization_code.scope,
auth_time=self.params.authorization_code.auth_time,
session=self.params.authorization_code.session,
session_id=self.params.authorization_code.session_id,
)
access_id_token = IDToken.new(
self.provider,
@ -602,7 +580,7 @@ class TokenView(View):
expires=refresh_token_expiry,
provider=self.provider,
auth_time=self.params.authorization_code.auth_time,
session=self.params.authorization_code.session,
session_id=self.params.authorization_code.session_id,
)
id_token = IDToken.new(
self.provider,
@ -635,7 +613,7 @@ class TokenView(View):
# Keep same scopes as previous token
scope=self.params.refresh_token.scope,
auth_time=self.params.refresh_token.auth_time,
session=self.params.refresh_token.session,
session_id=self.params.refresh_token.session_id,
)
access_token.id_token = IDToken.new(
self.provider,
@ -651,7 +629,7 @@ class TokenView(View):
expires=refresh_token_expiry,
provider=self.provider,
auth_time=self.params.refresh_token.auth_time,
session=self.params.refresh_token.session,
session_id=self.params.refresh_token.session_id,
)
id_token = IDToken.new(
self.provider,
@ -709,14 +687,13 @@ class TokenView(View):
raise DeviceCodeError("authorization_pending")
now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
auth_event = get_login_event(self.params.device_code.session)
auth_event = get_login_event(self.request)
access_token = AccessToken(
provider=self.provider,
user=self.params.device_code.user,
expires=access_token_expiry,
scope=self.params.device_code.scope,
auth_time=auth_event.created if auth_event else now,
session=self.params.device_code.session,
)
access_token.id_token = IDToken.new(
self.provider,
@ -734,7 +711,7 @@ class TokenView(View):
"id_token": access_token.id_token.to_jwt(self.provider),
}
if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
if SCOPE_OFFLINE_ACCESS in self.params.scope:
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
refresh_token = RefreshToken(
user=self.params.device_code.user,

View File

@ -108,7 +108,7 @@ class UserInfoView(View):
response = super().dispatch(request, *args, **kwargs)
allowed_origins = []
if self.token:
allowed_origins = [x.url for x in self.token.provider.redirect_uris]
allowed_origins = self.token.provider.redirect_uris.split("\n")
cors_allow(self.request, response, *allowed_origins)
return response

View File

@ -121,6 +121,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
"basic_auth_password_attribute": ["iexact"],
"basic_auth_user_attribute": ["iexact"],
"mode": ["iexact"],
"redirect_uris": ["iexact"],
"cookie_domain": ["iexact"],
}
search_fields = ["name"]

View File

@ -13,13 +13,7 @@ from rest_framework.serializers import Serializer
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator
from authentik.outposts.models import OutpostModel
from authentik.providers.oauth2.models import (
ClientTypes,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
SCOPE_AK_PROXY = "ak_proxy"
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
@ -30,15 +24,14 @@ def get_cookie_secret():
return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32))
def _get_callback_url(uri: str) -> list[RedirectURI]:
return [
RedirectURI(
RedirectURIMatchingMode.STRICT,
def _get_callback_url(uri: str) -> str:
return "\n".join(
[
urljoin(uri, "outpost.goauthentik.io/callback")
+ f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
),
RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true"),
]
uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
]
)
class ProxyMode(models.TextChoices):

View File

@ -1,12 +1,13 @@
"""proxy provider tasks"""
from hashlib import sha256
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError
from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP
@ -25,7 +26,7 @@ def proxy_set_defaults():
def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
hashed_session_id = hash_session_key(session_id)
hashed_session_id = sha256(session_id.encode("ascii")).hexdigest()
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
async_to_sync(layer.group_send)(

View File

@ -50,7 +50,6 @@ class AssertionProcessor:
_issue_instant: str
_assertion_id: str
_response_id: str
_valid_not_before: str
_session_not_on_or_after: str
@ -63,7 +62,6 @@ class AssertionProcessor:
self._issue_instant = get_time_string()
self._assertion_id = get_random_id()
self._response_id = get_random_id()
self._valid_not_before = get_time_string(
timedelta_from_string(self.provider.assertion_valid_not_before)
@ -132,9 +130,7 @@ class AssertionProcessor:
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
auth_n_statement.attrib["SessionIndex"] = sha256(
self.http_request.session.session_key.encode("ascii")
).hexdigest()
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
@ -289,7 +285,7 @@ class AssertionProcessor:
response.attrib["Version"] = "2.0"
response.attrib["IssueInstant"] = self._issue_instant
response.attrib["Destination"] = self.provider.acs_url
response.attrib["ID"] = self._response_id
response.attrib["ID"] = get_random_id()
if self.auth_n_request.id:
response.attrib["InResponseTo"] = self.auth_n_request.id
@ -312,7 +308,7 @@ class AssertionProcessor:
ref = xmlsec.template.add_reference(
signature_node,
digest_algorithm_transform,
uri="#" + element.attrib["ID"],
uri="#" + self._assertion_id,
)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)

View File

@ -180,10 +180,6 @@ class TestAuthNRequest(TestCase):
# Now create a response and convert it to string (provider)
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
response = response_proc.build_response()
# Ensure both response and assertion ID are in the response twice (once as ID attribute,
# once as ds:Reference URI)
self.assertEqual(response.count(response_proc._assertion_id), 2)
self.assertEqual(response.count(response_proc._response_id), 2)
# Now parse the response (source)
http_request.POST = QueryDict(mutable=True)

View File

@ -2,10 +2,9 @@
from itertools import batched
from django.db import transaction
from pydantic import ValidationError
from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp
from pydanticscim.responses import PatchOp, PatchOperation
from authentik.core.models import Group
from authentik.lib.sync.mapper import PropertyMappingManager
@ -20,7 +19,7 @@ from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import (
SCIMRequestException,
)
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchRequest
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.providers.scim.models import (
SCIMMapping,
@ -105,47 +104,13 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
provider=self.provider, group=group, scim_id=scim_id
)
users = list(group.users.order_by("id").values_list("id", flat=True))
self._patch_add_users(connection, users)
self._patch_add_users(group, users)
return connection
def update(self, group: Group, connection: SCIMProviderGroup):
"""Update existing group"""
scim_group = self.to_schema(group, connection)
scim_group.id = connection.scim_id
try:
if self._config.patch.supported:
return self._update_patch(group, scim_group, connection)
return self._update_put(group, scim_group, connection)
except NotFoundSyncException:
# Resource missing is handled by self.write, which will re-create the group
raise
def _update_patch(
self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup
):
"""Update a group via PATCH request"""
# Patch group's attributes instead of replacing it and re-adding users if we can
self._request(
"PATCH",
f"/Groups/{connection.scim_id}",
json=PatchRequest(
Operations=[
PatchOperation(
op=PatchOp.replace,
path=None,
value=scim_group.model_dump(mode="json", exclude_unset=True),
)
]
).model_dump(
mode="json",
exclude_unset=True,
exclude_none=True,
),
)
return self.patch_compare_users(group)
def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup):
"""Update a group via PUT request"""
try:
self._request(
"PUT",
@ -155,25 +120,33 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
exclude_unset=True,
),
)
return self.patch_compare_users(group)
users = list(group.users.order_by("id").values_list("id", flat=True))
return self._patch_add_users(group, users)
except NotFoundSyncException:
# Resource missing is handled by self.write, which will re-create the group
raise
except (SCIMRequestException, ObjectExistsSyncException):
# Some providers don't support PUT on groups, so this is mainly a fix for the initial
# sync, send patch add requests for all the users the group currently has
return self._update_patch(group, scim_group, connection)
users = list(group.users.order_by("id").values_list("id", flat=True))
self._patch_add_users(group, users)
# Also update the group name
return self._patch(
scim_group.id,
PatchOperation(
op=PatchOp.replace,
path="displayName",
value=scim_group.displayName,
),
)
def update_group(self, group: Group, action: Direction, users_set: set[int]):
"""Update a group, either using PUT to replace it or PATCH if supported"""
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
if not scim_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
if self._config.patch.supported:
if action == Direction.add:
return self._patch_add_users(scim_group, users_set)
return self._patch_add_users(group, users_set)
if action == Direction.remove:
return self._patch_remove_users(scim_group, users_set)
return self._patch_remove_users(group, users_set)
try:
return self.write(group)
except SCIMRequestException as exc:
@ -181,24 +154,19 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
# Assume that provider does not support PUT and also doesn't support
# ServiceProviderConfig, so try PATCH as a fallback
if action == Direction.add:
return self._patch_add_users(scim_group, users_set)
return self._patch_add_users(group, users_set)
if action == Direction.remove:
return self._patch_remove_users(scim_group, users_set)
return self._patch_remove_users(group, users_set)
raise exc
def _patch_chunked(
def _patch(
self,
group_id: str,
*ops: PatchOperation,
):
"""Helper function that chunks patch requests based on the maxOperations attribute.
This is not strictly according to specs but there's nothing in the schema that allows the
us to know what the maximum patch operations per request should be."""
chunk_size = self._config.bulk.maxOperations
if chunk_size < 1:
chunk_size = len(ops)
if len(ops) < 1:
return
for chunk in batched(ops, chunk_size):
req = PatchRequest(Operations=list(chunk))
self._request(
@ -209,70 +177,16 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
),
)
@transaction.atomic
def patch_compare_users(self, group: Group):
"""Compare users with a SCIM group and add/remove any differences"""
# Get scim group first
def _patch_add_users(self, group: Group, users_set: set[int]):
"""Add users in users_set to group"""
if len(users_set) < 1:
return
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
if not scim_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
# Get a list of all users in the authentik group
raw_users_should = list(group.users.order_by("id").values_list("id", flat=True))
# Lookup the SCIM IDs of the users
users_should: list[str] = list(
SCIMProviderUser.objects.filter(
user__pk__in=raw_users_should, provider=self.provider
).values_list("scim_id", flat=True)
)
if len(raw_users_should) != len(users_should):
self.logger.warning(
"User count mismatch, not all users in the group are synced to SCIM yet.",
group=group,
)
# Get current group status
current_group = SCIMGroupSchema.model_validate(
self._request("GET", f"/Groups/{scim_group.scim_id}")
)
users_to_add = []
users_to_remove = []
# Check users currently in group and if they shouldn't be in the group and remove them
for user in current_group.members or []:
if user.value not in users_should:
users_to_remove.append(user.value)
# Check users that should be in the group and add them
for user in users_should:
if len([x for x in current_group.members if x.value == user]) < 1:
users_to_add.append(user)
# Only send request if we need to make changes
if len(users_to_add) < 1 and len(users_to_remove) < 1:
return
return self._patch_chunked(
scim_group.scim_id,
*[
PatchOperation(
op=PatchOp.add,
path="members",
value=[{"value": x}],
)
for x in users_to_add
],
*[
PatchOperation(
op=PatchOp.remove,
path="members",
value=[{"value": x}],
)
for x in users_to_remove
],
)
def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
"""Add users in users_set to group"""
if len(users_set) < 1:
return
user_ids = list(
SCIMProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
@ -280,7 +194,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
)
if len(user_ids) < 1:
return
self._patch_chunked(
self._patch(
scim_group.scim_id,
*[
PatchOperation(
@ -292,10 +206,16 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
],
)
def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]):
def _patch_remove_users(self, group: Group, users_set: set[int]):
"""Remove users in users_set from group"""
if len(users_set) < 1:
return
scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first()
if not scim_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
user_ids = list(
SCIMProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
@ -303,7 +223,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
)
if len(user_ids) < 1:
return
self._patch_chunked(
self._patch(
scim_group.scim_id,
*[
PatchOperation(

View File

@ -2,7 +2,6 @@
from pydantic import Field
from pydanticscim.group import Group as BaseGroup
from pydanticscim.responses import PatchOperation as BasePatchOperation
from pydanticscim.responses import PatchRequest as BasePatchRequest
from pydanticscim.responses import SCIMError as BaseSCIMError
from pydanticscim.service_provider import Bulk as BaseBulk
@ -19,7 +18,6 @@ SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
class User(BaseUser):
"""Modified User schema with added externalId field"""
id: str | int | None = None
schemas: list[str] = [SCIM_USER_SCHEMA]
externalId: str | None = None
meta: dict | None = None
@ -28,7 +26,6 @@ class User(BaseUser):
class Group(BaseGroup):
"""Modified Group schema with added externalId field"""
id: str | int | None = None
schemas: list[str] = [SCIM_GROUP_SCHEMA]
externalId: str | None = None
meta: dict | None = None
@ -71,12 +68,6 @@ class PatchRequest(BasePatchRequest):
schemas: tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:PatchOp",)
class PatchOperation(BasePatchOperation):
"""PatchOperation with optional path"""
path: str | None
class SCIMError(BaseSCIMError):
"""SCIM error with optional status code"""

View File

@ -252,118 +252,3 @@ class SCIMMembershipTests(TestCase):
],
},
)
def test_member_add_save(self):
"""Test member add + save"""
config = ServiceProviderConfiguration.default()
config.patch.supported = True
user_scim_id = generate_id()
group_scim_id = generate_id()
uid = generate_id()
group = Group.objects.create(
name=uid,
)
user = User.objects.create(username=generate_id())
# Test initial sync of group creation
with Mocker() as mocker:
mocker.get(
"https://localhost/ServiceProviderConfig",
json=config.model_dump(),
)
mocker.post(
"https://localhost/Users",
json={
"id": user_scim_id,
},
)
mocker.post(
"https://localhost/Groups",
json={
"id": group_scim_id,
},
)
self.configure()
sync_tasks.trigger_single_task(self.provider, scim_sync).get()
self.assertEqual(mocker.call_count, 6)
self.assertEqual(mocker.request_history[0].method, "GET")
self.assertEqual(mocker.request_history[1].method, "GET")
self.assertEqual(mocker.request_history[2].method, "GET")
self.assertEqual(mocker.request_history[3].method, "POST")
self.assertEqual(mocker.request_history[4].method, "GET")
self.assertEqual(mocker.request_history[5].method, "POST")
self.assertJSONEqual(
mocker.request_history[3].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"emails": [],
"active": True,
"externalId": user.uid,
"name": {"familyName": " ", "formatted": " ", "givenName": ""},
"displayName": "",
"userName": user.username,
},
)
self.assertJSONEqual(
mocker.request_history[5].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"externalId": str(group.pk),
"displayName": group.name,
},
)
with Mocker() as mocker:
mocker.get(
"https://localhost/ServiceProviderConfig",
json=config.model_dump(),
)
mocker.get(
f"https://localhost/Groups/{group_scim_id}",
json={},
)
mocker.patch(
f"https://localhost/Groups/{group_scim_id}",
json={},
)
group.users.add(user)
group.save()
self.assertEqual(mocker.call_count, 5)
self.assertEqual(mocker.request_history[0].method, "GET")
self.assertEqual(mocker.request_history[1].method, "PATCH")
self.assertEqual(mocker.request_history[2].method, "GET")
self.assertEqual(mocker.request_history[3].method, "PATCH")
self.assertEqual(mocker.request_history[4].method, "GET")
self.assertJSONEqual(
mocker.request_history[1].body,
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [{"value": user_scim_id}],
}
],
},
)
self.assertJSONEqual(
mocker.request_history[3].body,
{
"Operations": [
{
"op": "replace",
"value": {
"id": group_scim_id,
"displayName": group.name,
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"externalId": str(group.pk),
},
}
]
},
)

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