authentik.endpoints.connectors.agent.stage

  1from datetime import timedelta
  2from hashlib import sha256
  3from hmac import compare_digest
  4
  5from django.http import HttpResponse
  6from django.utils.timezone import now
  7from jwt import PyJWTError, decode, encode
  8from rest_framework.exceptions import ValidationError
  9from rest_framework.fields import CharField, IntegerField
 10
 11from authentik.crypto.models import CertificateKeyPair
 12from authentik.endpoints.connectors.agent.models import DeviceAuthenticationToken, DeviceToken
 13from authentik.endpoints.models import Device, EndpointStage, StageMode
 14from authentik.flows.challenge import (
 15    Challenge,
 16    ChallengeResponse,
 17)
 18from authentik.flows.planner import PLAN_CONTEXT_DEVICE
 19from authentik.flows.stage import ChallengeStageView
 20from authentik.lib.generators import generate_id
 21from authentik.lib.utils.time import timedelta_from_string
 22from authentik.providers.oauth2.models import JWTAlgorithms
 23
 24PLAN_CONTEXT_DEVICE_AUTH_TOKEN = "goauthentik.io/endpoints/device_auth_token"  # nosec
 25PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
 26QS_CHALLENGE = "challenge"
 27QS_CHALLENGE_RESPONSE = "response"
 28
 29
 30class EndpointAgentChallenge(Challenge):
 31    """Signed challenge for authentik agent to respond to"""
 32
 33    component = CharField(default="ak-stage-endpoint-agent")
 34    challenge = CharField()
 35    challenge_idle_timeout = IntegerField()
 36
 37
 38class EndpointAgentChallengeResponse(ChallengeResponse):
 39    """Response to signed challenge"""
 40
 41    component = CharField(default="ak-stage-endpoint-agent")
 42    response = CharField(required=False, allow_null=True)
 43
 44    def validate_response(self, response: str | None) -> Device | None:
 45        if not response:
 46            return None
 47        try:
 48            raw = decode(
 49                response,
 50                options={"verify_signature": False},
 51                audience="goauthentik.io/platform/endpoint",
 52            )
 53        except PyJWTError as exc:
 54            self.stage.logger.warning("Could not parse response", exc=exc)
 55            raise ValidationError("Invalid challenge response") from None
 56        device = Device.objects.filter(identifier=raw["iss"]).first()
 57        if not device:
 58            self.stage.logger.warning("Could not find device for challenge")
 59            raise ValidationError("Invalid challenge response")
 60        for token in DeviceToken.objects.filter(
 61            device__device=device,
 62            device__connector=self.stage.executor.current_stage.connector,
 63        ).values_list("key", flat=True):
 64            try:
 65                decoded = decode(
 66                    response,
 67                    key=token,
 68                    algorithms="HS512",
 69                    issuer=device.identifier,
 70                    audience="goauthentik.io/platform/endpoint",
 71                )
 72                if not compare_digest(
 73                    decoded["atc"],
 74                    self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
 75                ):
 76                    self.stage.logger.warning("mismatched challenge")
 77                    raise ValidationError("Invalid challenge response")
 78                return device
 79            except PyJWTError as exc:
 80                self.stage.logger.warning("failed to validate device challenge response", exc=exc)
 81        raise ValidationError("Invalid challenge response")
 82
 83
 84class AuthenticatorEndpointStageView(ChallengeStageView):
 85    """Endpoint stage"""
 86
 87    response_class = EndpointAgentChallengeResponse
 88
 89    def get(self, request, *args, **kwargs):
 90        # Check if we're in a device interactive auth flow, in which case we use that
 91        # to prove which device is being used
 92        if response := self.check_device_ia():
 93            return response
 94        stage: EndpointStage = self.executor.current_stage
 95        keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
 96        if not keypair:
 97            return self.executor.stage_ok()
 98        return super().get(request, *args, **kwargs)
 99
