diff --git a/examples/registration.py b/examples/registration.py index d5641c1..c854515 100644 --- a/examples/registration.py +++ b/examples/registration.py @@ -10,6 +10,7 @@ AuthenticatorAttachment, AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, + PublicKeyCredentialHint, ResidentKeyRequirement, ) @@ -47,6 +48,7 @@ ], supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], timeout=12000, + hints=[PublicKeyCredentialHint.CLIENT_DEVICE], ) print("\n[Registration Options - Complex]") diff --git a/tests/test_options_to_json.py b/tests/test_options_to_json.py index 64defc3..6fabf64 100644 --- a/tests/test_options_to_json.py +++ b/tests/test_options_to_json.py @@ -9,6 +9,7 @@ AuthenticatorSelectionCriteria, AuthenticatorTransport, PublicKeyCredentialDescriptor, + PublicKeyCredentialHint, ResidentKeyRequirement, UserVerificationRequirement, ) @@ -36,6 +37,11 @@ def test_converts_registration_options_to_JSON(self) -> None: ], supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], timeout=120000, + hints=[ + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.CLIENT_DEVICE, + PublicKeyCredentialHint.HYBRID, + ], ) output = options_to_json(options) @@ -60,6 +66,7 @@ def test_converts_registration_options_to_JSON(self) -> None: "userVerification": "preferred", }, "attestation": "direct", + "hints": ["security-key", "client-device", "hybrid"], }, ) diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py index 8fded6a..c827341 100644 --- a/tests/test_parse_registration_options_json.py +++ b/tests/test_parse_registration_options_json.py @@ -9,6 +9,7 @@ AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, ResidentKeyRequirement, + PublicKeyCredentialHint, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, UserVerificationRequirement, @@ -104,6 +105,7 @@ def test_returns_parsed_options_full(self) -> None: "userVerification": "discouraged", }, "attestation": "direct", + "hints": ["security-key", "client-device", "hybrid"], } ) @@ -180,6 +182,14 @@ def test_returns_parsed_options_full(self) -> None: ], ) self.assertEqual(parsed.timeout, 12000) + self.assertEqual( + parsed.hints, + [ + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.CLIENT_DEVICE, + PublicKeyCredentialHint.HYBRID, + ], + ) def test_supports_json_string(self) -> None: parsed = parse_registration_options_json( @@ -250,6 +260,11 @@ def test_supports_options_to_json_output(self) -> None: ], supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512], timeout=12000, + hints=[ + PublicKeyCredentialHint.CLIENT_DEVICE, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.HYBRID, + ], ) opts_json = options_to_json(opts) @@ -264,6 +279,7 @@ def test_supports_options_to_json_output(self) -> None: self.assertEqual(parsed_opts_json.exclude_credentials, opts.exclude_credentials) self.assertEqual(parsed_opts_json.pub_key_cred_params, opts.pub_key_cred_params) self.assertEqual(parsed_opts_json.timeout, opts.timeout) + self.assertEqual(parsed_opts_json.hints, opts.hints) def test_raises_on_non_dict_json(self) -> None: with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): @@ -499,3 +515,56 @@ def test_supports_missing_timeout(self) -> None: ) self.assertIsNone(opts.timeout) + + def test_supports_empty_hints(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}], + "hints": [], + } + ) + + self.assertEqual(opts.hints, []) + + def test_raises_on_invalid_hints_assignment(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "hints 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", + "pubKeyCredParams": [{"alg": -7}], + "hints": "security-key", + } + ) + + def test_raises_on_invalid_hints_entry(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "hints 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}], + "hints": ["platform"], + } + ) + + def test_supports_optional_hints(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.hints) diff --git a/webauthn/helpers/options_to_json.py b/webauthn/helpers/options_to_json.py index 4b61c74..db35a8d 100644 --- a/webauthn/helpers/options_to_json.py +++ b/webauthn/helpers/options_to_json.py @@ -66,9 +66,9 @@ def options_to_json( json_selection: Dict[str, Any] = {} if _selection.authenticator_attachment is not None: - json_selection[ - "authenticatorAttachment" - ] = _selection.authenticator_attachment.value + json_selection["authenticatorAttachment"] = ( + _selection.authenticator_attachment.value + ) if _selection.resident_key is not None: json_selection["residentKey"] = _selection.resident_key.value @@ -84,6 +84,9 @@ def options_to_json( if options.attestation is not None: reg_to_return["attestation"] = options.attestation.value + if options.hints is not None: + reg_to_return["hints"] = [hint.value for hint in options.hints] + return json.dumps(reg_to_return) if isinstance(options, PublicKeyCredentialRequestOptions): diff --git a/webauthn/helpers/parse_registration_options_json.py b/webauthn/helpers/parse_registration_options_json.py index 96b5ffb..d3f7719 100644 --- a/webauthn/helpers/parse_registration_options_json.py +++ b/webauthn/helpers/parse_registration_options_json.py @@ -5,6 +5,7 @@ from .structs import ( PublicKeyCredentialCreationOptions, + PublicKeyCredentialHint, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, AttestationConveyancePreference, @@ -201,6 +202,20 @@ def parse_registration_options_json( if isinstance(options_timeout, int): mapped_timeout = options_timeout + """ + Check hints + """ + options_hints = json_val.get("hints") + mapped_hints = None + if options_hints is not None: + if not isinstance(options_hints, list): + raise InvalidJSONStructure("Options hints was invalid value") + + try: + mapped_hints = [PublicKeyCredentialHint(hint) for hint in options_hints] + except ValueError as exc: + raise InvalidJSONStructure("Options hints had invalid value") from exc + try: registration_options = PublicKeyCredentialCreationOptions( rp=PublicKeyCredentialRpEntity( @@ -218,6 +233,7 @@ def parse_registration_options_json( pub_key_cred_params=mapped_pub_key_cred_params, exclude_credentials=mapped_exclude_credentials, timeout=mapped_timeout, + hints=mapped_hints, ) except Exception as exc: raise InvalidRegistrationOptions( diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index b36a4a8..67f3bf8 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -1,6 +1,6 @@ from enum import Enum from dataclasses import dataclass, field -from typing import List, Literal, Optional, Union +from typing import List, Literal, Optional from .cose import COSEAlgorithmIdentifier @@ -158,6 +158,25 @@ class TokenBindingStatus(str, Enum): SUPPORTED = "supported" +class PublicKeyCredentialHint(str, Enum): + """Categories of authenticators that Relying Parties can pass along to browsers during + registration. Browsers that understand these values can optimize their modal experience to + start the user off in a particular registration flow. These values are less strict than + `authenticatorAttachment` (see `webauthn.helpers.strucAuthenticatorAttachment`) + + Members: + `SECURITY_KEY`: A portable FIDO2 authenticator capable of being used on multiple devices via a USB or NFC connection + `CLIENT_DEVICE`: The device that WebAuthn is being called on. Typically synonymous with platform authenticators + `HYBRID`: A platform authenticator on a mobile device + + https://w3c.github.io/webauthn/#enumdef-publickeycredentialhint + """ + + SECURITY_KEY = "security-key" + CLIENT_DEVICE = "client-device" + HYBRID = "hybrid" + + @dataclass class TokenBinding: """ @@ -293,6 +312,7 @@ class PublicKeyCredentialCreationOptions: (optional) `timeout`: How long the client/browser should give the user to interact with an authenticator (optional) `exclude_credentials`: A list of credentials associated with the user to prevent them from re-enrolling one of them (optional) `authenticator_selection`: Additional qualities about the authenticators the user can use to complete registration + (optional) `hints`: Suggestions to the browser about the type of authenticator the user should try and register. Multiple values should be ordered by decreasing preference (optional) `attestation`: The Relying Party's desire for a declaration of an authenticator's provenance via attestation statement https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialcreationoptions @@ -305,6 +325,7 @@ class PublicKeyCredentialCreationOptions: timeout: Optional[int] = None exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None + hints: Optional[List[PublicKeyCredentialHint]] = None attestation: AttestationConveyancePreference = AttestationConveyancePreference.NONE diff --git a/webauthn/registration/generate_registration_options.py b/webauthn/registration/generate_registration_options.py index 432e9a2..254d978 100644 --- a/webauthn/registration/generate_registration_options.py +++ b/webauthn/registration/generate_registration_options.py @@ -11,6 +11,7 @@ PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, ResidentKeyRequirement, + PublicKeyCredentialHint, ) @@ -52,6 +53,7 @@ def generate_registration_options( authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None, exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None, supported_pub_key_algs: Optional[List[COSEAlgorithmIdentifier]] = None, + hints: Optional[List[PublicKeyCredentialHint]] = None, ) -> PublicKeyCredentialCreationOptions: """Generate options for registering a credential via navigator.credentials.create() @@ -123,6 +125,7 @@ def generate_registration_options( timeout=timeout, exclude_credentials=exclude_credentials, attestation=attestation, + hints=hints, ) ########