Compare commits

...

2 Commits

Author SHA1 Message Date
Jens Langhammer
026b7d3e73 update template
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-03-09 11:33:48 +01:00
Jens Langhammer
91ac87c934 endpoints/connectors/agent: add browser-backchannel support
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-03-09 11:32:19 +01:00
9 changed files with 151 additions and 67 deletions

View File

@@ -10,6 +10,7 @@ class AuthentikEndpointsConnectorAgentAppConfig(ManagedAppConfig):
label = "authentik_endpoints_connectors_agent"
verbose_name = "authentik Endpoints.Connectors.Agent"
default = True
mountpoint = "endpoints/agent/"
def import_related(self):
from authentik.endpoints.connectors.agent.models import AgentConnector

View File

@@ -1,15 +1,24 @@
from datetime import timedelta
from hmac import compare_digest
from plistlib import PlistFormat, dumps
from uuid import uuid4
from xml.etree.ElementTree import Element, SubElement, tostring # nosec
from django.http import HttpRequest
from django.urls import reverse
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 authentik.core.api.utils import PassiveSerializer
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
from authentik.endpoints.controller import BaseController
from authentik.endpoints.facts import OSFamily
from authentik.endpoints.models import Device
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import JWTAlgorithms
def csp_create_replace_item(loc_uri, data_value) -> Element:
@@ -36,14 +45,12 @@ def csp_create_replace_item(loc_uri, data_value) -> Element:
class MDMConfigResponseSerializer(PassiveSerializer):
config = CharField(required=True)
mime_type = CharField(required=True)
filename = CharField(required=True)
class AgentConnectorController(BaseController[AgentConnector]):
class AgentController(BaseController[AgentConnector]):
@staticmethod
def vendor_identifier() -> str:
return "goauthentik.io/platform"
@@ -51,6 +58,57 @@ class AgentConnectorController(BaseController[AgentConnector]):
def supported_enrollment_methods(self):
return []
def generate_device_challenge(self):
keypair = CertificateKeyPair.objects.get(pk=self.connector.challenge_key_id)
challenge_str = generate_id()
iat = now()
challenge = encode(
{
"atc": challenge_str,
"iss": str(self.connector.pk),
"iat": int(iat.timestamp()),
"exp": int((iat + timedelta(minutes=5)).timestamp()),
"goauthentik.io/device/check_in": self.connector.challenge_trigger_check_in,
},
headers={"kid": keypair.kid},
key=keypair.private_key,
algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
)
return challenge
def validate_device_challenge(self, response: str, challenge: str):
try:
raw = decode(
response,
options={"verify_signature": False},
audience="goauthentik.io/platform/endpoint",
)
except PyJWTError as exc:
self.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.logger.warning("Could not find device for challenge")
raise ValidationError("Invalid challenge response")
for token in DeviceToken.filter_not_expired(
device__device=device, device__connector=self.connector
).values_list("key", flat=True):
try:
decoded = decode(
response,
key=token,
algorithms="HS512",
issuer=device.identifier,
audience="goauthentik.io/platform/endpoint",
)
if not compare_digest(decoded["atc"], challenge):
self.logger.warning("mismatched challenge")
raise ValidationError("Invalid challenge response")
return device
except PyJWTError as exc:
self.logger.warning("failed to validate device challenge response", exc=exc)
raise ValidationError("Invalid challenge response")
def generate_mdm_config(
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken
) -> MDMConfigResponseSerializer:

View File

@@ -21,7 +21,7 @@ from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
if TYPE_CHECKING:
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
from authentik.endpoints.connectors.agent.controller import AgentController
class AgentConnector(Connector):
@@ -73,10 +73,10 @@ class AgentConnector(Connector):
return AuthenticatorEndpointStageView
@property
def controller(self) -> type[AgentConnectorController]:
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
def controller(self) -> type[AgentController]:
from authentik.endpoints.connectors.agent.controller import AgentController
return AgentConnectorController
return AgentController
@property
def component(self) -> str:

View File

