From d0ef8a8b8e3864ad5bd7bd09750f717de011a2e5 Mon Sep 17 00:00:00 2001
From: "Jens L."
Date: Tue, 2 Dec 2025 22:25:47 +0100
Subject: [PATCH] endpoints/stage: v2, better error handling, more settings
(#18545)
* add options, idle fallback
Signed-off-by: Jens Langhammer
* delete other device tokens during enroll
Signed-off-by: Jens Langhammer
* better error handling
Signed-off-by: Jens Langhammer
---------
Signed-off-by: Jens Langhammer
---
.../connectors/agent/api/connectors.py | 3 +
...nnector_challenge_idle_timeout_and_more.py | 30 +++
.../endpoints/connectors/agent/models.py | 4 +
authentik/endpoints/connectors/agent/stage.py | 47 +++--
.../connectors/agent/tests/test_agent_api.py | 10 +
.../connectors/agent/tests/test_stage.py | 50 +++++
authentik/endpoints/stage.py | 11 +-
blueprints/schema.json | 9 +
schema.yml | 17 ++
.../connectors/agent/AgentConnectorForm.ts | 178 +++++++++++-------
web/src/flow/FlowExecutor.ts | 2 +-
.../agent}/EndpointAgentStage.ts | 11 ++
12 files changed, 285 insertions(+), 87 deletions(-)
create mode 100644 authentik/endpoints/connectors/agent/migrations/0004_agentconnector_challenge_idle_timeout_and_more.py
rename web/src/flow/stages/{endpoint_agent => endpoint/agent}/EndpointAgentStage.ts (88%)
diff --git a/authentik/endpoints/connectors/agent/api/connectors.py b/authentik/endpoints/connectors/agent/api/connectors.py
index ae95362897..66bb9b9f46 100644
--- a/authentik/endpoints/connectors/agent/api/connectors.py
+++ b/authentik/endpoints/connectors/agent/api/connectors.py
@@ -50,6 +50,8 @@ class AgentConnectorSerializer(ConnectorSerializer):
"nss_uid_offset",
"nss_gid_offset",
"challenge_key",
+ "challenge_idle_timeout",
+ "challenge_trigger_check_in",
"jwt_federation_providers",
]
@@ -133,6 +135,7 @@ class AgentConnectorViewSet(
device=device,
connector=token.connector,
)
+ DeviceToken.objects.filter(device=connection).delete()
token = DeviceToken.objects.create(device=connection, expiring=False)
return Response(
{
diff --git a/authentik/endpoints/connectors/agent/migrations/0004_agentconnector_challenge_idle_timeout_and_more.py b/authentik/endpoints/connectors/agent/migrations/0004_agentconnector_challenge_idle_timeout_and_more.py
new file mode 100644
index 0000000000..d0254a46e1
--- /dev/null
+++ b/authentik/endpoints/connectors/agent/migrations/0004_agentconnector_challenge_idle_timeout_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.8 on 2025-12-02 20:31
+
+import authentik.lib.utils.time
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "authentik_endpoints_connectors_agent",
+ "0003_agentconnector_auth_session_duration_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="agentconnector",
+ name="challenge_idle_timeout",
+ field=models.TextField(
+ default="seconds=5",
+ validators=[authentik.lib.utils.time.timedelta_string_validator],
+ ),
+ ),
+ migrations.AddField(
+ model_name="agentconnector",
+ name="challenge_trigger_check_in",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/authentik/endpoints/connectors/agent/models.py b/authentik/endpoints/connectors/agent/models.py
index f85d35e7f8..429bc76b49 100644
--- a/authentik/endpoints/connectors/agent/models.py
+++ b/authentik/endpoints/connectors/agent/models.py
@@ -46,6 +46,10 @@ class AgentConnector(Connector):
nss_gid_offset = models.PositiveIntegerField(default=1000)
challenge_key = models.ForeignKey(CertificateKeyPair, on_delete=models.CASCADE, null=True)
+ challenge_idle_timeout = models.TextField(
+ validators=[timedelta_string_validator], default="seconds=5"
+ )
+ challenge_trigger_check_in = models.BooleanField(default=False)
@property
def serializer(self) -> type[Serializer]:
diff --git a/authentik/endpoints/connectors/agent/stage.py b/authentik/endpoints/connectors/agent/stage.py
index 46fd2b1e32..349beefdfb 100644
--- a/authentik/endpoints/connectors/agent/stage.py
+++ b/authentik/endpoints/connectors/agent/stage.py
@@ -5,7 +5,7 @@ from django.http import HttpResponse
from django.utils.timezone import now
from jwt import PyJWTError, decode, encode
from rest_framework.exceptions import ValidationError
-from rest_framework.fields import CharField
+from rest_framework.fields import CharField, IntegerField
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import DeviceToken
@@ -17,6 +17,7 @@ from authentik.flows.challenge import (
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.flows.stage import ChallengeStageView
from authentik.lib.generators import generate_id
+from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.models import JWTAlgorithms
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
@@ -29,6 +30,7 @@ class EndpointAgentChallenge(Challenge):
component = CharField(default="ak-stage-endpoint-agent")
challenge = CharField()
+ challenge_idle_timeout = IntegerField()
class EndpointAgentChallengeResponse(ChallengeResponse):
@@ -40,20 +42,24 @@ class EndpointAgentChallengeResponse(ChallengeResponse):
def validate_response(self, response: str | None) -> Device | None:
if not response:
return None
- raw = decode(
- response,
- options={"verify_signature": False},
- audience="goauthentik.io/platform/endpoint",
- )
+ try:
+ raw = decode(
+ response,
+ options={"verify_signature": False},
+ audience="goauthentik.io/platform/endpoint",
+ )
+ except PyJWTError as exc:
+ self.stage.logger.warning("Could not parse response", exc=exc)
+ raise ValidationError("Invalid challenge response") from None
device = Device.filter_not_expired(identifier=raw["iss"]).first()
if not device:
self.stage.logger.warning("Could not find device for challenge")
raise ValidationError("Invalid challenge response")
- try:
- for token in DeviceToken.filter_not_expired(
- device__device=device,
- device__connector=self.stage.executor.current_stage.connector,
- ).values_list("key", flat=True):
+ for token in DeviceToken.filter_not_expired(
+ device__device=device,
+ device__connector=self.stage.executor.current_stage.connector,
+ ).values_list("key", flat=True):
+ try:
decoded = decode(
response,
key=token,
@@ -68,9 +74,9 @@ class EndpointAgentChallengeResponse(ChallengeResponse):
self.stage.logger.warning("mismatched challenge")
raise ValidationError("Invalid challenge response")
return device
- except PyJWTError as exc:
- self.stage.logger.warning("failed to validate device challenge response", exc=exc)
- raise ValidationError("Invalid challenge response") from None
+ except PyJWTError as exc:
+ self.stage.logger.warning("failed to validate device challenge response", exc=exc)
+ raise ValidationError("Invalid challenge response")
class AuthenticatorEndpointStageView(ChallengeStageView):
@@ -96,6 +102,7 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
"iss": str(stage.pk),
"iat": int(iat.timestamp()),
"exp": int((iat + timedelta(minutes=5)).timestamp()),
+ "goauthentik.io/device/check_in": stage.connector.challenge_trigger_check_in,
},
headers={"kid": keypair.kid},
key=keypair.private_key,
@@ -106,13 +113,23 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
data={
"component": "ak-stage-endpoint-agent",
"challenge": challenge,
+ "challenge_idle_timeout": int(
+ timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
+ ),
}
)
+ def challenge_invalid(self, response: EndpointAgentChallengeResponse) -> HttpResponse:
+ if self.executor.current_stage.mode == StageMode.OPTIONAL:
+ return self.executor.stage_ok()
+ return super().challenge_invalid(response)
+
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
- self.executor.plan.context.pop(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, None)
if device := response.validated_data.get("response"):
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
elif self.executor.current_stage.mode == StageMode.REQUIRED:
return self.executor.stage_invalid("Invalid challenge response")
return self.executor.stage_ok()
+
+ def cleanup(self):
+ self.executor.plan.context.pop(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, None)
diff --git a/authentik/endpoints/connectors/agent/tests/test_agent_api.py b/authentik/endpoints/connectors/agent/tests/test_agent_api.py
index 26dc417390..c817e811ef 100644
--- a/authentik/endpoints/connectors/agent/tests/test_agent_api.py
+++ b/authentik/endpoints/connectors/agent/tests/test_agent_api.py
@@ -58,6 +58,16 @@ class TestAgentAPI(APITestCase):
)
self.assertEqual(response.status_code, 200)
+ def test_enroll_token_delete(self):
+ response = self.client.post(
+ reverse("authentik_api:agentconnector-enroll"),
+ data={"device_serial": self.device.identifier, "device_name": "bar"},
+ HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(DeviceToken.objects.filter(pk=self.device_token.pk).exists())
+ self.assertEqual(DeviceToken.objects.filter(device=self.connection).count(), 1)
+
def test_enroll_group(self):
device_group = DeviceAccessGroup.objects.create(name=generate_id())
self.token.device_group = device_group
diff --git a/authentik/endpoints/connectors/agent/tests/test_stage.py b/authentik/endpoints/connectors/agent/tests/test_stage.py
index ff2e361ab0..766198f207 100644
--- a/authentik/endpoints/connectors/agent/tests/test_stage.py
+++ b/authentik/endpoints/connectors/agent/tests/test_stage.py
@@ -49,6 +49,11 @@ class TestEndpointStage(FlowTestCase):
challenge = loads(res.content.decode())["challenge"]
+ DeviceToken.objects.create(
+ device=self.connection,
+ key=generate_id(),
+ )
+
response = encode(
{
"iss": self.device.identifier,
@@ -123,6 +128,27 @@ class TestEndpointStage(FlowTestCase):
self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
+ def test_endpoint_stage_optional_invalid(self):
+ flow = create_test_flow()
+ stage = EndpointStage.objects.create(connector=self.connector, mode=StageMode.OPTIONAL)
+ FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
+
+ res = self.client.get(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(res.status_code, 200)
+ self.assertStageResponse(res, flow=flow, component="ak-stage-endpoint-agent")
+
+ with self.assertFlowFinishes() as plan:
+ res = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+ data={"component": "ak-stage-endpoint-agent", "response": generate_id()},
+ )
+ self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
+ plan = plan()
+ self.assertNotIn(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, plan.context)
+ self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)
+
def test_endpoint_stage_required_none(self):
flow = create_test_flow()
stage = EndpointStage.objects.create(connector=self.connector, mode=StageMode.REQUIRED)
@@ -144,3 +170,27 @@ class TestEndpointStage(FlowTestCase):
component="ak-stage-access-denied",
error_message="Invalid challenge response",
)
+
+ def test_endpoint_stage_required_invalid(self):
+ flow = create_test_flow()
+ stage = EndpointStage.objects.create(connector=self.connector, mode=StageMode.REQUIRED)
+ FlowStageBinding.objects.create(stage=stage, target=flow, order=0)
+
+ res = self.client.get(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(res.status_code, 200)
+ self.assertStageResponse(res, flow=flow, component="ak-stage-endpoint-agent")
+
+ res = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+ data={"component": "ak-stage-endpoint-agent", "response": generate_id()},
+ )
+ self.assertStageResponse(
+ res,
+ flow=flow,
+ component="ak-stage-endpoint-agent",
+ response_errors={
+ "response": [{"string": "Invalid challenge response", "code": "invalid"}]
+ },
+ )
diff --git a/authentik/endpoints/stage.py b/authentik/endpoints/stage.py
index 33492f8840..cb4b039b29 100644
--- a/authentik/endpoints/stage.py
+++ b/authentik/endpoints/stage.py
@@ -6,10 +6,15 @@ PLAN_CONTEXT_ENDPOINT_CONNECTOR = "endpoint_connector"
class EndpointStageView(StageView):
- def dispatch(self, request, *args, **kwargs):
+ def _get_inner(self):
stage: EndpointStage = self.executor.current_stage
inner_stage: type[StageView] | None = stage.connector.stage
if not inner_stage:
return self.executor.stage_ok()
- view = inner_stage(self.executor, request=self.request)
- return view.dispatch(request, *args, **kwargs)
+ return inner_stage(self.executor, request=self.request)
+
+ def dispatch(self, request, *args, **kwargs):
+ return self._get_inner().dispatch(request, *args, **kwargs)
+
+ def cleanup(self):
+ return self._get_inner().cleanup()
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 3b7a3cd0fd..1d044ea0fa 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -6023,6 +6023,15 @@
"format": "uuid",
"title": "Challenge key"
},
+ "challenge_idle_timeout": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Challenge idle timeout"
+ },
+ "challenge_trigger_check_in": {
+ "type": "boolean",
+ "title": "Challenge trigger check in"
+ },
"jwt_federation_providers": {
"type": "array",
"items": {
diff --git a/schema.yml b/schema.yml
index 9b74ce38a8..5174bf0bfe 100644
--- a/schema.yml
+++ b/schema.yml
@@ -32804,6 +32804,10 @@ components:
type: string
format: uuid
nullable: true
+ challenge_idle_timeout:
+ type: string
+ challenge_trigger_check_in:
+ type: boolean
jwt_federation_providers:
type: array
items:
@@ -32852,6 +32856,11 @@ components:
type: string
format: uuid
nullable: true
+ challenge_idle_timeout:
+ type: string
+ minLength: 1
+ challenge_trigger_check_in:
+ type: boolean
jwt_federation_providers:
type: array
items:
@@ -36779,8 +36788,11 @@ components:
$ref: '#/components/schemas/ErrorDetail'
challenge:
type: string
+ challenge_idle_timeout:
+ type: integer
required:
- challenge
+ - challenge_idle_timeout
EndpointAgentChallengeResponseRequest:
type: object
description: Response to signed challenge
@@ -45471,6 +45483,11 @@ components:
type: string
format: uuid
nullable: true
+ challenge_idle_timeout:
+ type: string
+ minLength: 1
+ challenge_trigger_check_in:
+ type: boolean
jwt_federation_providers:
type: array
items:
diff --git a/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts b/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts
index c0fb612779..09204dc32b 100644
--- a/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts
+++ b/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts
@@ -37,8 +37,8 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm {
@@ -61,6 +61,18 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm
+
+ ${msg("Interval how frequently the agent tries to update its config.")}
+
+ `}
+ >
+
-
-
- ${msg("Flow used for users to authorize.")}
-
-
-
-
-
- ${msg("Certificate used for signing device compliance challenges.")}
-
-
-
- ${msg("Configure how long an authenticated session is valid for.")}
-
- `}
- >
-
-
-