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())
class TestAuthenticatorWebAuthnStage(authentik.flows.tests.FlowTestCase):
 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

def setUp(self) -> None:
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.

def test_api_delete(self):
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

def test_registration_options(self):
 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

def test_register(self):
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

def test_register_restricted_device_type_deny(self):
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)

def test_register_restricted_device_type_allow(self):
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)

def test_register_restricted_device_type_allow_unknown(self):
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)

def test_registration_options_with_hints(self):
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

def test_registration_options_hints_empty(self):
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

def test_registration_options_hints_infer_attachment_cross_platform(self):
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

def test_registration_options_hints_infer_attachment_platform(self):
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

def test_registration_options_hints_no_infer_when_attachment_set(self):
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

def test_registration_options_hints_no_infer_mixed(self):
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

def test_registration_options_hints_order_preserved(self):
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)

def test_register_max_retries(self):
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)