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 }