100    def check_device_ia(self):
101        """Check if we're in a device interactive authentication flow, and if so,
102        there won't be a browser extension to talk to. However we can authenticate
103        on the DTH header"""
104        if PLAN_CONTEXT_DEVICE_AUTH_TOKEN not in self.executor.plan.context:
105            return None
106        auth_token: DeviceAuthenticationToken = self.executor.plan.context.get(
107            PLAN_CONTEXT_DEVICE_AUTH_TOKEN
108        )
109        device_token_hash = self.request.headers.get("X-Authentik-Platform-Auth-DTH")
110        if not device_token_hash:
111            return None
112        if not compare_digest(
113            device_token_hash, sha256(auth_token.device_token.key.encode()).hexdigest()
114        ):
115            return self.executor.stage_invalid("Invalid device token")
116        self.logger.debug("Setting device based on DTH header")
117        self.executor.plan.context[PLAN_CONTEXT_DEVICE] = auth_token.device
118        return self.executor.stage_ok()
119
120    def get_challenge(self, *args, **kwargs) -> Challenge:
121        stage: EndpointStage = self.executor.current_stage
122        keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
123        challenge_str = generate_id()
124        iat = now()
125        challenge = encode(
126            {
127                "atc": challenge_str,
128                "iss": str(stage.pk),
129                "iat": int(iat.timestamp()),
130                "exp": int((iat + timedelta(minutes=5)).timestamp()),
131                "goauthentik.io/device/check_in": stage.connector.challenge_trigger_check_in,
132            },
133            headers={"kid": keypair.kid},
134            key=keypair.private_key,
135            algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
136        )
137        self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
138        return EndpointAgentChallenge(
139            data={
140                "component": "ak-stage-endpoint-agent",
141                "challenge": challenge,
142                "challenge_idle_timeout": int(
143                    timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
144                ),
145            }
146        )
147
148    def challenge_invalid(self, response: EndpointAgentChallengeResponse) -> HttpResponse:
149        if self.executor.current_stage.mode == StageMode.OPTIONAL:
150            return self.executor.stage_ok()
151        return super().challenge_invalid(response)
152
153    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
154        if device := response.validated_data.get("response"):
155            self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
156        elif self.executor.current_stage.mode == StageMode.REQUIRED:
157            return self.executor.stage_invalid("Invalid challenge response")
158        return self.executor.stage_ok()
159
160    def cleanup(self):
161        self.executor.plan.context.pop(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, None)
PLAN_CONTEXT_DEVICE_AUTH_TOKEN = 'goauthentik.io/endpoints/device_auth_token'
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = 'goauthentik.io/endpoints/connectors/agent/challenge'
QS_CHALLENGE = 'challenge'
QS_CHALLENGE_RESPONSE = 'response'
class EndpointAgentChallenge(authentik.flows.challenge.Challenge):
31class EndpointAgentChallenge(Challenge):
32    """Signed challenge for authentik agent to respond to"""
33
34    component = CharField(default="ak-stage-endpoint-agent")
35    challenge = CharField()
36    challenge_idle_timeout = IntegerField()

Signed challenge for authentik agent to respond to

component
challenge
challenge_idle_timeout
class EndpointAgentChallengeResponse(authentik.flows.challenge.ChallengeResponse):
39class EndpointAgentChallengeResponse(ChallengeResponse):
40    """Response to signed challenge"""
41
42    component = CharField(default="ak-stage-endpoint-agent")
43    response = CharField(required=False, allow_null=True)
44
45    def validate_response(self, response: str | None) -> Device | None:
46        if not response:
47            return None
48        try:
49            raw = decode(
50                response,
51                options={"verify_signature": False},
52                audience="goauthentik.io/platform/endpoint",
53            )
54        except PyJWTError as exc:
55            self.stage.logger.warning("Could not parse response", exc=exc)
56            raise ValidationError("Invalid challenge response") from None
57        device = Device.objects.filter(identifier=raw["iss"]).first()
58        if not device:
59            self.stage.logger.warning("Could not find device for challenge")
60            raise ValidationError("Invalid challenge response")
61        for token in DeviceToken.objects.filter(
62            device__device=device,
63            device__connector=self.stage.executor.current_stage.connector,
64        ).values_list("key", flat=True):
65            try:
66                decoded = decode(
67                    response,
68                    key=token,
69                    algorithms="HS512",
70                    issuer=device.identifier,
71                    audience="goauthentik.io/platform/endpoint",
72                )
73                if not compare_digest(
74                    decoded["atc"],
75                    self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
76                ):
77                    self.stage.logger.warning("mismatched challenge")
78                    raise ValidationError("Invalid challenge response")
79                return device
80            except PyJWTError as exc:
81                self.stage.logger.warning("failed to validate device challenge response", exc=exc)
82        raise ValidationError("Invalid challenge response")

