Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions .github/workflows/ci-outpost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ jobs:
fail-fast: false
matrix:
type:
- proxy
- ldap
- radius
- rac
Expand All @@ -92,24 +91,6 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Pin pnpm store directory
run: |
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
pnpm --dir web install --frozen-lockfile
- name: Build web
run: pnpm --dir web run build-proxy
- name: Build outpost
run: |
set -x
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ It is a **polyglot monorepo**. Most work lands in one of the subtrees below; whe
| Language | Where | What it is | Deeper guide |
| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
| **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, proxy, RAC, RADIUS) and the front reverse-proxy that fronts Django. | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
| **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) |
| **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) |

Expand All @@ -19,8 +19,8 @@ The Python core and the web UI talk through a **generated OpenAPI client** — n
```
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy)
internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
internal/ # Shared Go: outpost implementations, config
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
packages/ # Shared workspace packages, polyglot:
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
Expand Down
2 changes: 1 addition & 1 deletion authentik/brands/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def map_serializer_field(self, auto_schema, direction):
props[_flag.key]["description"] = _flag.description
if _flag.deprecated:
props[_flag.key]["deprecated"] = _flag.deprecated
if visibility == "public":
if visibility == "public" and not _flag.deprecated:
required.append(_flag.key)
return build_object_type(props, required=required)

Expand Down
13 changes: 0 additions & 13 deletions authentik/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from contextlib import contextmanager
from contextvars import ContextVar

from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_in
from django.core.cache import cache
from django.db.models import Model
Expand All @@ -20,9 +19,7 @@
User,
default_token_duration,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.lib.models import ExpiringModel
from authentik.root.ws.consumer import build_device_group

password_changed = Signal()
"""Arguments: user: User, password: str"""
Expand Down Expand Up @@ -97,16 +94,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):

AuthenticatedSession.create_from_request(request, user)

if not RefreshOtherFlowsAfterAuthentication.get():
return
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
if device_cookie:
layer.group_send_blocking(
build_device_group(device_cookie),
{"type": "event.session.authenticated"},
)


@receiver(post_save, sender=User)
def user_deactivated_delete_sessions(sender: type[Model], instance: User, **_):
Expand Down
33 changes: 32 additions & 1 deletion authentik/core/sources/flow_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,41 @@ def __init__(
group_id=group_id,
**self.user_info,
)
for group_id in self.user_properties.setdefault("groups", [])
for group_id in self._keyable_group_ids(self.user_properties.setdefault("groups", []))
}
del self.user_properties["groups"]

def _keyable_group_ids(self, group_ids: list[Any]) -> list[Any]:
"""Drop group identifiers that cannot be used as a `groups_properties` key.

An unhashable identifier, such as an object in an IdP's `groups` claim,
raised `TypeError` out of the constructor and surfaced as HTTP 500,
locking every member of that group out of the source. Skipped entries are
recorded so a user arriving with fewer groups than the IdP granted stays
visible to an operator.
"""
keyable = []
skipped = []
for group_id in group_ids:
try:
hash(group_id)
except TypeError:
skipped.append(group_id)
else:
keyable.append(group_id)
if skipped:
self._logger.warning("Skipping groups with an unusable identifier", groups=skipped)
Event.new(
EventAction.CONFIGURATION_ERROR,
message=(
f"Source '{self.source.name}' returned {len(skipped)} group(s) whose "
"identifier is not a string; they were not applied to the user."
),
source=self.source,
groups=skipped,
).from_http(self.request)
return keyable

def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
"""decide which action should be taken"""
# When request is authenticated, always link
Expand Down
22 changes: 22 additions & 0 deletions authentik/core/tests/test_source_flow_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from authentik.core.sources.matcher import MatchFailureReason
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import RequestFactory, create_test_flow
from authentik.events.models import Event, EventAction
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
Expand Down Expand Up @@ -161,6 +162,27 @@ def test_unauthenticated_link(self):
self.assertIsNone(connection.pk)
flow_manager.get_flow()

def test_unusable_group_identifier_does_not_abort(self):
"""Test a group identifier that cannot be used as a key being skipped (#25191)"""
request = self.request_factory.get("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(
self.source,
request,
self.identifier,
{"info": {"groups": ["usable", {"id": "unusable"}, ["also-unusable"]]}},
{},
)
# Enrolling has to remain possible: raising here surfaced as HTTP 500 and
# locked out every member of such a group.
self.assertEqual(list(flow_manager.groups_properties.keys()), ["usable"])
self.assertEqual(flow_manager.get_action()[0], Action.ENROLL)
self.assertEqual(flow_manager.get_flow().status_code, 302)

event = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR).first()
self.assertIsNotNone(event)
self.assertIn("2 group(s)", event.context["message"])
self.assertEqual(event.context["groups"], [{"id": "unusable"}, ["also-unusable"]])

def test_unauthenticated_enroll_email(self):
"""Test un-authenticated user enrolling (link on email)"""
User.objects.create(username="foo", email="foo@bar.baz")
Expand Down
4 changes: 2 additions & 2 deletions authentik/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
InterfaceView,
RootRedirectView,
)
from authentik.events.consumer import ClientConsumer
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.middleware import ChannelsLoggingMiddleware
from authentik.root.ws.consumer import MessageConsumer
from authentik.tenants.channels import TenantsAwareMiddleware

urlpatterns = [
Expand Down Expand Up @@ -116,7 +116,7 @@
path(
"ws/client/",
ChannelsLoggingMiddleware(
TenantsAwareMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi()))
TenantsAwareMiddleware(AuthMiddlewareStack(ClientConsumer.as_asgi()))
),
),
]
Expand Down
39 changes: 39 additions & 0 deletions authentik/events/consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""websocket Message consumer"""

from hashlib import sha256

from asgiref.sync import async_to_sync
from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from django.db import connection

from authentik.core.models import User


def build_user_group(user: User):
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()


class ClientConsumer(JsonWebsocketConsumer):
"""Consumer which sends django.contrib.messages Messages over WS.
channel_name is saved into cache with user_id, and when a add_message is called"""

user: User | None = None

def connect(self):
user = self.scope.get("user")
if user is None or not user.is_authenticated:
raise DenyConnection()
self.user = user
self.accept()
async_to_sync(self.channel_layer.group_add)(build_user_group(self.user), self.channel_name)

def disconnect(self, code):
if self.user:
async_to_sync(self.channel_layer.group_discard)(
build_user_group(self.user), self.channel_name
)

def event_notification(self, event: dict):
"""Event handler for new notifications"""
self.send_json({"message_type": "notification.new", **event})
2 changes: 1 addition & 1 deletion authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
)
from authentik.core.models import Group, PropertyMapping, User
from authentik.crypto.models import CertificateKeyPair
from authentik.events.consumer import build_user_group
from authentik.events.context_processors.base import get_context_processors
from authentik.events.utils import (
cleanse_dict,
Expand All @@ -50,7 +51,6 @@
from authentik.outposts.docker_tls import DockerInlineTLS
from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.root.ws.consumer import build_user_group
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tasks.models import TasksModel
Expand Down
59 changes: 59 additions & 0 deletions authentik/events/tests/test_ws_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from asgiref.sync import sync_to_async
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.test import TransactionTestCase

from authentik.core.tests.utils import create_test_user
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationTransport,
TransportMode,
)
from authentik.lib.generators import generate_id
from authentik.root import websocket


class TestClientWS(TransactionTestCase):
def setUp(self):
self.user = create_test_user()

async def test_unauthenticated(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
connected, _ = await communicator.connect()
self.assertFalse(connected)

async def test_notification(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
communicator.scope["user"] = self.user
connected, _ = await communicator.connect()
self.assertTrue(connected)

transport = await NotificationTransport.objects.acreate(
name=generate_id(), mode=TransportMode.LOCAL
)
event = await sync_to_async(Event.new)(EventAction.LOGIN)
event.set_user(self.user)
await event.asave()
notification = Notification(
user=self.user,
body="foo",
event=event,
hyperlink="goauthentik.io",
hyperlink_label="a link",
)
await sync_to_async(transport.send_local)(notification)

evt = await communicator.receive_json_from(timeout=5)
self.assertEqual(evt["message_type"], "notification.new")
self.assertEqual(evt["id"], str(notification.pk))
self.assertEqual(evt["data"]["pk"], str(notification.pk))
self.assertEqual(evt["data"]["body"], "foo")
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))

await communicator.disconnect()
8 changes: 0 additions & 8 deletions authentik/flows/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@
)


class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"):

default = False
visibility = "public"
description = _("Refresh other tabs after successful authentication.")
deprecated = True


class ContinuousLogin(Flag[bool], key="flows_continuous_login"):

default = False
Expand Down
13 changes: 12 additions & 1 deletion authentik/flows/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, TypedDict
from uuid import UUID

from django.contrib.messages import DEFAULT_TAGS
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.http import JsonResponse
Expand Down Expand Up @@ -42,6 +43,16 @@ class ErrorDetailSerializer(PassiveSerializer):
code = CharField()


FLOW_MESSAGE_LEVELS = list(DEFAULT_TAGS.values())


class FlowMessageSerializer(PassiveSerializer):
"""Serializer for a django.contrib.messages message"""

level = ChoiceField(choices=FLOW_MESSAGE_LEVELS, source="level_tag")
message = CharField()


class ContextualFlowInfo(PassiveSerializer):
"""Contextual flow information for a challenge"""

Expand All @@ -50,6 +61,7 @@ class ContextualFlowInfo(PassiveSerializer):
background_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
cancel_url = CharField()
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])
messages = FlowMessageSerializer(many=True, required=False)


class Challenge(PassiveSerializer):
Expand Down Expand Up @@ -179,7 +191,6 @@ class FrameChallenge(Challenge):


class FrameChallengeResponse(ChallengeResponse):

component = CharField(default="xak-flow-frame")


Expand Down
Loading
Loading