endpoints/stage: v2, better error handling, more settings (#18545)

* add options, idle fallback

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* delete other device tokens during enroll

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better error handling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-12-02 22:25:47 +01:00
committed by GitHub
parent 1474c65e11
commit d0ef8a8b8e
12 changed files with 285 additions and 87 deletions
@@ -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(
{
@@ -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),
),
]
@@ -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]:
+32 -15
View File
@@ -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)
@@ -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
@@ -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"}]
},
)
+8 -3
View File
@@ -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()
+9
View File
@@ -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": {
+17
View File
@@ -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:
@@ -37,8 +37,8 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
getSuccessMessage(): string {
return this.instance
? msg("Successfully updated connector.")
: msg("Successfully created connector.");
? msg("Successfully updated agent connector.")
: msg("Successfully created agent connector.");
}
async send(data: AgentConnector): Promise<AgentConnector> {
@@ -61,6 +61,18 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
value=${ifDefined(this.instance?.name)}
required
></ak-text-input>
<ak-text-input
name="refreshInterval"
label=${msg("Refresh interval")}
input-hint="code"
required
value="${ifDefined(this.instance?.refreshInterval ?? "minutes=30")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Interval how frequently the agent tries to update its config.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal name="enabled">
<label class="pf-c-switch">
<input
@@ -76,73 +88,103 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
<span class="pf-c-switch__label">${msg("Enabled")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Authorization flow")}
required
name="authorizationFlow"
>
<ak-flow-search
label=${msg("Authorization flow")}
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authorize.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Certificate")} name="challengeKey">
<ak-crypto-certificate-search
label=${msg("Certificate")}
placeholder=${msg("Select a certificate...")}
certificate=${ifPresent(this.instance?.challengeKey)}
name="certificate"
>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg("Certificate used for signing device compliance challenges.")}
</p>
</ak-form-element-horizontal>
<ak-text-input
name="authSessionDuration"
label=${msg("Session duration")}
input-hint="code"
required
value="${ifDefined(this.instance?.authSessionDuration ?? "hours=8")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long an authenticated session is valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal name="authTerminateSessionOnExpiry">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.authTerminateSessionOnExpiry ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Terminate authenticated sessions on token expiry")}</span
<ak-form-group label="${msg("Authentication settings")}">
<div class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Authorization flow")}
name="authorizationFlow"
>
</label>
</ak-form-element-horizontal>
<ak-text-input
name="refreshInterval"
label=${msg("Refresh interval")}
input-hint="code"
required
value="${ifDefined(this.instance?.refreshInterval ?? "minutes=30")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Interval how frequently the agent tries to update its config.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-group open label="${msg("Unix settings")}">
<ak-flow-search
label=${msg("Authorization flow")}
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used for users to authorize.")}
</p>
</ak-form-element-horizontal>
<ak-text-input
name="authSessionDuration"
label=${msg("Session duration")}
input-hint="code"
required
value="${ifDefined(this.instance?.authSessionDuration ?? "hours=8")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long an authenticated session is valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal name="authTerminateSessionOnExpiry">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.authTerminateSessionOnExpiry ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Terminate authenticated sessions on token expiry")}</span
>
</label>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group label="${msg("Device compliance settings")}">
<div class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Challenge certificate")}
name="challengeKey"
>
<ak-crypto-certificate-search
label=${msg("Certificate")}
placeholder=${msg("Select a certificate...")}
certificate=${ifPresent(this.instance?.challengeKey)}
name="certificate"
>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg("Certificate used for signing device compliance challenges.")}
</p>
</ak-form-element-horizontal>
<ak-text-input
name="challengeIdleTimeout"
label=${msg("Challenge idle timeout")}
input-hint="code"
required
value="${ifDefined(this.instance?.challengeIdleTimeout ?? "seconds=3")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg(
"Duration the flow executor will wait before continuing without a response.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal name="challengeTriggerCheckIn">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.challengeTriggerCheckIn ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Trigger check-in on device")}</span
>
</label>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group label="${msg("Unix settings")}">
<div class="pf-c-form">
<ak-number-input
label=${msg("NSS User ID offset")}
+1 -1
View File
@@ -375,7 +375,7 @@ export class FlowExecutor
.challenge=${this.challenge}
></ak-stage-user-login>`;
case "ak-stage-endpoint-agent":
await import("#flow/stages/endpoint_agent/EndpointAgentStage");
await import("#flow/stages/endpoint/agent/EndpointAgentStage");
return html`<ak-stage-endpoint-agent
.host=${this as StageHost}
.challenge=${this.challenge}
@@ -36,6 +36,17 @@ export class EndpointAgentStage extends BaseStage<
);
}
});
// Fallback in case we don't get a response
setTimeout(() => {
this.host?.submit(
{
response: null,
} as EndpointAgentChallengeResponseRequest,
{
invisible: true,
},
);
}, this.challenge.challengeIdleTimeout * 1000);
}
updated(changedProperties: PropertyValues<this>) {