Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hints support to registration #234

Merged
merged 5 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AuthenticatorAttachment,
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
PublicKeyCredentialHint,
ResidentKeyRequirement,
)

Expand Down Expand Up @@ -47,6 +48,7 @@
],
supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512],
timeout=12000,
hints=[PublicKeyCredentialHint.CLIENT_DEVICE],
)

print("\n[Registration Options - Complex]")
Expand Down
7 changes: 7 additions & 0 deletions tests/test_options_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
PublicKeyCredentialDescriptor,
PublicKeyCredentialHint,
ResidentKeyRequirement,
UserVerificationRequirement,
)
Expand Down Expand Up @@ -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)
Expand All @@ -60,6 +66,7 @@ def test_converts_registration_options_to_JSON(self) -> None:
"userVerification": "preferred",
},
"attestation": "direct",
"hints": ["security-key", "client-device", "hybrid"],
},
)

Expand Down
69 changes: 69 additions & 0 deletions tests/test_parse_registration_options_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
PublicKeyCredentialHint,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
UserVerificationRequirement,
Expand Down Expand Up @@ -104,6 +105,7 @@ def test_returns_parsed_options_full(self) -> None:
"userVerification": "discouraged",
},
"attestation": "direct",
"hints": ["security-key", "client-device", "hybrid"],
}
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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"):
Expand Down Expand Up @@ -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)
9 changes: 6 additions & 3 deletions webauthn/helpers/options_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions webauthn/helpers/parse_registration_options_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .structs import (
PublicKeyCredentialCreationOptions,
PublicKeyCredentialHint,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
AttestationConveyancePreference,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
23 changes: 22 additions & 1 deletion webauthn/helpers/structs.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
3 changes: 3 additions & 0 deletions webauthn/registration/generate_registration_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
ResidentKeyRequirement,
PublicKeyCredentialHint,
)


Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -123,6 +125,7 @@ def generate_registration_options(
timeout=timeout,
exclude_credentials=exclude_credentials,
attestation=attestation,
hints=hints,
)

########
Expand Down
Loading