1use anyhow::{anyhow, bail, Context as _, Result};
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use base64::Engine;
34use serde_json::{json, Map, Value};
35
36use super::jwt;
37use super::keys::SigningKey;
38
39const NONCE_CHALLENGE: &str = "use_dpop_nonce";
47
48fn new_jti() -> String {
51 let mut bytes = [0u8; 16];
52 getrandom::fill(&mut bytes).expect("OS CSPRNG unavailable; refusing to mint a DPoP proof");
53 URL_SAFE_NO_PAD.encode(bytes)
54}
55
56fn is_tchar(b: u8) -> bool {
58 b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
59}
60
61fn htu(url: &str) -> Result<String> {
72 let mut parsed = url::Url::parse(url).with_context(|| format!("not a valid URL {url:?}"))?;
73 if !matches!(parsed.scheme(), "http" | "https") {
74 bail!("DPoP target must be http(s), got {:?}", parsed.scheme());
75 }
76 parsed
77 .set_username("")
78 .map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
79 parsed
80 .set_password(None)
81 .map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
82 parsed.set_query(None);
83 parsed.set_fragment(None);
84 Ok(parsed.to_string())
85}
86
87fn embedded_public_jwk(key: &SigningKey) -> Result<Value> {
94 let full = key.public_jwk()?;
95 let mut minimal = Map::new();
96 for name in ["kty", "crv", "x", "y"] {
97 let value = full
98 .get(name)
99 .cloned()
100 .with_context(|| format!("public JWK is missing `{name}`"))?;
101 minimal.insert(name.to_string(), value);
102 }
103 Ok(Value::Object(minimal))
104}
105
106pub fn proof(
115 key: &SigningKey,
116 method: &str,
117 url: &str,
118 access_token: Option<&str>,
119 nonce: Option<&str>,
120) -> Result<String> {
121 let header = json!({
122 "typ": "dpop+jwt",
123 "alg": "ES256",
124 "jwk": embedded_public_jwk(key)?,
125 });
126
127 if method.is_empty() || !method.bytes().all(is_tchar) {
128 bail!("{method:?} is not a valid HTTP method token");
129 }
130
131 let mut claims = Map::new();
132 claims.insert("jti".into(), json!(new_jti()));
133 claims.insert("htm".into(), json!(method.to_ascii_uppercase()));
134 claims.insert("htu".into(), json!(htu(url)?));
135 claims.insert("iat".into(), json!(chrono::Utc::now().timestamp()));
136 if let Some(token) = access_token {
137 let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
138 claims.insert("ath".into(), json!(URL_SAFE_NO_PAD.encode(digest.as_ref())));
139 }
140 if let Some(nonce) = nonce {
141 claims.insert("nonce".into(), json!(nonce));
142 }
143
144 jwt::sign(key, &header, &Value::Object(claims))
145}
146
147struct Challenge {
149 scheme: String,
150 params: Vec<(String, String)>,
151}
152
153fn split_segments(header: &str) -> Option<Vec<String>> {
159 let mut out = Vec::new();
160 let mut current = String::new();
161 let mut chars = header.chars();
162 let mut in_quotes = false;
163
164 while let Some(c) = chars.next() {
165 if in_quotes {
166 match c {
167 '\\' => {
169 current.push('\\');
170 current.push(chars.next()?);
171 }
172 '"' => {
173 in_quotes = false;
174 current.push(c);
175 }
176 _ => current.push(c),
177 }
178 } else {
179 match c {
180 '"' => {
181 in_quotes = true;
182 current.push(c);
183 }
184 ',' => out.push(std::mem::take(&mut current)),
185 _ => current.push(c),
186 }
187 }
188 }
189 if in_quotes {
190 return None;
191 }
192 out.push(current);
193 Some(out)
194}
195
196fn first_unquoted_eq(s: &str) -> Option<usize> {
198 let mut in_quotes = false;
199 let mut escaped = false;
200 for (i, c) in s.char_indices() {
201 if in_quotes {
202 if escaped {
203 escaped = false;
204 } else if c == '\\' {
205 escaped = true;
206 } else if c == '"' {
207 in_quotes = false;
208 }
209 } else if c == '"' {
210 in_quotes = true;
211 } else if c == '=' {
212 return Some(i);
213 }
214 }
215 None
216}
217
218fn unquote(raw: &str) -> Option<String> {
221 let Some(inner) = raw.strip_prefix('"') else {
222 if raw.is_empty() || raw.contains('"') || raw.chars().any(char::is_whitespace) {
224 return None;
225 }
226 return Some(raw.to_string());
227 };
228 let inner = inner.strip_suffix('"')?;
229 let mut out = String::new();
230 let mut chars = inner.chars();
231 while let Some(c) = chars.next() {
232 match c {
233 '\\' => out.push(chars.next()?),
234 '"' => return None,
237 _ => out.push(c),
238 }
239 }
240 Some(out)
241}
242
243fn parse_challenges(header: &str) -> Option<Vec<Challenge>> {
257 let mut challenges: Vec<Challenge> = Vec::new();
258
259 for segment in split_segments(header)? {
260 let segment = segment.trim();
261 if segment.is_empty() {
262 continue;
263 }
264 let Some(eq) = first_unquoted_eq(segment) else {
265 challenges.push(Challenge {
267 scheme: segment.to_string(),
268 params: Vec::new(),
269 });
270 continue;
271 };
272 let left = segment[..eq].trim();
273 let Some(value) = unquote(segment[eq + 1..].trim()) else {
274 if let Some((scheme, _)) = left.split_once(char::is_whitespace) {
287 challenges.push(Challenge {
288 scheme: scheme.trim().to_string(),
289 params: Vec::new(),
290 });
291 }
292 continue;
293 };
294
295 match left.split_once(char::is_whitespace) {
296 Some((scheme, name)) => challenges.push(Challenge {
297 scheme: scheme.trim().to_string(),
298 params: vec![(name.trim().to_ascii_lowercase(), value)],
299 }),
300 None => challenges
302 .last_mut()?
303 .params
304 .push((left.to_ascii_lowercase(), value)),
305 }
306 }
307 Some(challenges)
308}
309
310#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum Endpoint {
316 AuthorizationServer,
318 ResourceServer,
320}
321
322fn body_asks_for_nonce(body: &[u8]) -> bool {
327 if !super::error_body_worth_parsing(body) {
328 return false;
329 }
330 serde_json::from_slice::<Value>(body)
331 .ok()
332 .as_ref()
333 .and_then(|v| v.get("error"))
334 .and_then(Value::as_str)
335 == Some(NONCE_CHALLENGE)
336}
337
338fn header_asks_for_nonce(www_authenticate: &str) -> bool {
340 let Some(challenges) = parse_challenges(www_authenticate) else {
341 return false;
342 };
343 challenges.iter().any(|c| {
344 c.scheme.eq_ignore_ascii_case("DPoP")
345 && c.params
346 .iter()
347 .any(|(name, value)| name == "error" && value == NONCE_CHALLENGE)
348 })
349}
350
351pub fn nonce_challenge(
375 endpoint: Endpoint,
376 status: u16,
377 www_authenticate: Option<&str>,
378 body: &[u8],
379 dpop_nonce: Option<&str>,
380) -> Option<String> {
381 let nonce = dpop_nonce.filter(|n| !n.is_empty())?;
382 let asked = match endpoint {
383 Endpoint::AuthorizationServer => status == 400 && body_asks_for_nonce(body),
384 Endpoint::ResourceServer => {
385 status == 401 && www_authenticate.is_some_and(header_asks_for_nonce)
386 }
387 };
388 asked.then(|| nonce.to_string())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::oauth::jwt::verify;
395
396 const KID: &str = "dpop-1";
397
398 fn parts(jws: &str) -> (Value, Value) {
399 let seg: Vec<&str> = jws.split('.').collect();
400 (
401 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[0]).unwrap()).unwrap(),
402 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[1]).unwrap()).unwrap(),
403 )
404 }
405
406 #[test]
409 fn the_proof_header_is_a_dpop_jwt_with_an_embedded_public_key() {
410 let key = SigningKey::generate(KID);
411 let proof = proof(&key, "POST", "https://bsky.social/oauth/token", None, None).unwrap();
412 let (header, _) = parts(&proof);
413 assert_eq!(header["typ"], "dpop+jwt");
414 assert_eq!(header["alg"], "ES256");
415 assert_eq!(header["jwk"]["kty"], "EC");
416 assert_eq!(header["jwk"]["crv"], "P-256");
417 assert!(header["jwk"]["x"].is_string());
418 assert!(header["jwk"]["y"].is_string());
419 }
420
421 #[test]
424 fn the_embedded_jwk_never_carries_the_private_scalar() {
425 let key = SigningKey::generate(KID);
426 let proof = proof(&key, "GET", "https://bsky.social/xrpc/x", None, None).unwrap();
427 let (header, _) = parts(&proof);
428 assert!(
429 header["jwk"].get("d").is_none(),
430 "private scalar in DPoP header"
431 );
432 let rendered = serde_json::to_string(&header).unwrap();
433 assert!(
434 !rendered.contains("\"d\""),
435 "private scalar in header: {rendered}"
436 );
437 }
438
439 #[test]
442 fn the_embedded_jwk_is_the_minimal_public_key() {
443 let key = SigningKey::generate(KID);
444 let (header, _) = parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
445 let members: Vec<&String> = header["jwk"].as_object().unwrap().keys().collect();
446 assert_eq!(members.len(), 4, "unexpected members: {members:?}");
447 }
448
449 #[test]
453 fn htu_strips_the_query_and_fragment() {
454 let key = SigningKey::generate(KID);
455 for (url, want) in [
456 (
457 "https://bsky.social/oauth/token?a=1&b=2",
458 "https://bsky.social/oauth/token",
459 ),
460 (
461 "https://bsky.social/xrpc/get#frag",
462 "https://bsky.social/xrpc/get",
463 ),
464 ("https://bsky.social/x?q=1#f", "https://bsky.social/x"),
465 ("https://bsky.social/plain", "https://bsky.social/plain"),
466 ] {
467 let (_, claims) = parts(&proof(&key, "GET", url, None, None).unwrap());
468 assert_eq!(claims["htu"], want, "for {url}");
469 }
470 }
471
472 #[test]
473 fn htm_carries_the_method_and_iat_is_current() {
474 let key = SigningKey::generate(KID);
475 let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
476 assert_eq!(claims["htm"], "POST");
477 let now = chrono::Utc::now().timestamp();
478 let iat = claims["iat"].as_i64().unwrap();
479 assert!((now - iat).abs() < 5, "iat {iat} is not close to {now}");
480 }
481
482 #[test]
486 fn every_proof_gets_a_fresh_unpredictable_jti() {
487 let key = SigningKey::generate(KID);
488 let mut seen = std::collections::HashSet::new();
489 for _ in 0..64 {
490 let (_, claims) =
491 parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
492 let jti = claims["jti"].as_str().unwrap().to_string();
493 assert!(jti.len() >= 22, "jti too short to be unguessable: {jti}");
494 assert!(seen.insert(jti), "jti repeated");
495 }
496 }
497
498 #[test]
504 fn ath_is_the_base64url_sha256_of_the_access_token_when_present() {
505 let key = SigningKey::generate(KID);
506 let token = "an-access-token";
507 let (_, claims) =
508 parts(&proof(&key, "GET", "https://x.example/a", Some(token), None).unwrap());
509
510 let want = URL_SAFE_NO_PAD
511 .encode(ring::digest::digest(&ring::digest::SHA256, token.as_bytes()).as_ref());
512 assert_eq!(claims["ath"], want);
513 }
514
515 #[test]
516 fn ath_is_absent_when_there_is_no_access_token() {
517 let key = SigningKey::generate(KID);
518 let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
519 assert!(claims.get("ath").is_none());
520 }
521
522 #[test]
523 fn the_nonce_claim_appears_only_when_the_server_supplied_one() {
524 let key = SigningKey::generate(KID);
525 let (_, without) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
526 assert!(without.get("nonce").is_none());
527
528 let (_, with) =
529 parts(&proof(&key, "POST", "https://x.example/t", None, Some("srv-nonce")).unwrap());
530 assert_eq!(with["nonce"], "srv-nonce");
531 }
532
533 #[test]
534 fn a_proof_verifies_against_its_own_key() {
535 let key = SigningKey::generate(KID);
536 let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
537 assert!(verify(&key, &p).is_ok());
538 assert!(verify(&SigningKey::generate(KID), &p).is_err());
539 }
540
541 #[test]
542 fn an_unparseable_target_url_is_an_error_not_a_panic() {
543 let key = SigningKey::generate(KID);
544 assert!(proof(&key, "GET", "not a url", None, None).is_err());
545 assert!(proof(&key, "GET", "", None, None).is_err());
546 }
547
548 #[test]
551 fn a_non_http_scheme_is_rejected() {
552 let key = SigningKey::generate(KID);
553 for url in [
554 "file:///etc/passwd",
555 "ftp://x.example/a",
556 "data:text/plain,x",
557 ] {
558 assert!(
559 proof(&key, "GET", url, None, None).is_err(),
560 "allowed {url}"
561 );
562 }
563 }
564
565 #[test]
568 fn a_non_token_method_is_rejected() {
569 let key = SigningKey::generate(KID);
570 for method in ["", "gé t", "GET POST", "GET\n", "GE\tT"] {
571 assert!(
572 proof(&key, method, "https://x.example/a", None, None).is_err(),
573 "allowed method {method:?}"
574 );
575 }
576 }
577
578 #[test]
582 fn htu_strips_userinfo() {
583 let key = SigningKey::generate(KID);
584 let (_, claims) = parts(
585 &proof(
586 &key,
587 "POST",
588 "https://Alice:s3cr3t@PDS.Example.COM:443/oauth/token?a=1#f",
589 None,
590 None,
591 )
592 .unwrap(),
593 );
594 let htu = claims["htu"].as_str().unwrap();
595 assert_eq!(htu, "https://pds.example.com/oauth/token");
596 assert!(!htu.contains("s3cr3t"), "password leaked into htu: {htu}");
597 assert!(!htu.contains("Alice"), "username leaked into htu: {htu}");
598 }
599
600 #[test]
603 fn the_embedded_jwk_is_the_key_that_actually_signed() {
604 let key = SigningKey::generate(KID);
605 let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
606 let (header, _) = parts(&p);
607 let embedded = serde_json::to_string(&header["jwk"]).unwrap();
608 assert_eq!(
609 SigningKey::public_thumbprint_of(&embedded).unwrap(),
610 key.thumbprint().unwrap(),
611 "the embedded jwk is not the signing key"
612 );
613 }
614
615 #[test]
616 fn htm_is_upcased() {
617 let key = SigningKey::generate(KID);
618 for (given, want) in [("get", "GET"), ("Post", "POST"), ("delete", "DELETE")] {
619 let (_, claims) =
620 parts(&proof(&key, given, "https://x.example/a", None, None).unwrap());
621 assert_eq!(claims["htm"], want, "for {given}");
622 }
623 }
624
625 fn rs(www_authenticate: &str, nonce: Option<&str>) -> Option<String> {
630 nonce_challenge(
631 Endpoint::ResourceServer,
632 401,
633 Some(www_authenticate),
634 b"",
635 nonce,
636 )
637 }
638
639 #[test]
651 fn the_authorization_server_signals_with_a_400_and_a_json_body() {
652 assert_eq!(
653 nonce_challenge(
654 Endpoint::AuthorizationServer,
655 400,
656 None,
657 br#"{"error":"use_dpop_nonce"}"#,
658 Some("n1"),
659 )
660 .as_deref(),
661 Some("n1")
662 );
663 assert_eq!(
665 nonce_challenge(
666 Endpoint::AuthorizationServer,
667 400,
668 None,
669 br#"{"error":"use_dpop_nonce","error_description":"nonce required"}"#,
670 Some("n1"),
671 )
672 .as_deref(),
673 Some("n1")
674 );
675 }
676
677 #[test]
678 fn the_authorization_server_path_ignores_other_errors_and_statuses() {
679 for (status, body) in [
680 (400u16, &br#"{"error":"invalid_grant"}"#[..]),
681 (400, br#"{"error":"invalid_dpop_proof"}"#),
682 (400, b"not json"),
683 (400, b""),
684 (400, br#"{"error":123}"#),
685 (400, br#"[]"#),
686 (401, br#"{"error":"use_dpop_nonce"}"#),
688 (200, br#"{"error":"use_dpop_nonce"}"#),
689 (500, br#"{"error":"use_dpop_nonce"}"#),
690 ] {
691 assert!(
692 nonce_challenge(
693 Endpoint::AuthorizationServer,
694 status,
695 None,
696 body,
697 Some("n1")
698 )
699 .is_none(),
700 "acted on status {status} body {:?}",
701 String::from_utf8_lossy(body)
702 );
703 }
704 }
705
706 #[test]
709 fn the_two_signalling_paths_are_independent() {
710 assert!(nonce_challenge(
712 Endpoint::AuthorizationServer,
713 400,
714 Some(r#"DPoP error="use_dpop_nonce""#),
715 br#"{"error":"invalid_grant"}"#,
716 Some("n1"),
717 )
718 .is_none());
719
720 assert!(nonce_challenge(
722 Endpoint::ResourceServer,
723 401,
724 None,
725 br#"{"error":"use_dpop_nonce"}"#,
726 Some("n1"),
727 )
728 .is_none());
729 }
730
731 #[test]
733 fn the_resource_server_path_requires_a_401() {
734 for status in [400u16, 403, 200, 500] {
735 assert!(
736 nonce_challenge(
737 Endpoint::ResourceServer,
738 status,
739 Some(r#"DPoP error="use_dpop_nonce""#),
740 b"",
741 Some("n1"),
742 )
743 .is_none(),
744 "acted on status {status}"
745 );
746 }
747 }
748
749 #[test]
759 fn agrees_with_the_reference_client_on_nonce_detection() {
760 use Endpoint::{AuthorizationServer as As, ResourceServer as Rs};
761 type Case = (Endpoint, u16, Option<&'static str>, &'static [u8], bool);
763 let cases: &[Case] = &[
764 (As, 400, None, br#"{"error":"use_dpop_nonce"}"#, true),
765 (
766 As,
767 400,
768 None,
769 br#"{"error":"use_dpop_nonce","error_description":"x"}"#,
770 true,
771 ),
772 (As, 400, None, br#"{"error":"invalid_grant"}"#, false),
773 (As, 400, None, br#"{"error":"invalid_dpop_proof"}"#, false),
774 (As, 400, None, b"not json", false),
775 (As, 400, None, b"", false),
776 (As, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
777 (As, 200, None, br#"{"error":"use_dpop_nonce"}"#, false),
778 (
779 As,
780 400,
781 Some(r#"DPoP error="use_dpop_nonce""#),
782 br#"{"error":"invalid_grant"}"#,
783 false,
784 ),
785 (Rs, 401, Some(r#"DPoP error="use_dpop_nonce""#), b"", true),
786 (
787 Rs,
788 401,
789 Some(r#"DPoP algs="ES256", error="use_dpop_nonce""#),
790 b"",
791 true,
792 ),
793 (
794 Rs,
795 401,
796 Some(r#"Bearer error="use_dpop_nonce""#),
797 b"",
798 false,
799 ),
800 (
801 Rs,
802 401,
803 Some(r#"DPoP error="invalid_dpop_proof""#),
804 b"",
805 false,
806 ),
807 (Rs, 400, Some(r#"DPoP error="use_dpop_nonce""#), b"", false),
808 (Rs, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
809 ];
810
811 for (i, (endpoint, status, header, body, expected)) in cases.iter().enumerate() {
812 let got = nonce_challenge(*endpoint, *status, *header, body, Some("n1")).is_some();
813 assert_eq!(
814 got, *expected,
815 "case {i} ({endpoint:?}, {status}, {header:?}) disagrees with the reference"
816 );
817 }
818 }
819
820 #[test]
824 fn an_oversized_error_body_is_not_parsed() {
825 let mut body = br#"{"error":"use_dpop_nonce","pad":""#.to_vec();
826 body.extend(std::iter::repeat_n(b'a', 32 * 1024));
827 body.extend(br#""}"#);
828 assert!(
829 nonce_challenge(Endpoint::AuthorizationServer, 400, None, &body, Some("n1")).is_none()
830 );
831 }
832
833 #[test]
836 fn a_dpop_nonce_challenge_yields_the_nonce_to_retry_with() {
837 for header in [
838 r#"DPoP error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#,
839 r#"DPoP algs="ES256", error="use_dpop_nonce""#,
840 r#"dpop error="use_dpop_nonce""#,
841 r#"DPoP algs="ES256", error = "use_dpop_nonce""#,
843 r#"DPoP error = "use_dpop_nonce""#,
844 ] {
845 assert_eq!(
846 rs(header, Some("n1")).as_deref(),
847 Some("n1"),
848 "should match: {header}"
849 );
850 }
851 }
852
853 #[test]
858 fn challenges_are_matched_to_their_own_scheme() {
859 assert_eq!(
861 rs(
862 r#"Bearer error="invalid_token", DPoP error="use_dpop_nonce", algs="ES256""#,
863 Some("n1")
864 )
865 .as_deref(),
866 Some("n1")
867 );
868 assert!(rs(r#"Bearer error="use_dpop_nonce""#, Some("n1")).is_none());
870 assert!(rs(
871 r#"Basic realm="r", Bearer error="use_dpop_nonce""#,
872 Some("n1")
873 )
874 .is_none());
875 assert!(rs(
877 r#"DPoP algs="ES256", Bearer error="use_dpop_nonce""#,
878 Some("n1")
879 )
880 .is_none());
881 }
882
883 #[test]
888 fn invalid_dpop_proof_is_a_failure_not_a_rs() {
889 assert!(rs(r#"DPoP error="invalid_dpop_proof""#, Some("n1")).is_none());
890 }
891
892 #[test]
895 fn no_retry_without_a_supplied_nonce() {
896 assert!(rs(r#"DPoP error="use_dpop_nonce""#, None).is_none());
897 assert!(rs(r#"DPoP error="use_dpop_nonce""#, Some("")).is_none());
898 }
899
900 #[test]
901 fn unrelated_challenges_do_not_trigger_a_retry() {
902 for header in [
903 r#"Bearer error="invalid_token""#,
904 r#"DPoP error="invalid_grant""#,
905 r#"DPoP algs="ES256""#,
906 "",
907 "garbage",
908 r#"DPoP error="x", error_description="do not use_dpop_nonce here""#,
910 ] {
911 assert!(
912 rs(header, Some("n1")).is_none(),
913 "should not match: {header}"
914 );
915 }
916 }
917
918 #[test]
926 fn a_token68_challenge_does_not_discard_the_other_challenges() {
927 for header in [
928 r#"DPoP error="use_dpop_nonce", Negotiate YII="#,
929 r#"Negotiate YII=, DPoP error="use_dpop_nonce""#,
930 r#"Basic realm=x, Negotiate abc==, DPoP error="use_dpop_nonce""#,
931 r#"DPoP foo=, error="use_dpop_nonce""#,
933 r#"DPoP error="use_dpop_nonce", bad="#,
934 ] {
935 assert_eq!(
936 rs(header, Some("n1")).as_deref(),
937 Some("n1"),
938 "token68 or unreadable param discarded the header: {header}"
939 );
940 }
941 }
942
943 #[test]
947 fn a_token68_challenge_cannot_manufacture_a_rs() {
948 for header in [
949 r#"Negotiate YII="#,
950 r#"Basic realm=x, Negotiate abc=="#,
951 r#"Bearer error="use_dpop_nonce", Negotiate YII="#,
952 ] {
953 assert!(
954 rs(header, Some("n1")).is_none(),
955 "invented a challenge from: {header}"
956 );
957 }
958 }
959
960 #[test]
964 fn a_malformed_header_does_not_trigger_a_retry() {
965 for header in [
966 r#"DPoP error_description="he said "x, junk error="use_dpop_nonce""#,
967 r#"DPoP realm="r", error_description="unbalanced " here, y error="use_dpop_nonce""#,
968 r#"DPoP error="use_dpop_nonce"#,
969 r#"DPoP error=""use_dpop_nonce"""#,
970 ] {
971 assert!(
972 rs(header, Some("n1")).is_none(),
973 "malformed header was acted on: {header}"
974 );
975 }
976 }
977
978 #[test]
981 fn quoted_values_may_contain_commas_and_escaped_quotes() {
982 assert_eq!(
983 rs(
984 r#"DPoP error_description="one, two, three", error="use_dpop_nonce""#,
985 Some("n1")
986 )
987 .as_deref(),
988 Some("n1")
989 );
990 assert_eq!(
991 rs(
992 r#"DPoP error_description="he said \"hi\", ok", error="use_dpop_nonce""#,
993 Some("n1")
994 )
995 .as_deref(),
996 Some("n1")
997 );
998 }
999
1000 #[test]
1002 fn an_unquoted_token_value_is_accepted() {
1003 assert_eq!(
1004 rs(r#"DPoP error=use_dpop_nonce"#, Some("n1")).as_deref(),
1005 Some("n1")
1006 );
1007 }
1008
1009 #[test]
1011 fn a_pathological_header_terminates() {
1012 let big = format!("DPoP error=\"{}", "a,".repeat(20_000));
1013 assert!(rs(&big, Some("n1")).is_none());
1014 let quotes = "\"".repeat(20_000);
1015 assert!(rs("es, Some("n1")).is_none());
1016 }
1017}