webauthn_rp

WebAuthn RP library.
git clone https://git.philomathiclife.com/repos/webauthn_rp
Log | Files | Refs | README

tests.rs (20448B)


      1 use super::{ClientDataJsonParser as _, Cow, RelaxedClientDataJsonParser};
      2 use serde::de::{Error as _, Unexpected};
      3 use serde_json::Error;
      4 #[expect(clippy::unwrap_used, reason = "OK in tests")]
      5 #[expect(clippy::little_endian_bytes, reason = "comments justify correctness")]
      6 #[expect(clippy::too_many_lines, reason = "a lot to test")]
      7 #[test]
      8 fn relaxed_client_data_json() {
      9     // Base case is correct.
     10     let mut input = serde_json::json!({
     11         "challenge": "ABABABABABABABABABABAA",
     12         "type": "webauthn.create",
     13         "origin": "https://example.com",
     14         "crossOrigin": true,
     15         "topOrigin": "https://example.org"
     16     })
     17     .to_string();
     18     assert!(
     19         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).is_ok_and(|c| {
     20             c.cross_origin
     21                 && c.challenge.0
     22                 // challenges are sent little-endian
     23                     == u128::from_le_bytes([
     24                         0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
     25                     ])
     26                 && matches!(c.origin.0, Cow::Borrowed(o) if o == "https://example.com")
     27                 && c.top_origin
     28                     .is_some_and(|t| matches!(t.0, Cow::Borrowed(o) if o == "https://example.org"))
     29         })
     30     );
     31     // Base case is correct.
     32     input = serde_json::json!({
     33         "challenge": "ABABABABABABABABABABAA",
     34         "type": "webauthn.get",
     35         "origin": "https://example.com",
     36         "crossOrigin": true,
     37         "topOrigin": "https://example.org"
     38     })
     39     .to_string();
     40     assert!(
     41         RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).is_ok_and(|c| {
     42             c.cross_origin
     43                 && c.challenge.0
     44                 // challenges are sent little-endian
     45                     == u128::from_le_bytes([
     46                         0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
     47                     ])
     48                 && matches!(c.origin.0, Cow::Borrowed(o) if o == "https://example.com")
     49                 && c.top_origin
     50                     .is_some_and(|t| matches!(t.0, Cow::Borrowed(o) if o == "https://example.org"))
     51         })
     52     );
     53     // Unknown keys are allowed.
     54     input = serde_json::json!({
     55         "challenge": "ABABABABABABABABABABAA",
     56         "type": "webauthn.create",
     57         "origin": "https://example.com",
     58         "crossOrigin": true,
     59         "topOrigin": "https://example.org",
     60         "foo": true
     61     })
     62     .to_string();
     63     drop(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).unwrap());
     64     // Duplicate keys are forbidden.
     65     let mut input_str = "{
     66         \"challenge\": \"ABABABABABABABABABABAA\",
     67         \"type\": \"webauthn.create\",
     68         \"origin\": \"https://example.com\",
     69         \"crossOrigin\": true,
     70         \"topOrigin\": \"https://example.org\",
     71         \"crossOrigin\": true
     72     }";
     73     let mut err = Error::duplicate_field("crossOrigin")
     74         .to_string()
     75         .into_bytes();
     76     assert_eq!(
     77         RelaxedClientDataJsonParser::<true>::parse(input_str.as_bytes())
     78             .unwrap_err()
     79             .to_string()
     80             .into_bytes()
     81             .get(..err.len()),
     82         Some(err.as_slice())
     83     );
     84     // `null` `crossOrigin`.
     85     input = serde_json::json!({
     86         "challenge": "ABABABABABABABABABABAA",
     87         "type": "webauthn.create",
     88         "origin": "https://example.com",
     89         "crossOrigin": null,
     90         "topOrigin": "https://example.org"
     91     })
     92     .to_string();
     93     assert!(
     94         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).is_ok_and(|c| !c.cross_origin)
     95     );
     96     // Missing `crossOrigin`.
     97     input = serde_json::json!({
     98         "challenge": "ABABABABABABABABABABAA",
     99         "type": "webauthn.create",
    100         "origin": "https://example.com",
    101         "topOrigin": "https://example.org"
    102     })
    103     .to_string();
    104     assert!(
    105         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).is_ok_and(|c| !c.cross_origin)
    106     );
    107     // `null` `topOrigin`.
    108     input = serde_json::json!({
    109         "challenge": "ABABABABABABABABABABAA",
    110         "type": "webauthn.create",
    111         "origin": "https://example.com",
    112         "crossOrigin": true,
    113         "topOrigin": null
    114     })
    115     .to_string();
    116     assert!(
    117         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    118             .is_ok_and(|c| c.top_origin.is_none())
    119     );
    120     // Missing `topOrigin`.
    121     input = serde_json::json!({
    122         "challenge": "ABABABABABABABABABABAA",
    123         "type": "webauthn.create",
    124         "origin": "https://example.com",
    125         "crossOrigin": true,
    126     })
    127     .to_string();
    128     assert!(
    129         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    130             .is_ok_and(|c| c.top_origin.is_none())
    131     );
    132     // `null` `challenge`.
    133     err = Error::invalid_type(
    134         Unexpected::Other("null"),
    135         &"base64 encoding of the 16-byte challenge in a URL safe way without padding",
    136     )
    137     .to_string()
    138     .into_bytes();
    139     input = serde_json::json!({
    140         "challenge": null,
    141         "type": "webauthn.create",
    142         "origin": "https://example.com",
    143         "crossOrigin": true,
    144         "topOrigin": "https://example.org"
    145     })
    146     .to_string();
    147     assert_eq!(
    148         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    149             .unwrap_err()
    150             .to_string()
    151             .into_bytes()
    152             .get(..err.len()),
    153         Some(err.as_slice())
    154     );
    155     // Missing `challenge`.
    156     err = Error::missing_field("challenge").to_string().into_bytes();
    157     input = serde_json::json!({
    158         "type": "webauthn.create",
    159         "origin": "https://example.com",
    160         "crossOrigin": true,
    161         "topOrigin": "https://example.org"
    162     })
    163     .to_string();
    164     assert_eq!(
    165         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    166             .unwrap_err()
    167             .to_string()
    168             .into_bytes()
    169             .get(..err.len()),
    170         Some(err.as_slice())
    171     );
    172     // `null` `type`.
    173     err = Error::invalid_type(
    174         Unexpected::Other("null"),
    175         &"'webauthn.create' or 'webauthn.get'",
    176     )
    177     .to_string()
    178     .into_bytes();
    179     input = serde_json::json!({
    180         "challenge": "ABABABABABABABABABABAA",
    181         "type": null,
    182         "origin": "https://example.com",
    183         "crossOrigin": true,
    184         "topOrigin": "https://example.org"
    185     })
    186     .to_string();
    187     assert_eq!(
    188         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    189             .unwrap_err()
    190             .to_string()
    191             .into_bytes()
    192             .get(..err.len()),
    193         Some(err.as_slice())
    194     );
    195     // Missing `type`.
    196     err = Error::missing_field("type").to_string().into_bytes();
    197     input = serde_json::json!({
    198         "challenge": "ABABABABABABABABABABAA",
    199         "origin": "https://example.com",
    200         "crossOrigin": true,
    201         "topOrigin": "https://example.org"
    202     })
    203     .to_string();
    204     assert_eq!(
    205         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    206             .unwrap_err()
    207             .to_string()
    208             .into_bytes()
    209             .get(..err.len()),
    210         Some(err.as_slice())
    211     );
    212     // `null` `origin`.
    213     err = Error::invalid_type(Unexpected::Other("null"), &"OriginWrapper")
    214         .to_string()
    215         .into_bytes();
    216     input = serde_json::json!({
    217         "challenge": "ABABABABABABABABABABAA",
    218         "type": "webauthn.get",
    219         "origin": null,
    220         "crossOrigin": true,
    221         "topOrigin": "https://example.org"
    222     })
    223     .to_string();
    224     assert_eq!(
    225         RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    226             .unwrap_err()
    227             .to_string()
    228             .into_bytes()
    229             .get(..err.len()),
    230         Some(err.as_slice())
    231     );
    232     // Missing `origin`.
    233     err = Error::missing_field("origin").to_string().into_bytes();
    234     input = serde_json::json!({
    235         "challenge": "ABABABABABABABABABABAA",
    236         "type": "webauthn.get",
    237         "crossOrigin": true,
    238         "topOrigin": "https://example.org"
    239     })
    240     .to_string();
    241     assert_eq!(
    242         RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    243             .unwrap_err()
    244             .to_string()
    245             .into_bytes()
    246             .get(..err.len()),
    247         Some(err.as_slice())
    248     );
    249     // Mismatched `type`.
    250     err = Error::invalid_value(Unexpected::Str("webauthn.create"), &"webauthn.get")
    251         .to_string()
    252         .into_bytes();
    253     input = serde_json::json!({
    254         "challenge": "ABABABABABABABABABABAA",
    255         "type": "webauthn.create",
    256         "origin": "https://example.com",
    257         "crossOrigin": true,
    258         "topOrigin": "https://example.org"
    259     })
    260     .to_string();
    261     assert_eq!(
    262         RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    263             .unwrap_err()
    264             .to_string()
    265             .into_bytes()
    266             .get(..err.len()),
    267         Some(err.as_slice())
    268     );
    269     // Mismatched `type`.
    270     err = Error::invalid_value(Unexpected::Str("webauthn.get"), &"webauthn.create")
    271         .to_string()
    272         .into_bytes();
    273     input = serde_json::json!({
    274         "challenge": "ABABABABABABABABABABAA",
    275         "type": "webauthn.get",
    276         "origin": "https://example.com",
    277         "crossOrigin": true,
    278         "topOrigin": "https://example.org"
    279     })
    280     .to_string();
    281     assert_eq!(
    282         RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    283             .unwrap_err()
    284             .to_string()
    285             .into_bytes()
    286             .get(..err.len()),
    287         Some(err.as_slice())
    288     );
    289     // `crossOrigin` can be `false` even when `topOrigin` exists.
    290     input = serde_json::json!({
    291         "challenge": "ABABABABABABABABABABAA",
    292         "type": "webauthn.get",
    293         "origin": "https://example.com",
    294         "crossOrigin": false,
    295         "topOrigin": "https://example.org"
    296     })
    297     .to_string();
    298     drop(RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).unwrap());
    299     // `crossOrigin` can be `true` even when `topOrigin` does not exist.
    300     input = serde_json::json!({
    301         "challenge": "ABABABABABABABABABABAA",
    302         "type": "webauthn.get",
    303         "origin": "https://example.com",
    304         "crossOrigin": true,
    305     })
    306     .to_string();
    307     drop(RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).unwrap());
    308     // BOM is removed.
    309     input_str = "\u{feff}{
    310         \"challenge\": \"ABABABABABABABABABABAA\",
    311         \"type\": \"webauthn.create\",
    312         \"origin\": \"https://example.com\",
    313         \"crossOrigin\": true,
    314         \"topOrigin\": \"https://example.org\"
    315     }";
    316     drop(RelaxedClientDataJsonParser::<true>::parse(input_str.as_bytes()).unwrap());
    317     // Invalid Unicode is replaced.
    318     let mut input_bytes = b"{
    319         \"challenge\": \"ABABABABABABABABABABAA\",
    320         \"type\": \"webauthn.create\",
    321         \"origin\": \"https://\xffexample.com\",
    322         \"crossOrigin\": true,
    323         \"topOrigin\": \"https://example.org\"
    324     }"
    325     .as_slice();
    326     assert!(
    327         RelaxedClientDataJsonParser::<true>::parse(input_bytes).is_ok_and(|c| {
    328             matches!(c.origin.0, Cow::Owned(o) if o == "https://\u{fffd}example.com")
    329         })
    330     );
    331     // Escape characters are de-escaped.
    332     input_bytes = b"{
    333         \"challenge\": \"ABABABABABABABABABABAA\",
    334         \"type\": \"webauthn\\u002ecreate\",
    335         \"origin\": \"https://examp\\\\le.com\",
    336         \"crossOrigin\": true,
    337         \"topOrigin\": \"https://example.org\"
    338     }";
    339     assert!(
    340         RelaxedClientDataJsonParser::<true>::parse(input_bytes)
    341             .is_ok_and(|c| { matches!(c.origin.0, Cow::Owned(o) if o == "https://examp\\le.com") })
    342     );
    343 }
    344 #[expect(clippy::unwrap_used, reason = "OK in tests")]
    345 #[expect(clippy::little_endian_bytes, reason = "comments justify correctness")]
    346 #[expect(clippy::too_many_lines, reason = "a lot to test")]
    347 #[test]
    348 fn relaxed_challenge() {
    349     // Base case is correct.
    350     let mut input = serde_json::json!({
    351         "challenge": "ABABABABABABABABABABAA",
    352         "type": "webauthn.create",
    353         "origin": "https://example.com",
    354         "crossOrigin": true,
    355         "topOrigin": "https://example.org"
    356     })
    357     .to_string();
    358     assert!(
    359         RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok_and(|c| {
    360             // `Challenges` are sent in little-endian.
    361             c.0 == u128::from_le_bytes([0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0])
    362         })
    363     );
    364     // Base case is correct.
    365     input = serde_json::json!({
    366         "challenge": "ABABABABABABABABABABAA",
    367         "type": "webauthn.get",
    368         "origin": "https://example.com",
    369         "crossOrigin": true,
    370         "topOrigin": "https://example.org"
    371     })
    372     .to_string();
    373     assert!(
    374         RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok_and(|c| {
    375             // `Challenges` are sent in little-endian.
    376             c.0 == u128::from_le_bytes([0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0])
    377         })
    378     );
    379     // Unknown keys are allowed.
    380     input = serde_json::json!({
    381         "challenge": "ABABABABABABABABABABAA",
    382         "type": "webauthn.create",
    383         "origin": "https://example.com",
    384         "crossOrigin": true,
    385         "topOrigin": "https://example.org",
    386         "foo": true
    387     })
    388     .to_string();
    389     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    390     // Duplicate keys are ignored.
    391     let mut input_str = "{
    392         \"challenge\": \"ABABABABABABABABABABAA\",
    393         \"type\": \"webauthn.create\",
    394         \"origin\": \"https://example.com\",
    395         \"crossOrigin\": true,
    396         \"topOrigin\": \"https://example.org\",
    397         \"crossOrigin\": true
    398     }";
    399     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_str.as_bytes()).unwrap();
    400     // `null` `crossOrigin`.
    401     input = serde_json::json!({
    402         "challenge": "ABABABABABABABABABABAA",
    403         "type": "webauthn.create",
    404         "origin": "https://example.com",
    405         "crossOrigin": null,
    406         "topOrigin": "https://example.org"
    407     })
    408     .to_string();
    409     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    410     // Missing `crossOrigin`.
    411     input = serde_json::json!({
    412         "challenge": "ABABABABABABABABABABAA",
    413         "type": "webauthn.create",
    414         "origin": "https://example.com",
    415         "topOrigin": "https://example.org"
    416     })
    417     .to_string();
    418     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    419     // `null` `topOrigin`.
    420     input = serde_json::json!({
    421         "challenge": "ABABABABABABABABABABAA",
    422         "type": "webauthn.create",
    423         "origin": "https://example.com",
    424         "crossOrigin": true,
    425         "topOrigin": null
    426     })
    427     .to_string();
    428     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    429     // Missing `topOrigin`.
    430     input = serde_json::json!({
    431         "challenge": "ABABABABABABABABABABAA",
    432         "type": "webauthn.create",
    433         "origin": "https://example.com",
    434         "crossOrigin": true,
    435     })
    436     .to_string();
    437     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    438     // `null` `challenge`.
    439     let mut err = Error::invalid_type(
    440         Unexpected::Other("null"),
    441         &"base64 encoding of the 16-byte challenge in a URL safe way without padding",
    442     )
    443     .to_string()
    444     .into_bytes();
    445     input = serde_json::json!({
    446         "challenge": null,
    447         "type": "webauthn.create",
    448         "origin": "https://example.com",
    449         "crossOrigin": true,
    450         "topOrigin": "https://example.org"
    451     })
    452     .to_string();
    453     assert_eq!(
    454         RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes())
    455             .unwrap_err()
    456             .to_string()
    457             .into_bytes()
    458             .get(..err.len()),
    459         Some(err.as_slice())
    460     );
    461     // Missing `challenge`.
    462     err = Error::missing_field("challenge").to_string().into_bytes();
    463     input = serde_json::json!({
    464         "type": "webauthn.create",
    465         "origin": "https://example.com",
    466         "crossOrigin": true,
    467         "topOrigin": "https://example.org"
    468     })
    469     .to_string();
    470     assert_eq!(
    471         RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes())
    472             .unwrap_err()
    473             .to_string()
    474             .into_bytes()
    475             .get(..err.len()),
    476         Some(err.as_slice())
    477     );
    478     // `null` `type`.
    479     input = serde_json::json!({
    480         "challenge": "ABABABABABABABABABABAA",
    481         "type": null,
    482         "origin": "https://example.com",
    483         "crossOrigin": true,
    484         "topOrigin": "https://example.org"
    485     })
    486     .to_string();
    487     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    488     // Missing `type`.
    489     input = serde_json::json!({
    490         "challenge": "ABABABABABABABABABABAA",
    491         "origin": "https://example.com",
    492         "crossOrigin": true,
    493         "topOrigin": "https://example.org"
    494     })
    495     .to_string();
    496     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    497     // `null` `origin`.
    498     input = serde_json::json!({
    499         "challenge": "ABABABABABABABABABABAA",
    500         "type": "webauthn.get",
    501         "origin": null,
    502         "crossOrigin": true,
    503         "topOrigin": "https://example.org"
    504     })
    505     .to_string();
    506     _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    507     // Missing `origin`.
    508     input = serde_json::json!({
    509         "challenge": "ABABABABABABABABABABAA",
    510         "type": "webauthn.get",
    511         "crossOrigin": true,
    512         "topOrigin": "https://example.org"
    513     })
    514     .to_string();
    515     _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    516     // Mismatched `type`.
    517     input = serde_json::json!({
    518         "challenge": "ABABABABABABABABABABAA",
    519         "type": "webauthn.create",
    520         "origin": "https://example.com",
    521         "crossOrigin": true,
    522         "topOrigin": "https://example.org"
    523     })
    524     .to_string();
    525     _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    526     // Mismatched `type`.
    527     input = serde_json::json!({
    528         "challenge": "ABABABABABABABABABABAA",
    529         "type": "webauthn.get",
    530         "origin": "https://example.com",
    531         "crossOrigin": true,
    532         "topOrigin": "https://example.org"
    533     })
    534     .to_string();
    535     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    536     // `crossOrigin` can be `false` even when `topOrigin` exists.
    537     input = serde_json::json!({
    538         "challenge": "ABABABABABABABABABABAA",
    539         "type": "webauthn.get",
    540         "origin": "https://example.com",
    541         "crossOrigin": false,
    542         "topOrigin": "https://example.org"
    543     })
    544     .to_string();
    545     _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    546     // `crossOrigin` can be `true` even when `topOrigin` does not exist.
    547     input = serde_json::json!({
    548         "challenge": "ABABABABABABABABABABAA",
    549         "type": "webauthn.get",
    550         "origin": "https://example.com",
    551         "crossOrigin": true,
    552     })
    553     .to_string();
    554     _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    555     // BOM is removed.
    556     input_str = "\u{feff}{
    557         \"challenge\": \"ABABABABABABABABABABAA\",
    558         \"type\": \"webauthn.create\",
    559         \"origin\": \"https://example.com\",
    560         \"crossOrigin\": true,
    561         \"topOrigin\": \"https://example.org\"
    562     }";
    563     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_str.as_bytes()).unwrap();
    564     // Invalid Unicode is replaced.
    565     let mut input_bytes = b"{
    566         \"challenge\": \"ABABABABABABABABABABAA\",
    567         \"type\": \"webauthn.create\",
    568         \"origin\": \"https://\xffexample.com\",
    569         \"crossOrigin\": true,
    570         \"topOrigin\": \"https://example.org\"
    571     }"
    572     .as_slice();
    573     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_bytes).unwrap();
    574     // Escape characters are de-escaped.
    575     input_bytes = b"{
    576         \"challenge\": \"ABABABABABABABABABABAA\",
    577         \"type\": \"webauthn\\u002ecreate\",
    578         \"origin\": \"https://examp\\\\le.com\",
    579         \"crossOrigin\": true,
    580         \"topOrigin\": \"https://example.org\"
    581     }";
    582     _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_bytes).unwrap();
    583 }