Response to signed challenge

component
response
def validate_response(self, response: str | None) -> authentik.endpoints.models.Device | None:
45    def validate_response(self, response: str | None) -> Device | None:
46        if not response:
47            return None
48        try:
49            raw = decode(
50                response,
51                options={"verify_signature": False},
52                audience="goauthentik.io/platform/endpoint",
53            )
54        except PyJWTError as exc:
55            self.stage.logger.warning("Could not parse response", exc=exc)
56            raise ValidationError("Invalid challenge response") from None
57        device = Device.objects.filter(identifier=raw["iss"]).first()
58        if not device:
59            self.stage.logger.warning("Could not find device for challenge")
60            raise ValidationError("Invalid challenge response")
61        for token in DeviceToken.objects.filter(
62            device__device=device,
63            device__connector=self.stage.executor.current_stage.connector,
64        ).values_list("key", flat=True):
65            try:
66                decoded = decode(
67                    response,
68                    key=token,
69                    algorithms="HS512",
70                    issuer=device.identifier,
71                    audience="goauthentik.io/platform/endpoint",
72                )
73                if not compare_digest(
74                    decoded["atc"],
75                    self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
76                ):
77                    self.stage.logger.warning("mismatched challenge")
78                    raise ValidationError("Invalid challenge response")
79                return device
80            except PyJWTError as exc:
81                self.stage.logger.warning("failed to validate device challenge response", exc=exc)
82        raise ValidationError("Invalid challenge response")
class AuthenticatorEndpointStageView(authentik.flows.stage.ChallengeStageView):
 85class AuthenticatorEndpointStageView(ChallengeStageView):
 86    """Endpoint stage"""
 87
 88    response_class = EndpointAgentChallengeResponse
 89
 90    def get(self, request, *args, **kwargs):
 91        # Check if we're in a device interactive auth flow, in which case we use that
 92        # to prove which device is being used
 93        if response := self.check_device_ia():
 94            return response
 95        stage: EndpointStage = self.executor.current_stage
 96        keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
 97        if not keypair:
 98            return self.executor.stage_ok()
 99        return super().get(request, *args, **kwargs)
100
101    def check_device_ia(self):
102        """Check if we're in a device interactive authentication flow, and if so,
103        there won't be a browser extension to talk to. However we can authenticate
104        on the DTH header"""
105        if PLAN_CONTEXT_DEVICE_AUTH_TOKEN not in self.executor.plan.context:
106            return None
107        auth_token: DeviceAuthenticationToken = self.executor.plan.context.get(
108            PLAN_CONTEXT_DEVICE_AUTH_TOKEN
109        )
110        device_token_hash = self.request.headers.get("X-Authentik-Platform-Auth-DTH")
111        if not device_token_hash:
112            return None
113        if not compare_digest(
114            device_token_hash, sha256(auth_token.device_token.key.encode()).hexdigest()
115        ):
116            return self.executor.stage_invalid("Invalid device token")
117        self.logger.debug("Setting device based on DTH header")
118        self.executor.plan.context[PLAN_CONTEXT_DEVICE] = auth_token.device
119        return self.executor.stage_ok()
120
121    def get_challenge(self, *args, **kwargs) -> Challenge:
122        stage: EndpointStage = self.executor.current_stage
123        keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
124        challenge_str = generate_id()
125        iat = now()
126        challenge = encode(
127            {
128                "atc": challenge_str,
129                "iss": str(stage.pk),
130                "iat": int(iat.timestamp()),
131                "exp": int((iat + timedelta(minutes=5)).timestamp()),
132                "goauthentik.io/device/check_in": stage.connector.challenge_trigger_check_in,
133            },
134            headers={"kid": keypair.kid},
135            key=keypair.private_key,
136            algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
137        )
138        self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
139        return EndpointAgentChallenge(
140            data={
141                "component": "ak-stage-endpoint-agent",
142                "challenge": challenge,
143                "challenge_idle_timeout": int(
144                    timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
145                ),
146            }
147        )
148
149    def challenge_invalid(self, response: EndpointAgentChallengeResponse) -> HttpResponse:
150        if self.executor.current_stage.mode == StageMode.OPTIONAL:
151            return self.executor.stage_ok()
152        return super().challenge_invalid(response)
153
154    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
155        if device := response.validated_data.get("response"):
156            self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
157        elif self.executor.current_stage.mode == StageMode.REQUIRED:
158            return self.executor.stage_invalid("Invalid challenge response")
159        return self.executor.stage_ok()
160
161    def cleanup(self):
162        self.executor.plan.context.pop(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, None)

