authentik.stages.authenticator_webauthn.tests.test_stage
Test WebAuthn API
1"""Test WebAuthn API""" 2 3from base64 import b64decode 4from json import loads 5 6from django.urls import reverse 7from webauthn.helpers.bytes_to_base64url import bytes_to_base64url 8 9from authentik.core.tests.utils import create_test_admin_user, create_test_flow 10from authentik.flows.markers import StageMarker 11from authentik.flows.models import FlowStageBinding 12from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan 13from authentik.flows.tests import FlowTestCase 14from authentik.flows.views.executor import SESSION_KEY_PLAN 15from authentik.lib.generators import generate_id 16from authentik.lib.tests.utils import load_fixture 17from authentik.stages.authenticator_webauthn.models import ( 18 UNKNOWN_DEVICE_TYPE_AAGUID, 19 AuthenticatorWebAuthnStage, 20 WebAuthnDevice, 21 WebAuthnDeviceType, 22 WebAuthnHint, 23) 24from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE 25from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import 26 27 28class TestAuthenticatorWebAuthnStage(FlowTestCase): 29 """Test WebAuthn API""" 30 31 def setUp(self) -> None: 32 self.stage = AuthenticatorWebAuthnStage.objects.create( 33 name=generate_id(), 34 ) 35 self.flow = create_test_flow() 36 self.binding = FlowStageBinding.objects.create( 37 target=self.flow, 38 stage=self.stage, 39 order=0, 40 ) 41 self.user = create_test_admin_user() 42 43 def test_api_delete(self): 44 """Test api delete""" 45 self.client.force_login(self.user) 46 dev = WebAuthnDevice.objects.create(user=self.user) 47 response = self.client.delete( 48 reverse("authentik_api:webauthndevice-detail", kwargs={"pk": dev.pk}) 49 ) 50 self.assertEqual(response.status_code, 204) 51 52 def test_registration_options(self): 53 """Test registration options""" 54 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 55 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 56 session = self.client.session 57 session[SESSION_KEY_PLAN] = plan 58 session.save() 59 60 response = self.client.get( 61 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 62 ) 63 64 plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] 65 66 self.assertEqual(response.status_code, 200) 67 session = self.client.session 68 self.assertStageResponse( 69 response, 70 self.flow, 71 self.user, 72 registration={ 73 "rp": {"name": "authentik", "id": "testserver"}, 74 "user": { 75 "id": bytes_to_base64url(self.user.uid.encode("utf-8")), 76 "name": self.user.username, 77 "displayName": self.user.name, 78 }, 79 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), 80 "pubKeyCredParams": [ 81 {"type": "public-key", "alg": -7}, 82 {"type": "public-key", "alg": -8}, 83 {"type": "public-key", "alg": -36}, 84 {"type": "public-key", "alg": -37}, 85 {"type": "public-key", "alg": -38}, 86 {"type": "public-key", "alg": -39}, 87 {"type": "public-key", "alg": -257}, 88 {"type": "public-key", "alg": -258}, 89 {"type": "public-key", "alg": -259}, 90 ], 91 "timeout": 60000, 92 "excludeCredentials": [], 93 "authenticatorSelection": { 94 "residentKey": "preferred", 95 "requireResidentKey": False, 96 "userVerification": "preferred", 97 }, 98 "attestation": "direct", 99 }, 100 ) 101 102 def test_register(self): 103 """Test registration""" 104 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 105 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 106 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 107 b"iHIX3AtkZZCxSYLxOhk80ZXI7RnAC0Pb4WTk9dEJ4eLJdzoh8jRmjKW2U9oE/CBn5n6Zj67BIIZvFL3lpiwJwg==" 108 ) 109 session = self.client.session 110 session[SESSION_KEY_PLAN] = plan 111 session.save() 112 response = self.client.post( 113 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 114 data={ 115 "component": "ak-stage-authenticator-webauthn", 116 "response": loads(load_fixture("fixtures/register.json")), 117 }, 118 SERVER_NAME="localhost", 119 SERVER_PORT="9000", 120 ) 121 self.assertEqual(response.status_code, 200) 122 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 123 device = WebAuthnDevice.objects.filter(user=self.user).first() 124 self.assertIsNotNone(device) 125 self.assertEqual( 126 device.credential_id, "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ" 127 ) 128 self.assertEqual( 129 device.attestation_certificate_fingerprint, 130 "3e:28:fc:df:45:19:bb:94:0a:0c:90:98:f2:08:72:53:2a:9e:e2:76:13:02:3e:69:61:4a:d9:90:49:80:3d:34", 131 ) 132 133 def test_register_restricted_device_type_deny(self): 134 """Test registration with restricted devices (fail)""" 135 webauthn_mds_import.send(force=True) 136 self.stage.device_type_restrictions.set( 137 WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") 138 ) 139 140 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 141 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 142 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 143 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 144 ) 145 session = self.client.session 146 session[SESSION_KEY_PLAN] = plan 147 session.save() 148 response = self.client.post( 149 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 150 data={ 151 "component": "ak-stage-authenticator-webauthn", 152 "response": { 153 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 154 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 155 "type": "public-key", 156 "registrationClientExtensions": "{}", 157 "response": { 158 "clientDataJSON": ( 159 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 160 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 161 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 162 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 163 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 164 ), 165 "attestationObject": ( 166 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 167 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 168 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 169 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 170 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 171 ), 172 }, 173 }, 174 }, 175 SERVER_NAME="localhost", 176 SERVER_PORT="9000", 177 ) 178 self.assertEqual(response.status_code, 200) 179 self.assertStageResponse( 180 response, 181 flow=self.flow, 182 component="ak-stage-authenticator-webauthn", 183 response_errors={ 184 "response": [ 185 { 186 "string": ( 187 "Invalid device type. Contact your authentik administrator for help." 188 ), 189 "code": "invalid", 190 } 191 ] 192 }, 193 ) 194 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) 195 196 def test_register_restricted_device_type_allow(self): 197 """Test registration with restricted devices (allow)""" 198 webauthn_mds_import.send(force=True) 199 self.stage.device_type_restrictions.set( 200 WebAuthnDeviceType.objects.filter(description="iCloud Keychain") 201 ) 202 203 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 204 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 205 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 206 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 207 ) 208 session = self.client.session 209 session[SESSION_KEY_PLAN] = plan 210 session.save() 211 response = self.client.post( 212 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 213 data={ 214 "component": "ak-stage-authenticator-webauthn", 215 "response": { 216 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 217 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 218 "type": "public-key", 219 "registrationClientExtensions": "{}", 220 "response": { 221 "clientDataJSON": ( 222 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 223 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 224 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 225 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 226 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 227 ), 228 "attestationObject": ( 229 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 230 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 231 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 232 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 233 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 234 ), 235 }, 236 }, 237 }, 238 SERVER_NAME="localhost", 239 SERVER_PORT="9000", 240 ) 241 self.assertEqual(response.status_code, 200) 242 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 243 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) 244 245 def test_register_restricted_device_type_allow_unknown(self): 246 """Test registration with restricted devices (allow, unknown device type)""" 247 webauthn_mds_import.send(force=True) 248 WebAuthnDeviceType.objects.filter(aaguid="fbfc3007-154e-4ecc-8c0b-6e020557d7bd").delete() 249 self.stage.device_type_restrictions.set( 250 WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID) 251 ) 252 253 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 254 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 255 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 256 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 257 ) 258 session = self.client.session 259 session[SESSION_KEY_PLAN] = plan 260 session.save() 261 response = self.client.post( 262 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 263 data={ 264 "component": "ak-stage-authenticator-webauthn", 265 "response": { 266 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 267 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 268 "type": "public-key", 269 "registrationClientExtensions": "{}", 270 "response": { 271 "clientDataJSON": ( 272 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 273 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 274 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 275 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 276 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 277 ), 278 "attestationObject": ( 279 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 280 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 281 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 282 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 283 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 284 ), 285 }, 286 }, 287 }, 288 SERVER_NAME="localhost", 289 SERVER_PORT="9000", 290 ) 291 self.assertEqual(response.status_code, 200) 292 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 293 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) 294 295 def test_registration_options_with_hints(self): 296 """Test that hints are included in registration options""" 297 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE, WebAuthnHint.SECURITY_KEY] 298 self.stage.save() 299 300 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 301 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 302 session = self.client.session 303 session[SESSION_KEY_PLAN] = plan 304 session.save() 305 306 response = self.client.get( 307 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 308 ) 309 self.assertEqual(response.status_code, 200) 310 registration = response.json()["registration"] 311 self.assertEqual(registration["hints"], ["client-device", "security-key"]) 312 313 def test_registration_options_hints_empty(self): 314 """Test that no hints key is present when hints are empty""" 315 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 316 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 317 session = self.client.session 318 session[SESSION_KEY_PLAN] = plan 319 session.save() 320 321 response = self.client.get( 322 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 323 ) 324 self.assertEqual(response.status_code, 200) 325 registration = response.json()["registration"] 326 self.assertNotIn("hints", registration) 327 328 def test_registration_options_hints_infer_attachment_cross_platform(self): 329 """Test that authenticatorAttachment is auto-inferred as cross-platform 330 from security-key/hybrid hints for backwards compatibility""" 331 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 332 self.stage.authenticator_attachment = None 333 self.stage.save() 334 335 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 336 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 337 session = self.client.session 338 session[SESSION_KEY_PLAN] = plan 339 session.save() 340 341 response = self.client.get( 342 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 343 ) 344 self.assertEqual(response.status_code, 200) 345 registration = response.json()["registration"] 346 self.assertEqual( 347 registration["authenticatorSelection"]["authenticatorAttachment"], "cross-platform" 348 ) 349 350 def test_registration_options_hints_infer_attachment_platform(self): 351 """Test that authenticatorAttachment is auto-inferred as platform 352 from client-device hint for backwards compatibility""" 353 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE] 354 self.stage.authenticator_attachment = None 355 self.stage.save() 356 357 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 358 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 359 session = self.client.session 360 session[SESSION_KEY_PLAN] = plan 361 session.save() 362 363 response = self.client.get( 364 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 365 ) 366 self.assertEqual(response.status_code, 200) 367 registration = response.json()["registration"] 368 self.assertEqual( 369 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 370 ) 371 372 def test_registration_options_hints_no_infer_when_attachment_set(self): 373 """Test that authenticatorAttachment is NOT overridden when explicitly set""" 374 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 375 self.stage.authenticator_attachment = "platform" 376 self.stage.save() 377 378 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 379 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 380 session = self.client.session 381 session[SESSION_KEY_PLAN] = plan 382 session.save() 383 384 response = self.client.get( 385 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 386 ) 387 self.assertEqual(response.status_code, 200) 388 registration = response.json()["registration"] 389 self.assertEqual( 390 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 391 ) 392 393 def test_registration_options_hints_no_infer_mixed(self): 394 """Test that authenticatorAttachment is NOT inferred when hints are mixed""" 395 self.stage.hints = [WebAuthnHint.SECURITY_KEY, WebAuthnHint.CLIENT_DEVICE] 396 self.stage.authenticator_attachment = None 397 self.stage.save() 398 399 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 400 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 401 session = self.client.session 402 session[SESSION_KEY_PLAN] = plan 403 session.save() 404 405 response = self.client.get( 406 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 407 ) 408 self.assertEqual(response.status_code, 200) 409 registration = response.json()["registration"] 410 self.assertNotIn("authenticatorAttachment", registration["authenticatorSelection"]) 411 412 def test_registration_options_hints_order_preserved(self): 413 """Test that hint order is preserved (first hint = highest priority)""" 414 self.stage.hints = [ 415 WebAuthnHint.HYBRID, 416 WebAuthnHint.CLIENT_DEVICE, 417 WebAuthnHint.SECURITY_KEY, 418 ] 419 self.stage.save() 420 421 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 422 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 423 session = self.client.session 424 session[SESSION_KEY_PLAN] = plan 425 session.save() 426 427 response = self.client.get( 428 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 429 ) 430 self.assertEqual(response.status_code, 200) 431 registration = response.json()["registration"] 432 self.assertEqual(registration["hints"], ["hybrid", "client-device", "security-key"]) 433 434 def test_register_max_retries(self): 435 """Test registration (exceeding max retries)""" 436 self.stage.max_attempts = 2 437 self.stage.save() 438 439 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 440 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 441 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 442 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 443 ) 444 session = self.client.session 445 session[SESSION_KEY_PLAN] = plan 446 session.save() 447 448 # first failed request 449 response = self.client.post( 450 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 451 data={ 452 "component": "ak-stage-authenticator-webauthn", 453 "response": { 454 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 455 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 456 "type": "public-key", 457 "registrationClientExtensions": "{}", 458 "response": { 459 "clientDataJSON": ( 460 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 461 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 462 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 463 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 464 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 465 ), 466 "attestationObject": ( 467 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 468 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 469 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 470 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 471 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 472 ), 473 }, 474 }, 475 }, 476 SERVER_NAME="localhost", 477 SERVER_PORT="9000", 478 ) 479 self.assertEqual(response.status_code, 200) 480 self.assertStageResponse( 481 response, 482 flow=self.flow, 483 component="ak-stage-authenticator-webauthn", 484 response_errors={ 485 "response": [ 486 { 487 "string": ( 488 "Registration failed. Error: Unable to decode " 489 "client_data_json bytes as JSON" 490 ), 491 "code": "invalid", 492 } 493 ] 494 }, 495 ) 496 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) 497 498 # Second failed request 499 response = self.client.post( 500 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 501 data={ 502 "component": "ak-stage-authenticator-webauthn", 503 "response": { 504 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 505 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 506 "type": "public-key", 507 "registrationClientExtensions": "{}", 508 "response": { 509 "clientDataJSON": ( 510 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 511 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 512 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 513 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 514 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 515 ), 516 "attestationObject": ( 517 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 518 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 519 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 520 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 521 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 522 ), 523 }, 524 }, 525 }, 526 SERVER_NAME="localhost", 527 SERVER_PORT="9000", 528 ) 529 self.assertEqual(response.status_code, 200) 530 self.assertStageResponse( 531 response, 532 flow=self.flow, 533 component="ak-stage-access-denied", 534 error_message=( 535 "Exceeded maximum attempts. Contact your authentik administrator for help." 536 ), 537 ) 538 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
29class TestAuthenticatorWebAuthnStage(FlowTestCase): 30 """Test WebAuthn API""" 31 32 def setUp(self) -> None: 33 self.stage = AuthenticatorWebAuthnStage.objects.create( 34 name=generate_id(), 35 ) 36 self.flow = create_test_flow() 37 self.binding = FlowStageBinding.objects.create( 38 target=self.flow, 39 stage=self.stage, 40 order=0, 41 ) 42 self.user = create_test_admin_user() 43 44 def test_api_delete(self): 45 """Test api delete""" 46 self.client.force_login(self.user) 47 dev = WebAuthnDevice.objects.create(user=self.user) 48 response = self.client.delete( 49 reverse("authentik_api:webauthndevice-detail", kwargs={"pk": dev.pk}) 50 ) 51 self.assertEqual(response.status_code, 204) 52 53 def test_registration_options(self): 54 """Test registration options""" 55 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 56 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 57 session = self.client.session 58 session[SESSION_KEY_PLAN] = plan 59 session.save() 60 61 response = self.client.get( 62 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 63 ) 64 65 plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] 66 67 self.assertEqual(response.status_code, 200) 68 session = self.client.session 69 self.assertStageResponse( 70 response, 71 self.flow, 72 self.user, 73 registration={ 74 "rp": {"name": "authentik", "id": "testserver"}, 75 "user": { 76 "id": bytes_to_base64url(self.user.uid.encode("utf-8")), 77 "name": self.user.username, 78 "displayName": self.user.name, 79 }, 80 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), 81 "pubKeyCredParams": [ 82 {"type": "public-key", "alg": -7}, 83 {"type": "public-key", "alg": -8}, 84 {"type": "public-key", "alg": -36}, 85 {"type": "public-key", "alg": -37}, 86 {"type": "public-key", "alg": -38}, 87 {"type": "public-key", "alg": -39}, 88 {"type": "public-key", "alg": -257}, 89 {"type": "public-key", "alg": -258}, 90 {"type": "public-key", "alg": -259}, 91 ], 92 "timeout": 60000, 93 "excludeCredentials": [], 94 "authenticatorSelection": { 95 "residentKey": "preferred", 96 "requireResidentKey": False, 97 "userVerification": "preferred", 98 }, 99 "attestation": "direct", 100 }, 101 ) 102 103 def test_register(self): 104 """Test registration""" 105 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 106 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 107 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 108 b"iHIX3AtkZZCxSYLxOhk80ZXI7RnAC0Pb4WTk9dEJ4eLJdzoh8jRmjKW2U9oE/CBn5n6Zj67BIIZvFL3lpiwJwg==" 109 ) 110 session = self.client.session 111 session[SESSION_KEY_PLAN] = plan 112 session.save() 113 response = self.client.post( 114 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 115 data={ 116 "component": "ak-stage-authenticator-webauthn", 117 "response": loads(load_fixture("fixtures/register.json")), 118 }, 119 SERVER_NAME="localhost", 120 SERVER_PORT="9000", 121 ) 122 self.assertEqual(response.status_code, 200) 123 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 124 device = WebAuthnDevice.objects.filter(user=self.user).first() 125 self.assertIsNotNone(device) 126 self.assertEqual( 127 device.credential_id, "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ" 128 ) 129 self.assertEqual( 130 device.attestation_certificate_fingerprint, 131 "3e:28:fc:df:45:19:bb:94:0a:0c:90:98:f2:08:72:53:2a:9e:e2:76:13:02:3e:69:61:4a:d9:90:49:80:3d:34", 132 ) 133 134 def test_register_restricted_device_type_deny(self): 135 """Test registration with restricted devices (fail)""" 136 webauthn_mds_import.send(force=True) 137 self.stage.device_type_restrictions.set( 138 WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") 139 ) 140 141 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 142 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 143 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 144 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 145 ) 146 session = self.client.session 147 session[SESSION_KEY_PLAN] = plan 148 session.save() 149 response = self.client.post( 150 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 151 data={ 152 "component": "ak-stage-authenticator-webauthn", 153 "response": { 154 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 155 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 156 "type": "public-key", 157 "registrationClientExtensions": "{}", 158 "response": { 159 "clientDataJSON": ( 160 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 161 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 162 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 163 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 164 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 165 ), 166 "attestationObject": ( 167 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 168 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 169 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 170 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 171 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 172 ), 173 }, 174 }, 175 }, 176 SERVER_NAME="localhost", 177 SERVER_PORT="9000", 178 ) 179 self.assertEqual(response.status_code, 200) 180 self.assertStageResponse( 181 response, 182 flow=self.flow, 183 component="ak-stage-authenticator-webauthn", 184 response_errors={ 185 "response": [ 186 { 187 "string": ( 188 "Invalid device type. Contact your authentik administrator for help." 189 ), 190 "code": "invalid", 191 } 192 ] 193 }, 194 ) 195 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) 196 197 def test_register_restricted_device_type_allow(self): 198 """Test registration with restricted devices (allow)""" 199 webauthn_mds_import.send(force=True) 200 self.stage.device_type_restrictions.set( 201 WebAuthnDeviceType.objects.filter(description="iCloud Keychain") 202 ) 203 204 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 205 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 206 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 207 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 208 ) 209 session = self.client.session 210 session[SESSION_KEY_PLAN] = plan 211 session.save() 212 response = self.client.post( 213 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 214 data={ 215 "component": "ak-stage-authenticator-webauthn", 216 "response": { 217 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 218 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 219 "type": "public-key", 220 "registrationClientExtensions": "{}", 221 "response": { 222 "clientDataJSON": ( 223 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 224 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 225 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 226 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 227 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 228 ), 229 "attestationObject": ( 230 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 231 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 232 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 233 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 234 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 235 ), 236 }, 237 }, 238 }, 239 SERVER_NAME="localhost", 240 SERVER_PORT="9000", 241 ) 242 self.assertEqual(response.status_code, 200) 243 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 244 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) 245 246 def test_register_restricted_device_type_allow_unknown(self): 247 """Test registration with restricted devices (allow, unknown device type)""" 248 webauthn_mds_import.send(force=True) 249 WebAuthnDeviceType.objects.filter(aaguid="fbfc3007-154e-4ecc-8c0b-6e020557d7bd").delete() 250 self.stage.device_type_restrictions.set( 251 WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID) 252 ) 253 254 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 255 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 256 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 257 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 258 ) 259 session = self.client.session 260 session[SESSION_KEY_PLAN] = plan 261 session.save() 262 response = self.client.post( 263 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 264 data={ 265 "component": "ak-stage-authenticator-webauthn", 266 "response": { 267 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 268 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 269 "type": "public-key", 270 "registrationClientExtensions": "{}", 271 "response": { 272 "clientDataJSON": ( 273 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 274 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 275 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 276 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 277 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 278 ), 279 "attestationObject": ( 280 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 281 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 282 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 283 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 284 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 285 ), 286 }, 287 }, 288 }, 289 SERVER_NAME="localhost", 290 SERVER_PORT="9000", 291 ) 292 self.assertEqual(response.status_code, 200) 293 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 294 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) 295 296 def test_registration_options_with_hints(self): 297 """Test that hints are included in registration options""" 298 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE, WebAuthnHint.SECURITY_KEY] 299 self.stage.save() 300 301 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 302 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 303 session = self.client.session 304 session[SESSION_KEY_PLAN] = plan 305 session.save() 306 307 response = self.client.get( 308 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 309 ) 310 self.assertEqual(response.status_code, 200) 311 registration = response.json()["registration"] 312 self.assertEqual(registration["hints"], ["client-device", "security-key"]) 313 314 def test_registration_options_hints_empty(self): 315 """Test that no hints key is present when hints are empty""" 316 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 317 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 318 session = self.client.session 319 session[SESSION_KEY_PLAN] = plan 320 session.save() 321 322 response = self.client.get( 323 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 324 ) 325 self.assertEqual(response.status_code, 200) 326 registration = response.json()["registration"] 327 self.assertNotIn("hints", registration) 328 329 def test_registration_options_hints_infer_attachment_cross_platform(self): 330 """Test that authenticatorAttachment is auto-inferred as cross-platform 331 from security-key/hybrid hints for backwards compatibility""" 332 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 333 self.stage.authenticator_attachment = None 334 self.stage.save() 335 336 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 337 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 338 session = self.client.session 339 session[SESSION_KEY_PLAN] = plan 340 session.save() 341 342 response = self.client.get( 343 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 344 ) 345 self.assertEqual(response.status_code, 200) 346 registration = response.json()["registration"] 347 self.assertEqual( 348 registration["authenticatorSelection"]["authenticatorAttachment"], "cross-platform" 349 ) 350 351 def test_registration_options_hints_infer_attachment_platform(self): 352 """Test that authenticatorAttachment is auto-inferred as platform 353 from client-device hint for backwards compatibility""" 354 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE] 355 self.stage.authenticator_attachment = None 356 self.stage.save() 357 358 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 359 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 360 session = self.client.session 361 session[SESSION_KEY_PLAN] = plan 362 session.save() 363 364 response = self.client.get( 365 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 366 ) 367 self.assertEqual(response.status_code, 200) 368 registration = response.json()["registration"] 369 self.assertEqual( 370 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 371 ) 372 373 def test_registration_options_hints_no_infer_when_attachment_set(self): 374 """Test that authenticatorAttachment is NOT overridden when explicitly set""" 375 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 376 self.stage.authenticator_attachment = "platform" 377 self.stage.save() 378 379 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 380 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 381 session = self.client.session 382 session[SESSION_KEY_PLAN] = plan 383 session.save() 384 385 response = self.client.get( 386 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 387 ) 388 self.assertEqual(response.status_code, 200) 389 registration = response.json()["registration"] 390 self.assertEqual( 391 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 392 ) 393 394 def test_registration_options_hints_no_infer_mixed(self): 395 """Test that authenticatorAttachment is NOT inferred when hints are mixed""" 396 self.stage.hints = [WebAuthnHint.SECURITY_KEY, WebAuthnHint.CLIENT_DEVICE] 397 self.stage.authenticator_attachment = None 398 self.stage.save() 399 400 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 401 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 402 session = self.client.session 403 session[SESSION_KEY_PLAN] = plan 404 session.save() 405 406 response = self.client.get( 407 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 408 ) 409 self.assertEqual(response.status_code, 200) 410 registration = response.json()["registration"] 411 self.assertNotIn("authenticatorAttachment", registration["authenticatorSelection"]) 412 413 def test_registration_options_hints_order_preserved(self): 414 """Test that hint order is preserved (first hint = highest priority)""" 415 self.stage.hints = [ 416 WebAuthnHint.HYBRID, 417 WebAuthnHint.CLIENT_DEVICE, 418 WebAuthnHint.SECURITY_KEY, 419 ] 420 self.stage.save() 421 422 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 423 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 424 session = self.client.session 425 session[SESSION_KEY_PLAN] = plan 426 session.save() 427 428 response = self.client.get( 429 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 430 ) 431 self.assertEqual(response.status_code, 200) 432 registration = response.json()["registration"] 433 self.assertEqual(registration["hints"], ["hybrid", "client-device", "security-key"]) 434 435 def test_register_max_retries(self): 436 """Test registration (exceeding max retries)""" 437 self.stage.max_attempts = 2 438 self.stage.save() 439 440 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 441 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 442 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 443 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 444 ) 445 session = self.client.session 446 session[SESSION_KEY_PLAN] = plan 447 session.save() 448 449 # first failed request 450 response = self.client.post( 451 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 452 data={ 453 "component": "ak-stage-authenticator-webauthn", 454 "response": { 455 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 456 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 457 "type": "public-key", 458 "registrationClientExtensions": "{}", 459 "response": { 460 "clientDataJSON": ( 461 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 462 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 463 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 464 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 465 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 466 ), 467 "attestationObject": ( 468 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 469 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 470 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 471 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 472 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 473 ), 474 }, 475 }, 476 }, 477 SERVER_NAME="localhost", 478 SERVER_PORT="9000", 479 ) 480 self.assertEqual(response.status_code, 200) 481 self.assertStageResponse( 482 response, 483 flow=self.flow, 484 component="ak-stage-authenticator-webauthn", 485 response_errors={ 486 "response": [ 487 { 488 "string": ( 489 "Registration failed. Error: Unable to decode " 490 "client_data_json bytes as JSON" 491 ), 492 "code": "invalid", 493 } 494 ] 495 }, 496 ) 497 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) 498 499 # Second failed request 500 response = self.client.post( 501 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 502 data={ 503 "component": "ak-stage-authenticator-webauthn", 504 "response": { 505 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 506 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 507 "type": "public-key", 508 "registrationClientExtensions": "{}", 509 "response": { 510 "clientDataJSON": ( 511 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 512 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 513 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 514 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 515 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 516 ), 517 "attestationObject": ( 518 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 519 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 520 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 521 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 522 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 523 ), 524 }, 525 }, 526 }, 527 SERVER_NAME="localhost", 528 SERVER_PORT="9000", 529 ) 530 self.assertEqual(response.status_code, 200) 531 self.assertStageResponse( 532 response, 533 flow=self.flow, 534 component="ak-stage-access-denied", 535 error_message=( 536 "Exceeded maximum attempts. Contact your authentik administrator for help." 537 ), 538 ) 539 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
Test WebAuthn API
32 def setUp(self) -> None: 33 self.stage = AuthenticatorWebAuthnStage.objects.create( 34 name=generate_id(), 35 ) 36 self.flow = create_test_flow() 37 self.binding = FlowStageBinding.objects.create( 38 target=self.flow, 39 stage=self.stage, 40 order=0, 41 ) 42 self.user = create_test_admin_user()
Hook method for setting up the test fixture before exercising it.
44 def test_api_delete(self): 45 """Test api delete""" 46 self.client.force_login(self.user) 47 dev = WebAuthnDevice.objects.create(user=self.user) 48 response = self.client.delete( 49 reverse("authentik_api:webauthndevice-detail", kwargs={"pk": dev.pk}) 50 ) 51 self.assertEqual(response.status_code, 204)
Test api delete
53 def test_registration_options(self): 54 """Test registration options""" 55 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 56 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 57 session = self.client.session 58 session[SESSION_KEY_PLAN] = plan 59 session.save() 60 61 response = self.client.get( 62 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 63 ) 64 65 plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] 66 67 self.assertEqual(response.status_code, 200) 68 session = self.client.session 69 self.assertStageResponse( 70 response, 71 self.flow, 72 self.user, 73 registration={ 74 "rp": {"name": "authentik", "id": "testserver"}, 75 "user": { 76 "id": bytes_to_base64url(self.user.uid.encode("utf-8")), 77 "name": self.user.username, 78 "displayName": self.user.name, 79 }, 80 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), 81 "pubKeyCredParams": [ 82 {"type": "public-key", "alg": -7}, 83 {"type": "public-key", "alg": -8}, 84 {"type": "public-key", "alg": -36}, 85 {"type": "public-key", "alg": -37}, 86 {"type": "public-key", "alg": -38}, 87 {"type": "public-key", "alg": -39}, 88 {"type": "public-key", "alg": -257}, 89 {"type": "public-key", "alg": -258}, 90 {"type": "public-key", "alg": -259}, 91 ], 92 "timeout": 60000, 93 "excludeCredentials": [], 94 "authenticatorSelection": { 95 "residentKey": "preferred", 96 "requireResidentKey": False, 97 "userVerification": "preferred", 98 }, 99 "attestation": "direct", 100 }, 101 )
Test registration options
103 def test_register(self): 104 """Test registration""" 105 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 106 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 107 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 108 b"iHIX3AtkZZCxSYLxOhk80ZXI7RnAC0Pb4WTk9dEJ4eLJdzoh8jRmjKW2U9oE/CBn5n6Zj67BIIZvFL3lpiwJwg==" 109 ) 110 session = self.client.session 111 session[SESSION_KEY_PLAN] = plan 112 session.save() 113 response = self.client.post( 114 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 115 data={ 116 "component": "ak-stage-authenticator-webauthn", 117 "response": loads(load_fixture("fixtures/register.json")), 118 }, 119 SERVER_NAME="localhost", 120 SERVER_PORT="9000", 121 ) 122 self.assertEqual(response.status_code, 200) 123 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 124 device = WebAuthnDevice.objects.filter(user=self.user).first() 125 self.assertIsNotNone(device) 126 self.assertEqual( 127 device.credential_id, "f7wv8mP-poSxh-567eWxZntzCBDW8hWlvzf92QJkT--Y2oBRz4IEAZ6M2PI9_KEQ" 128 ) 129 self.assertEqual( 130 device.attestation_certificate_fingerprint, 131 "3e:28:fc:df:45:19:bb:94:0a:0c:90:98:f2:08:72:53:2a:9e:e2:76:13:02:3e:69:61:4a:d9:90:49:80:3d:34", 132 )
Test registration
134 def test_register_restricted_device_type_deny(self): 135 """Test registration with restricted devices (fail)""" 136 webauthn_mds_import.send(force=True) 137 self.stage.device_type_restrictions.set( 138 WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") 139 ) 140 141 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 142 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 143 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 144 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 145 ) 146 session = self.client.session 147 session[SESSION_KEY_PLAN] = plan 148 session.save() 149 response = self.client.post( 150 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 151 data={ 152 "component": "ak-stage-authenticator-webauthn", 153 "response": { 154 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 155 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 156 "type": "public-key", 157 "registrationClientExtensions": "{}", 158 "response": { 159 "clientDataJSON": ( 160 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 161 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 162 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 163 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 164 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 165 ), 166 "attestationObject": ( 167 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 168 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 169 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 170 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 171 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 172 ), 173 }, 174 }, 175 }, 176 SERVER_NAME="localhost", 177 SERVER_PORT="9000", 178 ) 179 self.assertEqual(response.status_code, 200) 180 self.assertStageResponse( 181 response, 182 flow=self.flow, 183 component="ak-stage-authenticator-webauthn", 184 response_errors={ 185 "response": [ 186 { 187 "string": ( 188 "Invalid device type. Contact your authentik administrator for help." 189 ), 190 "code": "invalid", 191 } 192 ] 193 }, 194 ) 195 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
Test registration with restricted devices (fail)
197 def test_register_restricted_device_type_allow(self): 198 """Test registration with restricted devices (allow)""" 199 webauthn_mds_import.send(force=True) 200 self.stage.device_type_restrictions.set( 201 WebAuthnDeviceType.objects.filter(description="iCloud Keychain") 202 ) 203 204 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 205 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 206 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 207 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 208 ) 209 session = self.client.session 210 session[SESSION_KEY_PLAN] = plan 211 session.save() 212 response = self.client.post( 213 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 214 data={ 215 "component": "ak-stage-authenticator-webauthn", 216 "response": { 217 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 218 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 219 "type": "public-key", 220 "registrationClientExtensions": "{}", 221 "response": { 222 "clientDataJSON": ( 223 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 224 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 225 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 226 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 227 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 228 ), 229 "attestationObject": ( 230 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 231 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 232 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 233 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 234 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 235 ), 236 }, 237 }, 238 }, 239 SERVER_NAME="localhost", 240 SERVER_PORT="9000", 241 ) 242 self.assertEqual(response.status_code, 200) 243 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 244 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
Test registration with restricted devices (allow)
246 def test_register_restricted_device_type_allow_unknown(self): 247 """Test registration with restricted devices (allow, unknown device type)""" 248 webauthn_mds_import.send(force=True) 249 WebAuthnDeviceType.objects.filter(aaguid="fbfc3007-154e-4ecc-8c0b-6e020557d7bd").delete() 250 self.stage.device_type_restrictions.set( 251 WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID) 252 ) 253 254 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 255 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 256 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 257 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 258 ) 259 session = self.client.session 260 session[SESSION_KEY_PLAN] = plan 261 session.save() 262 response = self.client.post( 263 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 264 data={ 265 "component": "ak-stage-authenticator-webauthn", 266 "response": { 267 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 268 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 269 "type": "public-key", 270 "registrationClientExtensions": "{}", 271 "response": { 272 "clientDataJSON": ( 273 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 274 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 275 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 276 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 277 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9" 278 ), 279 "attestationObject": ( 280 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 281 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 282 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 283 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 284 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 285 ), 286 }, 287 }, 288 }, 289 SERVER_NAME="localhost", 290 SERVER_PORT="9000", 291 ) 292 self.assertEqual(response.status_code, 200) 293 self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) 294 self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
Test registration with restricted devices (allow, unknown device type)
296 def test_registration_options_with_hints(self): 297 """Test that hints are included in registration options""" 298 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE, WebAuthnHint.SECURITY_KEY] 299 self.stage.save() 300 301 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 302 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 303 session = self.client.session 304 session[SESSION_KEY_PLAN] = plan 305 session.save() 306 307 response = self.client.get( 308 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 309 ) 310 self.assertEqual(response.status_code, 200) 311 registration = response.json()["registration"] 312 self.assertEqual(registration["hints"], ["client-device", "security-key"])
Test that hints are included in registration options
314 def test_registration_options_hints_empty(self): 315 """Test that no hints key is present when hints are empty""" 316 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 317 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 318 session = self.client.session 319 session[SESSION_KEY_PLAN] = plan 320 session.save() 321 322 response = self.client.get( 323 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 324 ) 325 self.assertEqual(response.status_code, 200) 326 registration = response.json()["registration"] 327 self.assertNotIn("hints", registration)
Test that no hints key is present when hints are empty
329 def test_registration_options_hints_infer_attachment_cross_platform(self): 330 """Test that authenticatorAttachment is auto-inferred as cross-platform 331 from security-key/hybrid hints for backwards compatibility""" 332 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 333 self.stage.authenticator_attachment = None 334 self.stage.save() 335 336 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 337 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 338 session = self.client.session 339 session[SESSION_KEY_PLAN] = plan 340 session.save() 341 342 response = self.client.get( 343 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 344 ) 345 self.assertEqual(response.status_code, 200) 346 registration = response.json()["registration"] 347 self.assertEqual( 348 registration["authenticatorSelection"]["authenticatorAttachment"], "cross-platform" 349 )
Test that authenticatorAttachment is auto-inferred as cross-platform from security-key/hybrid hints for backwards compatibility
351 def test_registration_options_hints_infer_attachment_platform(self): 352 """Test that authenticatorAttachment is auto-inferred as platform 353 from client-device hint for backwards compatibility""" 354 self.stage.hints = [WebAuthnHint.CLIENT_DEVICE] 355 self.stage.authenticator_attachment = None 356 self.stage.save() 357 358 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 359 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 360 session = self.client.session 361 session[SESSION_KEY_PLAN] = plan 362 session.save() 363 364 response = self.client.get( 365 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 366 ) 367 self.assertEqual(response.status_code, 200) 368 registration = response.json()["registration"] 369 self.assertEqual( 370 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 371 )
Test that authenticatorAttachment is auto-inferred as platform from client-device hint for backwards compatibility
373 def test_registration_options_hints_no_infer_when_attachment_set(self): 374 """Test that authenticatorAttachment is NOT overridden when explicitly set""" 375 self.stage.hints = [WebAuthnHint.SECURITY_KEY] 376 self.stage.authenticator_attachment = "platform" 377 self.stage.save() 378 379 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 380 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 381 session = self.client.session 382 session[SESSION_KEY_PLAN] = plan 383 session.save() 384 385 response = self.client.get( 386 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 387 ) 388 self.assertEqual(response.status_code, 200) 389 registration = response.json()["registration"] 390 self.assertEqual( 391 registration["authenticatorSelection"]["authenticatorAttachment"], "platform" 392 )
Test that authenticatorAttachment is NOT overridden when explicitly set
394 def test_registration_options_hints_no_infer_mixed(self): 395 """Test that authenticatorAttachment is NOT inferred when hints are mixed""" 396 self.stage.hints = [WebAuthnHint.SECURITY_KEY, WebAuthnHint.CLIENT_DEVICE] 397 self.stage.authenticator_attachment = None 398 self.stage.save() 399 400 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 401 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 402 session = self.client.session 403 session[SESSION_KEY_PLAN] = plan 404 session.save() 405 406 response = self.client.get( 407 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 408 ) 409 self.assertEqual(response.status_code, 200) 410 registration = response.json()["registration"] 411 self.assertNotIn("authenticatorAttachment", registration["authenticatorSelection"])
Test that authenticatorAttachment is NOT inferred when hints are mixed
413 def test_registration_options_hints_order_preserved(self): 414 """Test that hint order is preserved (first hint = highest priority)""" 415 self.stage.hints = [ 416 WebAuthnHint.HYBRID, 417 WebAuthnHint.CLIENT_DEVICE, 418 WebAuthnHint.SECURITY_KEY, 419 ] 420 self.stage.save() 421 422 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 423 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 424 session = self.client.session 425 session[SESSION_KEY_PLAN] = plan 426 session.save() 427 428 response = self.client.get( 429 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 430 ) 431 self.assertEqual(response.status_code, 200) 432 registration = response.json()["registration"] 433 self.assertEqual(registration["hints"], ["hybrid", "client-device", "security-key"])
Test that hint order is preserved (first hint = highest priority)
435 def test_register_max_retries(self): 436 """Test registration (exceeding max retries)""" 437 self.stage.max_attempts = 2 438 self.stage.save() 439 440 plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) 441 plan.context[PLAN_CONTEXT_PENDING_USER] = self.user 442 plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( 443 b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" 444 ) 445 session = self.client.session 446 session[SESSION_KEY_PLAN] = plan 447 session.save() 448 449 # first failed request 450 response = self.client.post( 451 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 452 data={ 453 "component": "ak-stage-authenticator-webauthn", 454 "response": { 455 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 456 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 457 "type": "public-key", 458 "registrationClientExtensions": "{}", 459 "response": { 460 "clientDataJSON": ( 461 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 462 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 463 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 464 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 465 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 466 ), 467 "attestationObject": ( 468 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 469 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 470 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 471 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 472 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 473 ), 474 }, 475 }, 476 }, 477 SERVER_NAME="localhost", 478 SERVER_PORT="9000", 479 ) 480 self.assertEqual(response.status_code, 200) 481 self.assertStageResponse( 482 response, 483 flow=self.flow, 484 component="ak-stage-authenticator-webauthn", 485 response_errors={ 486 "response": [ 487 { 488 "string": ( 489 "Registration failed. Error: Unable to decode " 490 "client_data_json bytes as JSON" 491 ), 492 "code": "invalid", 493 } 494 ] 495 }, 496 ) 497 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) 498 499 # Second failed request 500 response = self.client.post( 501 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), 502 data={ 503 "component": "ak-stage-authenticator-webauthn", 504 "response": { 505 "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 506 "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", 507 "type": "public-key", 508 "registrationClientExtensions": "{}", 509 "response": { 510 "clientDataJSON": ( 511 "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" 512 "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" 513 "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" 514 "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" 515 "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" 516 ), 517 "attestationObject": ( 518 "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" 519 "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" 520 "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" 521 "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" 522 "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" 523 ), 524 }, 525 }, 526 }, 527 SERVER_NAME="localhost", 528 SERVER_PORT="9000", 529 ) 530 self.assertEqual(response.status_code, 200) 531 self.assertStageResponse( 532 response, 533 flow=self.flow, 534 component="ak-stage-access-denied", 535 error_message=( 536 "Exceeded maximum attempts. Contact your authentik administrator for help." 537 ), 538 ) 539 self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
Test registration (exceeding max retries)