Skip to content

Commit 1dfa002

Browse files
feat(webauthn): surface getAssertion unsignedExtensionOutputs to client extension results
PR #244 decoded the unsigned extension outputs map at the getAssertion response but kept it on the protocol response without surfacing it. This maps each entry into the client extension results as a top-level member keyed by extension identifier, the same shape WebAuthn uses for client extension results. The map has no fixed schema, so values are converted to JSON-safe types, with byte strings encoded as base64url. An empty or absent map adds nothing, and ids that would clash with a typed output are skipped. Includes an end-to-end test from the encoded response through to the JSON output.
1 parent 68ea5db commit 1dfa002

5 files changed

Lines changed: 232 additions & 4 deletions

File tree

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,13 @@ pub struct GetAssertionResponseUnsignedExtensions {
456456
/// if the extension wasn't requested.
457457
#[serde(skip_serializing_if = "Option::is_none")]
458458
pub appid: Option<bool>,
459+
460+
/// Outputs of extensions the authenticator returns unsigned (getAssertion
461+
/// response member 0x08), keyed by extension identifier and already
462+
/// converted to JSON-safe values. Surfaced verbatim as top-level members of
463+
/// the client extension results.
464+
#[serde(flatten)]
465+
pub unsigned_extension_outputs: serde_json::Map<String, serde_json::Value>,
459466
}
460467

461468
/// Context required for serializing a GetAssertion response to JSON.
@@ -568,6 +575,25 @@ impl Assertion {
568575
}),
569576
});
570577
}
578+
579+
// Unsigned extension outputs (getAssertion 0x08): surface each entry
580+
// as a top-level member, skipping ids that collide with a typed
581+
// member to avoid emitting a duplicate JSON key.
582+
const TYPED_MEMBERS: [&str; 6] = [
583+
"appid",
584+
"credProps",
585+
"hmacCreateSecret",
586+
"hmacGetSecret",
587+
"largeBlob",
588+
"prf",
589+
];
590+
for (id, value) in &unsigned_ext.unsigned_extension_outputs {
591+
if !TYPED_MEMBERS.contains(&id.as_str()) {
592+
results
593+
.unsigned_extension_outputs
594+
.insert(id.clone(), value.clone());
595+
}
596+
}
571597
}
572598

573599
results
@@ -1351,6 +1377,7 @@ mod tests {
13511377
}),
13521378
}),
13531379
appid: None,
1380+
unsigned_extension_outputs: Default::default(),
13541381
});
13551382

13561383
let request = create_test_request();
@@ -1372,6 +1399,7 @@ mod tests {
13721399
large_blob: None,
13731400
prf: None,
13741401
appid: Some(true),
1402+
unsigned_extension_outputs: Default::default(),
13751403
});
13761404

13771405
let request = create_test_request();
@@ -1395,6 +1423,7 @@ mod tests {
13951423
large_blob: None,
13961424
prf: None,
13971425
appid: None,
1426+
unsigned_extension_outputs: Default::default(),
13981427
});
13991428

14001429
let request = create_test_request();

libwebauthn/src/ops/webauthn/idl/response.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ pub struct AuthenticationExtensionsClientOutputsJSON {
225225
/// PRF extension output.
226226
#[serde(skip_serializing_if = "Option::is_none")]
227227
pub prf: Option<PRFOutputJSON>,
228+
229+
/// Outputs of extensions the authenticator returned unsigned (e.g.
230+
/// `thirdPartyPayment`), each surfaced as a top-level member keyed by its
231+
/// extension identifier. An empty map emits nothing.
232+
#[serde(flatten)]
233+
pub unsigned_extension_outputs: serde_json::Map<String, serde_json::Value>,
228234
}
229235

