Repository: duo-labs/py_webauthn

Issue:
Add methods to help parse registration and authentication options JSON
While trying to update https://webauthn.io to use `webauthn==2.0.0` I discovered I needed to write logic to map JSON-serialized registration and authentication options to instances of this library's `PublicKeyCredentialCreationOptions` and `PublicKeyCredentialRequestOptions`. Moving away from Pydantic lost me the ability to call `.parse_obj()` on these objects to parse instances of these objects I pulled out of Redis as plain `dict`'s, so manual mapping was required.

In v2.0.0 of this library, though, I added `parse_registration_credential_json()` and `parse_authentication_credential_json()` helpers to do this for WebAuthn responses. Now I need to do the same for their corresponding options. I've already begun this work and will put it up as a PR shortly.

Discussion:

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c20a0fa..1346add 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,5 +13,6 @@
     "gitlens.advanced.blame.customArguments": [
         "--ignore-revs-file",
         ".git-blame-ignore-revs"
-      ]
+    ],
+    "python.analysis.autoImportCompletions": true
 }
diff --git a/README.md b/README.md
index bfdd883..b2dc8e7 100644
--- a/README.md
+++ b/README.md
@@ -85,3 +85,32 @@ Python's unittest module can be used to execute everything in the **tests/** dir
 ```sh
 venv $> python -m unittest
 ```
+
+Auto-watching unittests can be achieved with a tool like nodemon.
+
+**All tests:**
+```sh
+venv $> nodemon --exec "python -m unittest" --ext py
+```
+
+**An individual test file:**
+```sh
+venv $> nodemon --exec "python -m unittest tests/test_aaguid_to_string.py" --ext py
+```
+
+### Linting and Formatting
+
+Linting is handled via `mypy`:
+
+```sh
+venv $> python -m mypy webauthn
+Success: no issues found in 52 source files
+```
+
+The entire library is formatted using `black`:
+
+```sh
+venv $> python -m black webauthn --line-length=99
+All done! ✨ 🍰 ✨
+52 files left unchanged.
+```
diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py
index 751d5d5..443d1b0 100644
--- a/webauthn/helpers/__init__.py
+++ b/webauthn/helpers/__init__.py
@@ -11,11 +11,13 @@ from .hash_by_alg import hash_by_alg
 from .options_to_json import options_to_json
 from .parse_attestation_object import parse_attestation_object
 from .parse_authentication_credential_json import parse_authentication_credential_json
+from .parse_authentication_options_json import parse_authentication_options_json
 from .parse_authenticator_data import parse_authenticator_data
 from .parse_backup_flags import parse_backup_flags
 from .parse_cbor import parse_cbor
 from .parse_client_data_json import parse_client_data_json
 from .parse_registration_credential_json import parse_registration_credential_json
+from .parse_registration_options_json import parse_registration_options_json
 from .validate_certificate_chain import validate_certificate_chain
 from .verify_safetynet_timestamp import verify_safetynet_timestamp
 from .verify_signature import verify_signature
