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'
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
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
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")
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
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