230236
/// Credential properties extension output.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! JSON-safe conversion of arbitrary CBOR values.
2+
//!
3+
//! Used to surface authenticator-provided maps that have no typed model, such
4+
//! as the getAssertion `unsignedExtensionOutputs` map (response member 0x08),
5+
//! into WebAuthn client extension results. Per the WebAuthn JSON convention,
6+
//! binary (CBOR byte string) values are encoded as base64url.
7+
8+
use std::collections::BTreeMap;
9+
10+
use serde_json::{Map, Number, Value as Json};
11+
12+
use super::Value;
13+
14+
/// Converts a CBOR map into a JSON object, dropping entries whose key cannot be
15+
/// represented as a JSON string.
16+
pub(crate) fn map_to_json_object(map: &BTreeMap<Value, Value>) -> Map<String, Json> {
17+
map.iter()
18+
.filter_map(|(k, v)| key_to_string(k).map(|k| (k, value_to_json(v))))
19+
.collect()
20+
}
21+
22+
/// Converts an arbitrary CBOR value into a JSON-safe value.
23+
fn value_to_json(value: &Value) -> Json {
24+
match value {
25+
Value::Null => Json::Null,
26+
Value::Bool(b) => Json::Bool(*b),
27+
Value::Integer(i) => integer_to_json(*i),
28+
Value::Float(f) => Number::from_f64(*f).map(Json::Number).unwrap_or(Json::Null),
29+
Value::Bytes(b) => Json::String(base64_url::encode(b)),
30+
Value::Text(s) => Json::String(s.clone()),
31+
Value::Array(items) => Json::Array(items.iter().map(value_to_json).collect()),
32+
Value::Map(m) => Json::Object(map_to_json_object(m)),
33+
// Tags carry no WebAuthn meaning here; surface the tagged value itself.
34+
Value::Tag(_, inner) => value_to_json(inner),
35+
// Non-exhaustive enum: anything else has no JSON representation.
36+
_ => Json::Null,
37+
}
38+
}
39+
40+
/// CBOR integers span -2^64..2^64-1, wider than any single JSON number type.
41+
/// Map what fits into `i64`/`u64`; fall back to a decimal string otherwise.
42+
fn integer_to_json(i: i128) -> Json {
43+
if let Ok(n) = i64::try_from(i) {
44+
Json::Number(n.into())
45+
} else if let Ok(n) = u64::try_from(i) {
46+
Json::Number(n.into())
47+
} else {
48+
Json::String(i.to_string())
49+
}
50+
}
51+
52+
/// JSON object keys must be strings. Extension output maps use text keys; other
53+
/// scalar key types are stringified best-effort, and unsupported keys are
54+
/// dropped by the caller.
55+
fn key_to_string(key: &Value) -> Option<String> {
56+
match key {
57+
Value::Text(s) => Some(s.clone()),
58+
Value::Integer(i) => Some(i.to_string()),
59+
Value::Bytes(b) => Some(base64_url::encode(b)),
60+
Value::Bool(b) => Some(b.to_string()),
61+
_ => None,
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::*;
68+
69+
#[test]
70+
fn scalars_round_trip() {
71+
assert_eq!(value_to_json(&Value::Bool(true)), Json::Bool(true));
72+
assert_eq!(value_to_json(&Value::Null), Json::Null);
73+
assert_eq!(value_to_json(&Value::Text("hi".into())), Json::from("hi"));
74+
assert_eq!(value_to_json(&Value::Integer(42)), Json::from(42));
75+
assert_eq!(value_to_json(&Value::Integer(-7)), Json::from(-7));
76+
}
77+
78+
#[test]
79+
fn bytes_become_base64url() {
80+
let value = Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]);
81+
assert_eq!(
82+
value_to_json(&value),
83+
Json::from(base64_url::encode(&[0xDE, 0xAD, 0xBE, 0xEF]))
84+
);
85+
}
86+
87+
#[test]
88+
fn nested_array_and_map() {
89+
let mut inner = BTreeMap::new();
90+
inner.insert(Value::Text("blob".into()), Value::Bytes(vec![0x01, 0x02]));
91+
let value = Value::Array(vec![Value::Integer(1), Value::Map(inner)]);
92+
93+
let json = value_to_json(&value);
94+
let arr = json.as_array().unwrap();
95+
assert_eq!(arr[0], Json::from(1));
96+
assert_eq!(
97+
arr[1]["blob"],
98+
Json::from(base64_url::encode(&[0x01, 0x02]))
99+
);
100+
}
101+
102+
#[test]
103+
fn tag_surfaces_inner_value() {
104+
let value = Value::Tag(42, Box::new(Value::Text("tagged".into())));
105+
assert_eq!(value_to_json(&value), Json::from("tagged"));
106+
}
107+
108+
#[test]
109+
fn out_of_range_integer_becomes_string() {
110+
let big = i128::from(u64::MAX) + 1;
111+
assert_eq!(
112+
value_to_json(&Value::Integer(big)),
113+
Json::from(big.to_string())
114+
);
115+
}
116+
117+
#[test]
118+
fn non_finite_float_becomes_null() {
119+
assert_eq!(value_to_json(&Value::Float(f64::NAN)), Json::Null);
120+
assert_eq!(value_to_json(&Value::Float(1.5)), Json::from(1.5));
121+
}
122+
123+
#[test]
124+
fn non_string_keys_are_dropped() {
125+
let mut map = BTreeMap::new();
126+
map.insert(Value::Text("ok".into()), Value::Bool(true));
127+
map.insert(Value::Array(vec![Value::Integer(1)]), Value::Bool(false));
128+
129+
let object = map_to_json_object(&map);
130+
assert_eq!(object.len(), 1);
131+
assert_eq!(object["ok"], Json::Bool(true));
132+
}
133+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
mod json;
12
mod request;
23
mod response;
34
mod serde;
45

