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.")} -

- `} - > -
- -