Endpoint stage

response_class = <class 'EndpointAgentChallengeResponse'>
def get(self, request, *args, **kwargs):
90    def get(self, request, *args, **kwargs):
91        # Check if we're in a device interactive auth flow, in which case we use that
92        # to prove which device is being used
93        if response := self.check_device_ia():
94            return response
95        stage: EndpointStage = self.executor.current_stage
96        keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
97        if not keypair:
98            return self.executor.stage_ok()
99        return super().get(request, *args, **kwargs)

Return a challenge for the frontend to solve

def check_device_ia(self):
101    def check_device_ia(self):
102        """Check if we're in a device interactive authentication flow, and if so,
103        there won't be a browser extension to talk to. However we can authenticate
104        on the DTH header"""
105        if PLAN_CONTEXT_DEVICE_AUTH_TOKEN not in self.executor.plan.context:
106            return None
107        auth_token: DeviceAuthenticationToken = self.executor.plan.context.get(
108            PLAN_CONTEXT_DEVICE_AUTH_TOKEN
109        )
110        device_token_hash = self.request.headers.get("X-Authentik-Platform-Auth-DTH")
111        if not device_token_hash:
112            return None
113        if not compare_digest(
114            device_token_hash, sha256(auth_token.device_token.key.encode()).hexdigest()
115        ):
116            return self.executor.stage_invalid("Invalid device token")
117        self.logger.debug("Setting device based on DTH header")
118        self.executor.plan.context[PLAN_CONTEXT_DEVICE] = auth_token.device
119        return self.executor.stage_ok()

Check if we're in a device interactive authentication flow, and if so, there won't be a browser extension to talk to. However we can authenticate on the DTH header

def get_challenge(self, *args, **kwargs) -> authentik.flows.challenge.Challenge:
121    def get_challenge(self, *args, **kwargs) -> Challenge:
122        stage: EndpointStage = self.executor.current_stage
123        keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
124        challenge_str = generate_id()
125        iat = now()
126        challenge = encode(
127            {
128                "atc": challenge_str,
129                "iss": str(stage.pk),
130                "iat": int(iat.timestamp()),
131                "exp": int((iat + timedelta(minutes=5)).timestamp()),
132                "goauthentik.io/device/check_in": stage.connector.challenge_trigger_check_in,
133            },
134            headers={"kid": keypair.kid},
135            key=keypair.private_key,
136            algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
137        )
138        self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
139        return EndpointAgentChallenge(
140            data={
141                "component": "ak-stage-endpoint-agent",
142                "challenge": challenge,
143                "challenge_idle_timeout": int(
144                    timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
145                ),
146            }
147        )

Return the challenge that the client should solve

def challenge_invalid( self, response: EndpointAgentChallengeResponse) -> django.http.response.HttpResponse:
149    def challenge_invalid(self, response: EndpointAgentChallengeResponse) -> HttpResponse:
150        if self.executor.current_stage.mode == StageMode.OPTIONAL:
151            return self.executor.stage_ok()
152        return super().challenge_invalid(response)

Callback when the challenge has the incorrect format

def challenge_valid( self, response: authentik.flows.challenge.ChallengeResponse) -> django.http.response.HttpResponse:
154    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
155        if device := response.validated_data.get("response"):
156            self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
157        elif self.executor.current_stage.mode == StageMode.REQUIRED:
158            return self.executor.stage_invalid("Invalid challenge response")
159        return self.executor.stage_ok()

Callback when the challenge has the correct format

def cleanup(self):
161    def cleanup(self):
162        self.executor.plan.context.pop(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE, None)

Cleanup session