6+
pub(crate) use json::map_to_json_object;
57
pub use request::CborRequest;
68
pub use response::CborResponse;
79
pub(crate) use serde::{from_cursor, from_slice, to_vec, CborError, Value};

libwebauthn/src/proto/ctap2/model/get_assertion.rs

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, PrfInputValue, PrfOutputValue,
88
},
99
pin::PinUvAuthProtocol,
10-
proto::ctap2::cbor::Value,
10+
proto::ctap2::cbor::{map_to_json_object, Value},
1111
transport::AuthTokenData,
1212
webauthn::{Error, PlatformError},
1313
};
@@ -466,11 +466,24 @@ impl Ctap2GetAssertionResponse {
466466
request: &GetAssertionRequest,
467467
auth_data: Option<&AuthTokenData>,
468468
) -> Assertion {
469-
let unsigned_extensions_output = self
469+
let mut unsigned_extensions_output = self
470470
.authenticator_data
471471
.extensions
472472
.as_ref()
473-
.map(|x| x.to_unsigned_extensions(request, &self, auth_data));
473+
.map(|x| x.to_unsigned_extensions(request, auth_data));
474+
475+
// unsignedExtensionOutputs (response 0x08) are independent of the signed
476+
// authenticator-data extensions, so surface them even when no signed
477+
// extensions are present. CTAP 2.2: an empty map equals an omitted field.
478+
if let Some(map) = &self.unsigned_extension_outputs {
479+
let object = map_to_json_object(map);
480+
if !object.is_empty() {
481+
unsigned_extensions_output
482+
.get_or_insert_with(Default::default)
483+
.unsigned_extension_outputs = object;
484+
}
485+
}
486+
474487
// CTAP2 6.2.2: authenticators may omit credential ID when the allow list has one entry.
475488
// We always return it, for convenience.
476489
let credential_id = self.credential_id.or_else(|| {
@@ -512,7 +525,6 @@ impl Ctap2GetAssertionResponseExtensions {
512525
pub(crate) fn to_unsigned_extensions(
513526
&self,
514527
request: &GetAssertionRequest,
515-
_response: &Ctap2GetAssertionResponse,
516528
auth_data: Option<&AuthTokenData>,
517529
) -> GetAssertionResponseUnsignedExtensions {
518530
let decrypted_hmac = self.hmac_secret.as_ref().and_then(|x| {
@@ -566,6 +578,7 @@ impl Ctap2GetAssertionResponseExtensions {
566578
large_blob,
567579
prf,
568580
appid,
581+
unsigned_extension_outputs: Default::default(),
569582
}
570583
}
571584
}
@@ -711,4 +724,49 @@ mod tests {
711724

712725
assert_eq!(parsed.unsigned_extension_outputs, Some(ueo));
713726
}
727+
728+
#[test]
729+
fn surfaces_unsigned_extension_outputs_in_client_extension_results() {
730+
use crate::ops::webauthn::idl::response::{JsonFormat, WebAuthnIDLResponse};
731+
732+
// End-to-end: a getAssertion response carrying unsignedExtensionOutputs
733+
// (0x08) with a boolean (thirdPartyPayment) and a byte string is decoded
734+
// and surfaced into the client extension results JSON, with bytes encoded
735+
// as base64url.
736+
let mut auth_data = vec![0u8; 37];
737+
auth_data[32] = AuthenticatorDataFlags::USER_PRESENT.bits();
738+
739+
let mut ueo = BTreeMap::new();
740+
ueo.insert(
741+
Value::Text("thirdPartyPayment".to_string()),
742+
Value::Bool(true),
743+
);
744+
ueo.insert(
745+
Value::Text("blobby".to_string()),
746+
Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]),
747+
);
748+
749+
let mut response: BTreeMap<u64, Value> = BTreeMap::new();
750+
response.insert(0x02, Value::Bytes(auth_data));
751+
response.insert(0x03, Value::Bytes(vec![0xAAu8; 64]));
752+
response.insert(0x08, Value::Map(ueo));
753+
754+
let bytes = crate::proto::ctap2::cbor::to_vec(&response).unwrap();
755+
let parsed: Ctap2GetAssertionResponse =
756+
crate::proto::ctap2::cbor::from_slice(&bytes).unwrap();
757+
758+
let request = make_request(vec![make_credential(b"cred-1")]);
759+
let assertion = parsed.into_assertion_output(&request, None);
760+
let json_str = assertion
761+
.to_json_string(&request, JsonFormat::default())
762+
.unwrap();
763+
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
764+
765+
let results = &json["clientExtensionResults"];
766+
assert_eq!(results["thirdPartyPayment"], serde_json::json!(true));
767+
assert_eq!(
768+
results["blobby"],
769+
serde_json::json!(base64_url::encode(&[0xDE, 0xAD, 0xBE, 0xEF]))
770+
);
771+
}
714772
}

0 commit comments

Comments
 (0)