@@ -1,15 +1,18 @@
from datetime import timedelta
from hashlib import sha256
from hmac import compare_digest
from typing import cast
from urllib.parse import urlencode
from django.http import HttpResponse
from django.utils.timezone import now
from jwt import PyJWTError, decode, encode
from rest_framework.exceptions import ValidationError
from django.urls import reverse
from rest_framework.fields import CharField, IntegerField
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import DeviceAuthenticationToken, DeviceToken
from authentik.endpoints.connectors.agent.controller import AgentController
from authentik.endpoints.connectors.agent.models import (
AgentConnector,
DeviceAuthenticationToken,
)
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.flows.challenge import (
Challenge,
@@ -17,9 +20,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_DEVICE_AUTH_TOKEN = "goauthentik.io/endpoints/device_auth_token" # nosec
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
@@ -31,8 +32,9 @@ class EndpointAgentChallenge(Challenge):
"""Signed challenge for authentik agent to respond to"""
component = CharField(default="ak-stage-endpoint-agent")
challenge = CharField()
challenge = CharField(required=True)
challenge_idle_timeout = IntegerField()
frame_url = CharField(required=True)
class EndpointAgentChallengeResponse(ChallengeResponse):
@@ -44,47 +46,23 @@ class EndpointAgentChallengeResponse(ChallengeResponse):
def validate_response(self, response: str | None) -> Device | None:
if not response:
return None
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")
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,
algorithms="HS512",
issuer=device.identifier,
audience="goauthentik.io/platform/endpoint",
)
if not compare_digest(
decoded["atc"],
self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
):
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")
return cast(AgentController, self.stage.controller).validate_device_challenge(
response,
self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
)
class AuthenticatorEndpointStageView(ChallengeStageView):
"""Endpoint stage"""
response_class = EndpointAgentChallengeResponse
controller: AgentController
def dispatch(self, request, *args, **kwargs):
stage: EndpointStage = self.executor.current_stage
connector: AgentConnector = stage.connector
self.controller = connector.controller(connector)
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
# Check if we're in a device interactive auth flow, in which case we use that
@@ -119,21 +97,7 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
def get_challenge(self, *args, **kwargs) -> Challenge:
stage: EndpointStage = self.executor.current_stage
keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
challenge_str = generate_id()
iat = now()
challenge = encode(
{
"atc": challenge_str,
"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,
algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
)
challenge = self.controller.generate_device_challenge()
self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
return EndpointAgentChallenge(
data={
@@ -142,6 +106,11 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
"challenge_idle_timeout": int(
timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
),
"frame_url": self.request.build_absolute_uri(
reverse("authentik_endpoints_connectors_agent:browser-backchannel")
+ "?"
+ urlencode({"xak-agent-challenge": challenge})
),
}
)

View File

@@ -1,5 +1,12 @@
from django.urls import path
from authentik.endpoints.connectors.agent.api.connectors import AgentConnectorViewSet
from authentik.endpoints.connectors.agent.api.enrollment_tokens import EnrollmentTokenViewSet
from authentik.endpoints.connectors.agent.views.browser_backchannel import BrowserBackchannel
urlpatterns = [
path("browser-backchannel/", BrowserBackchannel.as_view(), name="browser-backchannel"),
]
api_urlpatterns = [
("endpoints/agents/connectors", AgentConnectorViewSet),

View File

@@ -0,0 +1,40 @@
from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.template.response import TemplateResponse
from django.views import View
from rest_framework.exceptions import ValidationError
from authentik.endpoints.connectors.agent.controller import AgentController
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.connectors.agent.stage import PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE
from authentik.endpoints.models import EndpointStage
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
class BrowserBackchannel(View):
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: EndpointStage = self.get_flow_plan().bindings[0].stage
connector = AgentConnector.objects.filter(pk=stage.connector_id).first()
if not connector:
return HttpResponseBadRequest()
self.controller: AgentController = connector.controller(connector)
def get(self, request: HttpRequest) -> HttpResponse:
response = request.GET.get("xak-agent-response")
flow_plan = self.get_flow_plan()
try:
dev = self.controller.validate_device_challenge(
response, flow_plan.context.get(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE)
)
flow_plan.context[PLAN_CONTEXT_DEVICE] = dev
request.session[SESSION_KEY_PLAN] = flow_plan
except ValidationError:
return HttpResponseBadRequest()
return TemplateResponse(request, "flows/frame-submit.html")

View File

@@ -38246,9 +38246,12 @@ components:
type: string
challenge_idle_timeout:
type: integer
frame_url:
type: string
required:
- challenge
- challenge_idle_timeout
- frame_url
EndpointAgentChallengeResponseRequest:
type: object
description: Response to signed challenge

View File

@@ -7,7 +7,7 @@ import { BaseStage } from "#flow/stages/base";
import { EndpointAgentChallenge, EndpointAgentChallengeResponseRequest } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -88,6 +88,12 @@ export class EndpointAgentStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
${this.challenge
? html`<iframe
style="width:0;height:0;position:absolute;"
src=${this.challenge?.frameUrl}
></iframe>`
: nothing}
${this.challenge?.responseErrors
? html`
<ak-empty-state icon="fa-times"