Skip to content

Commit 6a3f7c7

Browse files
authored
Merge pull request #256 from duo-labs/feat/254-options-to-json-dict
Add new `options_to_json_dict` helper
2 parents ac2e03c + bd6f816 commit 6a3f7c7

File tree

5 files changed

+234
-113
lines changed

5 files changed

+234
-113
lines changed

tests/test_options_to_json_dict.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from unittest import TestCase
2+
3+
from webauthn.helpers.cose import COSEAlgorithmIdentifier
4+
from webauthn.helpers.options_to_json_dict import options_to_json_dict
5+
from webauthn.helpers.structs import (
6+
AttestationConveyancePreference,
7+
AuthenticatorAttachment,
8+
AuthenticatorSelectionCriteria,
9+
PublicKeyCredentialDescriptor,
10+
PublicKeyCredentialHint,
11+
ResidentKeyRequirement,
12+
UserVerificationRequirement,
13+
)
14+
from webauthn import generate_registration_options, generate_authentication_options
15+
16+
17+
class TestWebAuthnOptionsToJSON(TestCase):
18+
maxDiff = None
19+
20+
def test_converts_registration_options_to_JSON(self) -> None:
21+
options = generate_registration_options(
22+
rp_id="example.com",
23+
rp_name="Example Co",
24+
user_id=bytes([1, 2, 3, 4]),
25+
user_name="lee",
26+
user_display_name="Lee",
27+
attestation=AttestationConveyancePreference.DIRECT,
28+
authenticator_selection=AuthenticatorSelectionCriteria(
29+
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
30+
resident_key=ResidentKeyRequirement.REQUIRED,
31+
),
32+
challenge=b"1234567890",
33+
exclude_credentials=[
34+
PublicKeyCredentialDescriptor(id=b"1234567890"),
35+
],
36+
supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512],
37+
timeout=120000,
38+
hints=[
39+
PublicKeyCredentialHint.SECURITY_KEY,
40+
PublicKeyCredentialHint.CLIENT_DEVICE,
41+
PublicKeyCredentialHint.HYBRID,
42+
],
43+
)
44+
45+
output = options_to_json_dict(options)
46+
47+
self.assertEqual(
48+
output,
49+
{
50+
"rp": {"name": "Example Co", "id": "example.com"},
51+
"user": {
52+
"id": "AQIDBA",
53+
"name": "lee",
54+
"displayName": "Lee",
55+
},
56+
"challenge": "MTIzNDU2Nzg5MA",
57+
"pubKeyCredParams": [{"type": "public-key", "alg": -36}],
58+
"timeout": 120000,
59+
"excludeCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}],
60+
"authenticatorSelection": {
61+
"authenticatorAttachment": "platform",
62+
"residentKey": "required",
63+
"requireResidentKey": True,
64+
"userVerification": "preferred",
65+
},
66+
"attestation": "direct",
67+
"hints": ["security-key", "client-device", "hybrid"],
68+
},
69+
)
70+
71+
def test_converts_authentication_options_to_JSON(self) -> None:
72+
options = generate_authentication_options(
73+
rp_id="example.com",
74+
challenge=b"1234567890",
75+
allow_credentials=[
76+
PublicKeyCredentialDescriptor(id=b"1234567890"),
77+
],
78+
timeout=120000,
79+
user_verification=UserVerificationRequirement.DISCOURAGED,
80+
)
81+
82+
output = options_to_json_dict(options)
83+
84+
self.assertEqual(
85+
output,
86+
{
87+
"rpId": "example.com",
88+
"challenge": "MTIzNDU2Nzg5MA",
89+
"allowCredentials": [{"type": "public-key", "id": "MTIzNDU2Nzg5MA"}],
90+
"timeout": 120000,
91+
"userVerification": "discouraged",
92+
},
93+
)

webauthn/helpers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .generate_user_handle import generate_user_handle
1010
from .hash_by_alg import hash_by_alg
1111
from .options_to_json import options_to_json
12+
from .options_to_json_dict import options_to_json_dict
1213
from .parse_attestation_object import parse_attestation_object
1314
from .parse_authentication_credential_json import parse_authentication_credential_json
1415
from .parse_authentication_options_json import parse_authentication_options_json
@@ -34,6 +35,7 @@
3435
"generate_user_handle",
3536
"hash_by_alg",
3637
"options_to_json",
38+
"options_to_json_dict",
3739
"parse_attestation_object",
3840
"parse_authenticator_data",
3941
"parse_authentication_credential_json",
Lines changed: 8 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,23 @@
11
import json
2-
from typing import Union, Dict, Any
2+
from typing import Union
33

44
from .structs import (
55
PublicKeyCredentialCreationOptions,
66
PublicKeyCredentialRequestOptions,
77
)
8-
from .bytes_to_base64url import bytes_to_base64url
8+
from .options_to_json_dict import options_to_json_dict
99