@@ -35,10 +37,12 @@ __all__ = [
     "parse_attestation_object",
     "parse_authenticator_data",
     "parse_authentication_credential_json",
+    "parse_authentication_options_json",
     "parse_backup_flags",
     "parse_cbor",
     "parse_client_data_json",
     "parse_registration_credential_json",
+    "parse_registration_options_json",
     "validate_certificate_chain",
     "verify_safetynet_timestamp",
     "verify_signature",
diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py
index 7776fcd..af5db41 100644
--- a/webauthn/helpers/exceptions.py
+++ b/webauthn/helpers/exceptions.py
@@ -1,7 +1,15 @@
+class InvalidRegistrationOptions(Exception):
+    pass
+
+
 class InvalidRegistrationResponse(Exception):
     pass
 
 
+class InvalidAuthenticationOptions(Exception):
+    pass
+
+
 class InvalidAuthenticationResponse(Exception):
     pass
 
diff --git a/webauthn/helpers/parse_authentication_options_json.py b/webauthn/helpers/parse_authentication_options_json.py
new file mode 100644
index 0000000..2f5db3b
--- /dev/null
+++ b/webauthn/helpers/parse_authentication_options_json.py
@@ -0,0 +1,112 @@
+import json
+from json import JSONDecodeError
+from typing import List, Optional, Union
+
+from .base64url_to_bytes import base64url_to_bytes
+from .exceptions import InvalidJSONStructure, InvalidAuthenticationOptions
+from .structs import (
+    AuthenticatorTransport,
+    PublicKeyCredentialDescriptor,
+    PublicKeyCredentialRequestOptions,
+    UserVerificationRequirement,
+)
+
+
+def parse_authentication_options_json(
+    json_val: Union[str, dict]
+) -> PublicKeyCredentialRequestOptions:
+    """
+    Parse a JSON form of authentication options, as either stringified JSON or a plain dict, into an
+    instance of `PublicKeyCredentialRequestOptions`. Typically useful in mapping output from
+    `generate_authentication_options()`, that's been persisted as JSON via Redis/etc... back into
+    structured data.
+    """
+    if isinstance(json_val, str):
+        try:
+            json_val = json.loads(json_val)
+        except JSONDecodeError:
+            raise InvalidJSONStructure("Unable to decode options as JSON")
+
+    if not isinstance(json_val, dict):
+        raise InvalidJSONStructure("Options were not a JSON object")
+
+    """
+    Check challenge
+    """
+    options_challenge = json_val.get("challenge")
+    if not isinstance(options_challenge, str):
+        raise InvalidJSONStructure("Options missing required challenge")
+
+    """
+    Check timeout
+    """
+    options_timeout = json_val.get("timeout")
+    mapped_timeout = None
+    if isinstance(options_timeout, int):
+        mapped_timeout = options_timeout
+
+    """
+    Check rpId
+    """
+    options_rp_id = json_val.get("rpId")
+    mapped_rp_id = None
+    if isinstance(options_rp_id, str):
+        mapped_rp_id = options_rp_id
+
+    """
+    Check userVerification
+    """
+    options_user_verification = json_val.get("userVerification")
+    if not isinstance(options_user_verification, str):
+        raise InvalidJSONStructure("Options missing required userVerification")
+
+    try:
+        mapped_user_verification = UserVerificationRequirement(options_user_verification)
+    except ValueError as exc:
+        raise InvalidJSONStructure("Options userVerification was invalid value") from exc
+
+    """
+    Check allowCredentials
+    """
+    options_allow_credentials = json_val.get("allowCredentials")
+    mapped_allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None
+    if isinstance(options_allow_credentials, list):
+        mapped_allow_credentials = []
+        for cred in options_allow_credentials:
+            _cred_id = cred.get("id")
+            if not isinstance(_cred_id, str):
+                raise InvalidJSONStructure("Options excludeCredentials entry missing required id")
+
+            _mapped = PublicKeyCredentialDescriptor(id=base64url_to_bytes(_cred_id))
+
+            _transports = cred.get("transports")
+            if _transports is not None:
+                if not isinstance(_transports, list):
+                    raise InvalidJSONStructure(
+                        "Options excludeCredentials entry transports was not list"
+                    )
+                try:
+                    _mapped.transports = [
+                        AuthenticatorTransport(_transport) for _transport in _transports
+                    ]
+                except ValueError as exc:
+                    raise InvalidJSONStructure(
+                        "Options excludeCredentials entry transports had invalid value"
+                    ) from exc
+
+            mapped_allow_credentials.append(_mapped)
+
+    try:
+        authentication_options = PublicKeyCredentialRequestOptions(
+            challenge=base64url_to_bytes(options_challenge),
+            timeout=mapped_timeout,
+            rp_id=mapped_rp_id,
+            user_verification=mapped_user_verification,
+            allow_credentials=mapped_allow_credentials,
+        )
+    except Exception as exc:
+        raise InvalidAuthenticationOptions(
+            "Could not parse authentication options from JSON data"
+        ) from exc
+
+    return authentication_options
diff --git a/webauthn/helpers/parse_registration_options_json.py b/webauthn/helpers/parse_registration_options_json.py
new file mode 100644
index 0000000..96b5ffb
--- /dev/null
+++ b/webauthn/helpers/parse_registration_options_json.py
@@ -0,0 +1,227 @@
+import json
+from json.decoder import JSONDecodeError
+from typing import Union, Optional, List
+
+
+from .structs import (
+    PublicKeyCredentialCreationOptions,
+    PublicKeyCredentialRpEntity,
+    PublicKeyCredentialUserEntity,
+    AttestationConveyancePreference,
+    AuthenticatorSelectionCriteria,
+    AuthenticatorAttachment,
+    ResidentKeyRequirement,
+    UserVerificationRequirement,
+    PublicKeyCredentialParameters,
+    PublicKeyCredentialDescriptor,
+    AuthenticatorTransport,
+)
+from .cose import COSEAlgorithmIdentifier
+from .exceptions import InvalidJSONStructure, InvalidRegistrationOptions
+from .base64url_to_bytes import base64url_to_bytes
+
+
+def parse_registration_options_json(
+    json_val: Union[str, dict]
+) -> PublicKeyCredentialCreationOptions:
+    """
+    Parse a JSON form of registration options, as either stringified JSON or a plain dict, into an
+    instance of `PublicKeyCredentialCreationOptions`. Typically useful in mapping output from
+    `generate_registration_options()`, that's been persisted as JSON via Redis/etc... back into
+    structured data.
+    """
+    if isinstance(json_val, str):
+        try:
+            json_val = json.loads(json_val)
+        except JSONDecodeError:
+            raise InvalidJSONStructure("Unable to decode options as JSON")
+
+    if not isinstance(json_val, dict):
+        raise InvalidJSONStructure("Options were not a JSON object")
+
+    """
+    Check rp
+    """
+    options_rp = json_val.get("rp")
+    if not isinstance(options_rp, dict):
+        raise InvalidJSONStructure("Options missing required rp")
+
+    options_rp_id = options_rp.get("id")
+    if options_rp_id is not None and not isinstance(options_rp_id, str):
+        raise InvalidJSONStructure("Options rp.id present but not string")
+
+    options_rp_name = options_rp.get("name")
+    if not isinstance(options_rp_name, str):
+        raise InvalidJSONStructure("Options rp missing required name")
+
+    """
+    Check user
+    """
+    options_user = json_val.get("user")
+    if not isinstance(options_user, dict):
+        raise InvalidJSONStructure("Options missing required user")
+
+    options_user_id = options_user.get("id")
+    if not isinstance(options_user_id, str):
+        raise InvalidJSONStructure("Options user missing required id")
+
+    options_user_name = options_user.get("name")
+    if not isinstance(options_user_name, str):
+        raise InvalidJSONStructure("Options user missing required name")
+
+    options_user_display_name = options_user.get("displayName")
+    if not isinstance(options_user_display_name, str):
+        raise InvalidJSONStructure("Options user missing required displayName")
+
+    """
+    Check attestation
+    """
+    options_attestation = json_val.get("attestation")
+    if not isinstance(options_attestation, str):
+        raise InvalidJSONStructure("Options missing required attestation")
+
+    try:
+        mapped_attestation = AttestationConveyancePreference(options_attestation)
+    except ValueError as exc:
+        raise InvalidJSONStructure("Options attestation was invalid value") from exc
+
+    """
+    Check authenticatorSelection
+    """
+    options_authr_selection = json_val.get("authenticatorSelection")
+    mapped_authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None
+    if isinstance(options_authr_selection, dict):
+        options_authr_selection_attachment = options_authr_selection.get("authenticatorAttachment")
+        mapped_attachment = None
+        if options_authr_selection_attachment is not None:
+            try:
+                mapped_attachment = AuthenticatorAttachment(options_authr_selection_attachment)
+            except ValueError as exc:
+                raise InvalidJSONStructure(
+                    "Options authenticatorSelection attachment was invalid value"
+                ) from exc
+
+        options_authr_selection_rkey = options_authr_selection.get("residentKey")
+        mapped_rkey = None
+        if options_authr_selection_rkey is not None:
+            try:
+                mapped_rkey = ResidentKeyRequirement(options_authr_selection_rkey)
+            except ValueError as exc:
+                raise InvalidJSONStructure(
+                    "Options authenticatorSelection residentKey was invalid value"
+                ) from exc
+
+        options_authr_selection_require_rkey = options_authr_selection.get("requireResidentKey")
+        mapped_require_rkey = False
+        if options_authr_selection_require_rkey is not None:
+            if not isinstance(options_authr_selection_require_rkey, bool):
+                raise InvalidJSONStructure(
+                    "Options authenticatorSelection requireResidentKey was invalid boolean"
+                )
+
+            mapped_require_rkey = options_authr_selection_require_rkey
+
+        options_authr_selection_uv = options_authr_selection.get("userVerification")
+        mapped_user_verification = UserVerificationRequirement.PREFERRED
+        if options_authr_selection_uv is not None:
+            try:
+                mapped_user_verification = UserVerificationRequirement(options_authr_selection_uv)
+            except ValueError as exc:
+                raise InvalidJSONStructure(
+                    "Options authenticatorSelection userVerification was invalid value"
+                ) from exc
+
+        mapped_authenticator_selection = AuthenticatorSelectionCriteria(
+            authenticator_attachment=mapped_attachment,
+            resident_key=mapped_rkey,
+            require_resident_key=mapped_require_rkey,
+            user_verification=mapped_user_verification,
+        )
+
+    """
+    Check challenge is present
+    """
+    options_challenge = json_val.get("challenge")
+    if not isinstance(options_challenge, str):
+        raise InvalidJSONStructure("Options missing required challenge")
+
+    """
+    Check pubKeyCredParams
+    """
+    options_pub_key_cred_params = json_val.get("pubKeyCredParams")
+    if not isinstance(options_pub_key_cred_params, list):
+        raise InvalidJSONStructure("Options pubKeyCredParams was invalid value")
+
+    try:
+        mapped_pub_key_cred_params = [
+            PublicKeyCredentialParameters(
+                alg=COSEAlgorithmIdentifier(param["alg"]), type="public-key"
+            )
+            for param in options_pub_key_cred_params
+        ]
+    except ValueError as exc:
+        raise InvalidJSONStructure("Options pubKeyCredParams entry had invalid alg") from exc
+
+    """
+    Check excludeCredentials
+    """
+    options_exclude_credentials = json_val.get("excludeCredentials")
+    mapped_exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None
+    if isinstance(options_exclude_credentials, list):
+        mapped_exclude_credentials = []
+        for cred in options_exclude_credentials:
+            _cred_id = cred.get("id")
+            if not isinstance(_cred_id, str):
+                raise InvalidJSONStructure("Options excludeCredentials entry missing required id")
+
+            _mapped = PublicKeyCredentialDescriptor(id=base64url_to_bytes(_cred_id))
+
+            _transports = cred.get("transports")
+            if _transports is not None:
+                if not isinstance(_transports, list):
+                    raise InvalidJSONStructure(
+                        "Options excludeCredentials entry transports was not list"
+                    )
+                try:
+                    _mapped.transports = [
+                        AuthenticatorTransport(_transport) for _transport in _transports
+                    ]
+                except ValueError as exc:
+                    raise InvalidJSONStructure(
+                        "Options excludeCredentials entry transports had invalid value"
+                    ) from exc
+
+            mapped_exclude_credentials.append(_mapped)
+
+    """
+    Check timeout
+    """
+    options_timeout = json_val.get("timeout")
+    mapped_timeout = None
+    if isinstance(options_timeout, int):
+        mapped_timeout = options_timeout
+
+    try:
+        registration_options = PublicKeyCredentialCreationOptions(
+            rp=PublicKeyCredentialRpEntity(
+                id=options_rp_id,
+                name=options_rp_name,
+            ),
+            user=PublicKeyCredentialUserEntity(
+                id=base64url_to_bytes(options_user_id),
+                name=options_user_name,
+                display_name=options_user_display_name,
+            ),
+            attestation=mapped_attestation,
+            authenticator_selection=mapped_authenticator_selection,
+            challenge=base64url_to_bytes(options_challenge),
+            pub_key_cred_params=mapped_pub_key_cred_params,
+            exclude_credentials=mapped_exclude_credentials,
+            timeout=mapped_timeout,
+        )
+    except Exception as exc:
+        raise InvalidRegistrationOptions(
+            "Could not parse registration options from JSON data"
+        ) from exc
+
+    return registration_options


Test patch:
diff --git a/tests/test_parse_authentication_options.py b/tests/test_parse_authentication_options.py
new file mode 100644
index 0000000..ba84be3
--- /dev/null
+++ b/tests/test_parse_authentication_options.py
@@ -0,0 +1,169 @@
+from email.mime import base
+from unittest import TestCase
+
+from webauthn.helpers import base64url_to_bytes
+from webauthn.helpers.exceptions import InvalidJSONStructure
+from webauthn.helpers.structs import (
+    AuthenticatorTransport,
+    PublicKeyCredentialDescriptor,
+    UserVerificationRequirement,
+)
+from webauthn.helpers.parse_authentication_options_json import parse_authentication_options_json
+
+
+class TestParseAuthenticationOptionsJSON(TestCase):
+    maxDiff = None
+
+    def test_returns_parsed_options_simple(self) -> None:
+        opts = parse_authentication_options_json(
+            {
+                "challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ",
+                "timeout": 60000,
+                "rpId": "example.com",
+                "allowCredentials": [],
+                "userVerification": "preferred",
+            }
+        )
+
+        self.assertEqual(
+            opts.challenge,
+            base64url_to_bytes(
+                "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ"
+            ),
+        )
+        self.assertEqual(opts.timeout, 60000)
+        self.assertEqual(opts.rp_id, "example.com")
+        self.assertEqual(opts.allow_credentials, [])
+        self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED)
+
+    def test_returns_parsed_options_full(self) -> None:
+        opts = parse_authentication_options_json(
+            {
+                "challenge": "MTIzNDU2Nzg5MA",
+                "timeout": 12000,
+                "rpId": "example.com",
+                "allowCredentials": [
+                    {
+                        "id": "MTIzNDU2Nzg5MA",
+                        "type": "public-key",
+                        "transports": ["internal", "hybrid"],
+                    }
+                ],
+                "userVerification": "required",
+            }
+        )
+
+        self.assertEqual(opts.challenge, base64url_to_bytes("MTIzNDU2Nzg5MA"))
+        self.assertEqual(opts.timeout, 12000)
+        self.assertEqual(opts.rp_id, "example.com")
+        self.assertEqual(
+            opts.allow_credentials,
+            [
+                PublicKeyCredentialDescriptor(
+                    id=base64url_to_bytes("MTIzNDU2Nzg5MA"),
+                    transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID],
+                )
+            ],
+        )
+        self.assertEqual(opts.user_verification, UserVerificationRequirement.REQUIRED)
+
+    def test_supports_json_string(self) -> None:
+        opts = parse_authentication_options_json(
+            '{"challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ", "timeout": 60000, "rpId": "example.com", "allowCredentials": [], "userVerification": "preferred"}'
+        )
+
+        self.assertEqual(
+            opts.challenge,
+            base64url_to_bytes(
+                "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ"
+            ),
+        )
+        self.assertEqual(opts.timeout, 60000)
+        self.assertEqual(opts.rp_id, "example.com")
+        self.assertEqual(opts.allow_credentials, [])
+        self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED)
+
+    def test_raises_on_non_dict_json(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"):
+            parse_authentication_options_json("[0]")
+
+    def test_raises_on_missing_challenge(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required challenge"):
+            parse_authentication_options_json({})
+
+    def test_supports_optional_timeout(self) -> None:
+        opts = parse_authentication_options_json(
+            {
+                "challenge": "aaa",
+                "userVerification": "required",
+            }
+        )
+
+        self.assertIsNone(opts.timeout)
+
+    def test_supports_optional_rp_id(self) -> None:
+        opts = parse_authentication_options_json(
+            {
+                "challenge": "aaa",
+                "userVerification": "required",
+            }
+        )
+
+        self.assertIsNone(opts.rp_id)
+
+    def test_raises_on_missing_user_verification(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required userVerification"):
+            parse_authentication_options_json(
+                {
+                    "challenge": "aaaa",
+                }
+            )
+
+    def test_raises_on_invalid_user_verification(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "userVerification was invalid"):
+            parse_authentication_options_json(
+                {
+                    "challenge": "aaaa",
+                    "userVerification": "when_inconvenient",
+                }
+            )
+
+    def test_supports_optional_allow_credentials(self) -> None:
+        opts = parse_authentication_options_json(
+            {
+                "challenge": "aaa",
+                "userVerification": "required",
+            }
+        )
+
+        self.assertIsNone(opts.allow_credentials)
+
+    def test_raises_on_allow_credentials_entry_missing_id(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"):
+            parse_authentication_options_json(
+                {
+                    "challenge": "aaa",
+                    "userVerification": "required",
+                    "allowCredentials": [{}],
+                }
+            )
+
+    def test_raises_on_allow_credentials_entry_invalid_transports(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "transports was not list"):
+            parse_authentication_options_json(
+                {
+                    "challenge": "aaa",
+                    "userVerification": "required",
+                    "allowCredentials": [{"id": "aaaa", "transports": ""}],
+                }
+            )
+
+    def test_raises_on_allow_credentials_entry_invalid_transports_entry(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "entry transports had invalid value"):
+            parse_authentication_options_json(
+                {
+                    "challenge": "aaa",
+                    "userVerification": "required",
+                    "allowCredentials": [{"id": "aaaa", "transports": ["pcie"]}],
+                }
+            )
diff --git a/tests/test_parse_registration_credential_json.py b/tests/test_parse_registration_credential_json.py
index ece8f32..f2829e0 100644
--- a/tests/test_parse_registration_credential_json.py
+++ b/tests/test_parse_registration_credential_json.py
@@ -6,7 +6,7 @@ from webauthn.helpers.structs import AuthenticatorTransport, AuthenticatorAttach
 from webauthn.helpers.parse_registration_credential_json import parse_registration_credential_json
 
 
-class TestParseClientDataJSON(TestCase):
+class TestParseRegistrationCredentialJSON(TestCase):
     def test_raises_on_non_dict_json(self) -> None:
         with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"):
             parse_registration_credential_json("[0]")
diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py
new file mode 100644
index 0000000..3a2d2c6
--- /dev/null
+++ b/tests/test_parse_registration_options_json.py
@@ -0,0 +1,457 @@
+from unittest import TestCase
+
+from webauthn.helpers import base64url_to_bytes
+from webauthn.helpers.exceptions import InvalidJSONStructure
+from webauthn.helpers.structs import (
+    AuthenticatorTransport,
+    AuthenticatorAttachment,
+    AttestationConveyancePreference,
+    AuthenticatorSelectionCriteria,
+    PublicKeyCredentialDescriptor,
+    ResidentKeyRequirement,
+    PublicKeyCredentialRpEntity,
+    PublicKeyCredentialUserEntity,
+    UserVerificationRequirement,
+    PublicKeyCredentialParameters,
+)
+from webauthn.helpers.cose import COSEAlgorithmIdentifier
+from webauthn.helpers.parse_registration_options_json import parse_registration_options_json
+
+
+class TestParseRegistrationOptionsJSON(TestCase):
+    maxDiff = None
+
+    def test_returns_parsed_options_simple(self) -> None:
+        parsed = parse_registration_options_json(
+            {
+                "rp": {"name": "Example Co", "id": "example.com"},
+                "user": {
+                    "id": "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw",
+                    "name": "bob",
+                    "displayName": "bob",
+                },
+                "challenge": "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w",
+                "pubKeyCredParams": [{"type": "public-key", "alg": -36}],
+                "timeout": 60000,
+                "excludeCredentials": [],
+                "attestation": "none",
+            }
+        )
+
+        self.assertEqual(
+            parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co")
+        )
+        self.assertEqual(
+            parsed.user,
+            PublicKeyCredentialUserEntity(
+                id=base64url_to_bytes(
+                    "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw"
+                ),
+                name="bob",
+                display_name="bob",
+            ),
+        )
+        self.assertEqual(parsed.attestation, AttestationConveyancePreference.NONE)
+        self.assertEqual(parsed.authenticator_selection, None)
+        self.assertEqual(
+            parsed.challenge,
+            base64url_to_bytes(
+                "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w"
+            ),
+        )
+        self.assertEqual(parsed.exclude_credentials, [])
+        self.assertEqual(
+            parsed.pub_key_cred_params,
+            [
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.ECDSA_SHA_512,
+                    type="public-key",
+                )
+            ],
+        )
+        self.assertEqual(parsed.timeout, 60000)
+
+    def test_returns_parsed_options_full(self) -> None:
+        parsed = parse_registration_options_json(
+            {
+                "rp": {"name": "Example Co", "id": "example.com"},
+                "user": {"id": "AQIDBA", "name": "lee", "displayName": "Lee"},
+                "challenge": "AQIDBAUGBwgJAA",
+                "pubKeyCredParams": [
+                    {"type": "public-key", "alg": -7},
+                    {"type": "public-key", "alg": -8},
+                    {"type": "public-key", "alg": -36},
+                    {"type": "public-key", "alg": -37},
+                    {"type": "public-key", "alg": -38},
+                    {"type": "public-key", "alg": -39},
+                    {"type": "public-key", "alg": -257},
+                    {"type": "public-key", "alg": -258},
+                    {"type": "public-key", "alg": -259},
+                ],
+                "timeout": 12000,
+                "excludeCredentials": [
+                    {
+                        "id": "MTIzNDU2Nzg5MA",
+                        "type": "public-key",
+                        "transports": ["internal", "hybrid"],
+                    }
+                ],
+                "authenticatorSelection": {
+                    "authenticatorAttachment": "platform",
+                    "residentKey": "required",
+                    "requireResidentKey": True,
+                    "userVerification": "discouraged",
+                },
+                "attestation": "direct",
+            }
+        )
+
+        self.assertEqual(
+            parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co")
+        )
+        self.assertEqual(
+            parsed.user,
+            PublicKeyCredentialUserEntity(
+                id=base64url_to_bytes("AQIDBA"),
+                name="lee",
+                display_name="Lee",
+            ),
+        )
+        self.assertEqual(parsed.attestation, AttestationConveyancePreference.DIRECT)
+        self.assertEqual(
+            parsed.authenticator_selection,
+            AuthenticatorSelectionCriteria(
+                authenticator_attachment=AuthenticatorAttachment.PLATFORM,
+                resident_key=ResidentKeyRequirement.REQUIRED,
+                require_resident_key=True,
+                user_verification=UserVerificationRequirement.DISCOURAGED,
+            ),
+        )
+        self.assertEqual(parsed.challenge, base64url_to_bytes("AQIDBAUGBwgJAA"))
+        self.assertEqual(
+            parsed.exclude_credentials,
+            [
+                PublicKeyCredentialDescriptor(
+                    id=base64url_to_bytes("MTIzNDU2Nzg5MA"),
+                    transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID],
+                )
+            ],
+        )
+        self.assertEqual(
+            parsed.pub_key_cred_params,
+            [
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.ECDSA_SHA_256,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.EDDSA,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.ECDSA_SHA_512,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384,
+                    type="public-key",
+                ),
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512,
+                    type="public-key",
+                ),
+            ],
+        )
+        self.assertEqual(parsed.timeout, 12000)
+
+    def test_supports_json_string(self) -> None:
+        parsed = parse_registration_options_json(
+            '{"rp": {"name": "Example Co", "id": "example.com"}, "user": {"id": "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw", "name": "bob", "displayName": "bob"}, "challenge": "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w", "authenticatorSelection": {"userVerification": "required"}, "pubKeyCredParams": [{"type": "public-key", "alg": -36}], "timeout": 60000, "excludeCredentials": [], "attestation": "none"}'
+        )
+
+        self.assertEqual(
+            parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co")
+        )
+        self.assertEqual(
+            parsed.user,
+            PublicKeyCredentialUserEntity(
+                id=base64url_to_bytes(
+                    "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw"
+                ),
+                name="bob",
+                display_name="bob",
+            ),
+        )
+        self.assertEqual(parsed.attestation, AttestationConveyancePreference.NONE)
+        self.assertEqual(
+            parsed.authenticator_selection,
+            AuthenticatorSelectionCriteria(user_verification=UserVerificationRequirement.REQUIRED),
+        )
+        self.assertEqual(
+            parsed.challenge,
+            base64url_to_bytes(
+                "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w"
+            ),
+        )
+        self.assertEqual(parsed.exclude_credentials, [])
+        self.assertEqual(
+            parsed.pub_key_cred_params,
+            [
+                PublicKeyCredentialParameters(
+                    alg=COSEAlgorithmIdentifier.ECDSA_SHA_512,
+                    type="public-key",
+                )
+            ],
+        )
+        self.assertEqual(parsed.timeout, 60000)
+
+    def test_raises_on_non_dict_json(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"):
+            parse_registration_options_json("[0]")
+
+    def test_raises_on_missing_rp(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required rp"):
+            parse_registration_options_json({})
+
+    def test_raises_on_malformed_rp_id(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "id present but not string"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": 0},
+                }
+            )
+
+    def test_raises_on_missing_missing_rp_name(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required name"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com"},
+                }
+            )
+
+    def test_raises_on_missing_user(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required user"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                }
+            )
+
+    def test_raises_on_missing_user_id(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {},
+                }
+            )
+
+    def test_raises_on_missing_user_name(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required name"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa"},
+                }
+            )
+
+    def test_raises_on_missing_user_display_name(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required displayName"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "Lee"},
+                }
+            )
+
+    def test_raises_on_missing_attestation(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required attestation"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                }
+            )
+
+    def test_raises_on_unrecognized_attestation(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "attestation was invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "if_you_feel_like_it",
+                }
+            )
+
+    def test_supports_optional_authenticator_selection(self) -> None:
+        opts = parse_registration_options_json(
+            {
+                "rp": {"id": "example.com", "name": "Example Co"},
+                "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                "attestation": "none",
+                "challenge": "AAAA",
+                "pubKeyCredParams": [{"type": "public-key", "alg": -7}],
+            }
+        )
+
+        self.assertIsNone(opts.authenticator_selection)
+
+    def test_raises_on_invalid_authenticator_selection_authenticator_attachment(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "attachment was invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "authenticatorSelection": {"authenticatorAttachment": "pcie"},
+                }
+            )
+
+    def test_raises_on_invalid_authenticator_selection_resident_key(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "residentKey was invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "authenticatorSelection": {"residentKey": "yes_please"},
+                }
+            )
+
+    def test_raises_on_invalid_authenticator_selection_require_resident_key(self) -> None:
+        with self.assertRaisesRegex(
+            InvalidJSONStructure, "requireResidentKey was invalid boolean"
+        ):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "authenticatorSelection": {"requireResidentKey": "always"},
+                }
+            )
+
+    def test_raises_on_invalid_authenticator_selection_user_verification(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "userVerification was invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "authenticatorSelection": {"userVerification": "when_inconvenient"},
+                }
+            )
+
+    def test_raises_on_missing_challenge(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required challenge"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                }
+            )
+
+    def test_raises_on_missing_pub_key_cred_params(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "pubKeyCredParams was invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "challenge": "aaaa",
+                }
+            )
+
+    def test_raises_on_pub_key_cred_params_entry_with_invalid_alg(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "entry had invalid alg"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "challenge": "aaaa",
+                    "pubKeyCredParams": [{"alg": 0}],
+                }
+            )
+
+    def test_supports_optional_exclude_credentials(self) -> None:
+        opts = parse_registration_options_json(
+            {
+                "rp": {"id": "example.com", "name": "Example Co"},
+                "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                "attestation": "none",
+                "challenge": "aaaa",
+                "pubKeyCredParams": [{"alg": -7}],
+            }
+        )
+
+        self.assertIsNone(opts.exclude_credentials)
+
+    def test_raises_on_exclude_credentials_entry_missing_id(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "challenge": "aaaa",
+                    "pubKeyCredParams": [{"alg": -7}],
+                    "excludeCredentials": [{}],
+                }
+            )
+
+    def test_raises_on_exclude_credentials_entry_invalid_transports(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "transports was not list"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "challenge": "aaaa",
+                    "pubKeyCredParams": [{"alg": -7}],
+                    "excludeCredentials": [{"id": "aaaa", "transports": ""}],
+                }
+            )
+
+    def test_raises_on_exclude_credentials_entry_invalid_transports_entry(self) -> None:
+        with self.assertRaisesRegex(InvalidJSONStructure, "entry transports had invalid value"):
+            parse_registration_options_json(
+                {
+                    "rp": {"id": "example.com", "name": "Example Co"},
+                    "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                    "attestation": "none",
+                    "challenge": "aaaa",
+                    "pubKeyCredParams": [{"alg": -7}],
+                    "excludeCredentials": [{"id": "aaaa", "transports": ["pcie"]}],
+                }
+            )
+
+    def test_supports_missing_timeout(self) -> None:
+        opts = parse_registration_options_json(
+            {
+                "rp": {"id": "example.com", "name": "Example Co"},
+                "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"},
+                "attestation": "none",
+                "challenge": "aaaa",
+                "pubKeyCredParams": [{"alg": -7}],
+            }
+        )
+
+        self.assertIsNone(opts.timeout)