1010

1111
def options_to_json(
1212
options: Union[
1313
PublicKeyCredentialCreationOptions,
1414
PublicKeyCredentialRequestOptions,
15-
]
15+
],
1616
) -> str:
1717
"""
18-
Prepare options for transmission to the front end as JSON
18+
Convert registration or authentication options into a simple JSON dictionary, and then stringify
19+
the result to send to the front end as `Content-Type: application/json`. Alternatively use
20+
`webauthn.helpers.options_to_json_dict` to get a raw `dict` instead to combine the options with
21+
other data beforehand/encode with a different scheme/etc...
1922
"""
20-
if isinstance(options, PublicKeyCredentialCreationOptions):
21-
_rp = {"name": options.rp.name}
22-
if options.rp.id:
23-
_rp["id"] = options.rp.id
24-
25-
_user: Dict[str, Any] = {
26-
"id": bytes_to_base64url(options.user.id),
27-
"name": options.user.name,
28-
"displayName": options.user.display_name,
29-
}
30-
31-
reg_to_return: Dict[str, Any] = {
32-
"rp": _rp,
33-
"user": _user,
34-
"challenge": bytes_to_base64url(options.challenge),
35-
"pubKeyCredParams": [
36-
{"type": param.type, "alg": param.alg} for param in options.pub_key_cred_params
37-
],
38-
}
39-
40-
# Begin handling optional values
41-
42-
if options.timeout is not None:
43-
reg_to_return["timeout"] = options.timeout
44-
45-
if options.exclude_credentials is not None:
46-
_excluded = options.exclude_credentials
47-
json_excluded = []
48-
49-
for cred in _excluded:
50-
json_excluded_cred: Dict[str, Any] = {
51-
"id": bytes_to_base64url(cred.id),
52-
"type": cred.type.value,
53-
}
54-
55-
if cred.transports:
56-
json_excluded_cred["transports"] = [
57-
transport.value for transport in cred.transports
58-
]
59-
60-
json_excluded.append(json_excluded_cred)
61-
62-
reg_to_return["excludeCredentials"] = json_excluded
63-
64-
if options.authenticator_selection is not None:
65-
_selection = options.authenticator_selection
66-
json_selection: Dict[str, Any] = {}
67-
68-
if _selection.authenticator_attachment is not None:
69-
json_selection["authenticatorAttachment"] = (
70-
_selection.authenticator_attachment.value
71-
)
72-
73-
if _selection.resident_key is not None:
74-
json_selection["residentKey"] = _selection.resident_key.value
75-
76-
if _selection.require_resident_key is not None:
77-
json_selection["requireResidentKey"] = _selection.require_resident_key
78-
79-
if _selection.user_verification is not None:
80-
json_selection["userVerification"] = _selection.user_verification.value
81-
82-
reg_to_return["authenticatorSelection"] = json_selection
83-
84-
if options.attestation is not None:
85-
reg_to_return["attestation"] = options.attestation.value
86-
87-
if options.hints is not None:
88-
reg_to_return["hints"] = [hint.value for hint in options.hints]
89-
90-
return json.dumps(reg_to_return)
91-
92-
if isinstance(options, PublicKeyCredentialRequestOptions):
93-
auth_to_return: Dict[str, Any] = {"challenge": bytes_to_base64url(options.challenge)}
94-
95-
if options.timeout is not None:
96-
auth_to_return["timeout"] = options.timeout
97-
98-
if options.rp_id is not None:
99-
auth_to_return["rpId"] = options.rp_id
100-
101-
if options.allow_credentials is not None:
102-
_allowed = options.allow_credentials
103-
json_allowed = []
104-
105-
for cred in _allowed:
106-
json_allowed_cred: Dict[str, Any] = {
107-
"id": bytes_to_base64url(cred.id),
108-
"type": cred.type.value,
109-
}
110-
111-
if cred.transports:
112-
json_allowed_cred["transports"] = [
113-
transport.value for transport in cred.transports
114-
]
115-
116-
json_allowed.append(json_allowed_cred)
117-
118-
auth_to_return["allowCredentials"] = json_allowed
119-
120-
if options.user_verification:
121-
auth_to_return["userVerification"] = options.user_verification.value
122-
123-
return json.dumps(auth_to_return)
124-
125-
raise TypeError(
126-
"Options was not instance of PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions"
127-
)
23+
return json.dumps(options_to_json_dict(options=options))
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from typing import Union, Dict, Any
2+
3+
from .structs import (
4+
PublicKeyCredentialCreationOptions,
5+
PublicKeyCredentialRequestOptions,
6+
)
7+
from .bytes_to_base64url import bytes_to_base64url
8+
9+
10+
def options_to_json_dict(
11+
options: Union[
12+
PublicKeyCredentialCreationOptions,
13+
PublicKeyCredentialRequestOptions,
14+
],
15+
) -> Dict[str, Any]:
16+
"""
17+
Convert registration or authentication options into a simple JSON dictionary. Alternatively, use
18+
`webauthn.helpers.options_to_json` to perform this conversion and then stringify the resulting
19+
`dict` to make it easier to send to the front end.
20+
"""
21+
if isinstance(options, PublicKeyCredentialCreationOptions):
22+
_rp = {"name": options.rp.name}
23+
if options.rp.id:
24+
_rp["id"] = options.rp.id
25+
26+
_user: Dict[str, Any] = {
27+
"id": bytes_to_base64url(options.user.id),
28+
"name": options.user.name,
29+
"displayName": options.user.display_name,
30+
}
31+
32+
reg_to_return: Dict[str, Any] = {
33+
"rp": _rp,
34+
"user": _user,
35+
"challenge": bytes_to_base64url(options.challenge),
36+
"pubKeyCredParams": [
37+
{"type": param.type, "alg": param.alg} for param in options.pub_key_cred_params
38+
],
39+
}
40+
41+
# Begin handling optional values
42+
43+
if options.timeout is not None:
44+
reg_to_return["timeout"] = options.timeout
45+
46+
if options.exclude_credentials is not None:
47+
_excluded = options.exclude_credentials
48+
json_excluded = []
49+
50+
for cred in _excluded:
51+
json_excluded_cred: Dict[str, Any] = {
52+
"id": bytes_to_base64url(cred.id),
53+
"type": cred.type.value,
54+
}
55+
56+
if cred.transports:
57+
json_excluded_cred["transports"] = [
58+
transport.value for transport in cred.transports
59+
]
60+
61+
json_excluded.append(json_excluded_cred)
62+
63+
reg_to_return["excludeCredentials"] = json_excluded
64+
65+
if options.authenticator_selection is not None:
66+
_selection = options.authenticator_selection
67+
json_selection: Dict[str, Any] = {}
68+
69+
if _selection.authenticator_attachment is not None:
70+
json_selection["authenticatorAttachment"] = (
71+
_selection.authenticator_attachment.value
72+
)
73+
74+
if _selection.resident_key is not None:
75+
json_selection["residentKey"] = _selection.resident_key.value
76+
77+
if _selection.require_resident_key is not None:
78+
json_selection["requireResidentKey"] = _selection.require_resident_key
79+
80+
if _selection.user_verification is not None:
81+
json_selection["userVerification"] = _selection.user_verification.value
82+
83+
reg_to_return["authenticatorSelection"] = json_selection
84+
85+
if options.attestation is not None:
86+
reg_to_return["attestation"] = options.attestation.value
87+
88+
if options.hints is not None:
89+
reg_to_return["hints"] = [hint.value for hint in options.hints]
90+
91+
return reg_to_return
92+
93+
if isinstance(options, PublicKeyCredentialRequestOptions):
94+
auth_to_return: Dict[str, Any] = {"challenge": bytes_to_base64url(options.challenge)}
95+
96+
if options.timeout is not None:
97+
auth_to_return["timeout"] = options.timeout
98+
99+
if options.rp_id is not None:
100+
auth_to_return["rpId"] = options.rp_id
101+
102+
if options.allow_credentials is not None:
103+
_allowed = options.allow_credentials
104+
json_allowed = []
105+
106+
for cred in _allowed:
107+
json_allowed_cred: Dict[str, Any] = {
108+
"id": bytes_to_base64url(cred.id),
109+
"type": cred.type.value,
110+
}
111+
112+
if cred.transports:
113+
json_allowed_cred["transports"] = [
114+
transport.value for transport in cred.transports
115+
]
116+
117+
json_allowed.append(json_allowed_cred)
118+
119+
auth_to_return["allowCredentials"] = json_allowed
120+
121+
if options.user_verification:
122+
auth_to_return["userVerification"] = options.user_verification.value
123+
124+
return auth_to_return
125+
126+
raise TypeError(
127+
"Options was not instance of PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions"
128+
)

webauthn/registration/verify_registration_response.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ def verify_registration_response(
164164
raise InvalidRegistrationResponse("Unexpected RP ID hash")
165165

166166
if require_user_presence and not auth_data.flags.up:
167-
raise InvalidRegistrationResponse("User presence was required, but was not present during attestation")
167+
raise InvalidRegistrationResponse(
168+
"User presence was required, but was not present during attestation"
169+
)
168170

169171
if require_user_verification and not auth_data.flags.uv:
170172
raise InvalidRegistrationResponse(

0 commit comments

Comments
 (0)