1use authkestra_crypto_util::verify_ed25519_signature_strict;
48use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, Jwk};
49use jsonwebtoken::{Algorithm, DecodingKey, Validation};
50use serde::Deserialize;
51use serde_json::Value;
52
53const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"];
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct VerifiedDpopProof {
65 pub jkt: String,
69 pub jti: String,
71 pub iat: i64,
75}
76
77#[derive(Debug, thiserror::Error)]
79pub enum DpopError {
80 #[error("dpop proof is not a well-formed compact JWS: {0}")]
81 Malformed(String),
82 #[error("dpop proof header typ must be \"dpop+jwt\"")]
83 WrongTyp,
84 #[error("dpop proof alg is not permitted: {0}")]
85 UnsupportedAlgorithm(String),
86 #[error("dpop proof header carries no jwk, or jwk is malformed: {0}")]
87 MissingOrInvalidJwk(String),
88 #[error("dpop proof jwk carries a private or symmetric-secret component: {0}")]
89 PrivateOrSymmetricJwk(String),
90 #[error("dpop proof key is unsafe to use: {0}")]
91 WeakKey(String),
92 #[error("dpop proof signature is invalid: {0}")]
93 BadSignature(String),
94 #[error("dpop proof htm does not match the request method")]
95 WrongHtm,
96 #[error("dpop proof htu does not match the request URI")]
97 WrongHtu,
98 #[error("dpop proof iat is outside the allowed freshness window")]
99 Stale,
100 #[error("dpop proof ath does not match the presented access token")]
101 AthMismatch,
102 #[error("dpop proof thumbprint could not be computed: {0}")]
103 ThumbprintFailed(String),
104 #[error("dpop proof jti is too long ({0} bytes, max {MAX_JTI_LEN})")]
105 JtiTooLong(usize),
106}
107
108const MAX_JTI_LEN: usize = 255;
117
118#[derive(Debug, Deserialize)]
120struct DpopClaims {
121 htm: String,
122 htu: String,
123 iat: i64,
124 jti: String,
125 #[serde(default)]
126 ath: Option<String>,
127}
128
129pub fn verify_dpop_proof(
163 compact_jws: &str,
164 expected_htm: &str,
165 expected_htu: Option<&str>,
166 expected_ath: Option<&str>,
167 max_age: chrono::Duration,
168) -> Result<VerifiedDpopProof, DpopError> {
169 let mut parts = compact_jws.split('.');
170 let header_b64 = parts
171 .next()
172 .filter(|s| !s.is_empty())
173 .ok_or_else(|| DpopError::Malformed("missing header segment".to_string()))?;
174 if parts.next().filter(|s| !s.is_empty()).is_none() {
175 return Err(DpopError::Malformed("missing payload segment".to_string()));
176 }
177 if parts.next().filter(|s| !s.is_empty()).is_none() {
178 return Err(DpopError::Malformed(
179 "missing signature segment".to_string(),
180 ));
181 }
182 if parts.next().is_some() {
183 return Err(DpopError::Malformed(
184 "compact JWS has more than three segments".to_string(),
185 ));
186 }
187
188 let header_bytes = base64_decode(header_b64)
189 .map_err(|e| DpopError::Malformed(format!("header is not valid base64url: {e}")))?;
190 let header_json: Value = serde_json::from_slice(&header_bytes)
191 .map_err(|e| DpopError::Malformed(format!("header is not valid JSON: {e}")))?;
192
193 let typ = header_json.get("typ").and_then(Value::as_str);
196 if typ != Some("dpop+jwt") {
197 return Err(DpopError::WrongTyp);
198 }
199
200 let raw_alg = header_json
205 .get("alg")
206 .and_then(Value::as_str)
207 .ok_or_else(|| DpopError::UnsupportedAlgorithm("missing".to_string()))?;
208 if ["none", "HS256", "HS384", "HS512"]
209 .iter()
210 .any(|bad| raw_alg.eq_ignore_ascii_case(bad))
211 {
212 return Err(DpopError::UnsupportedAlgorithm(raw_alg.to_string()));
213 }
214
215 let jwk_json = header_json
216 .get("jwk")
217 .ok_or_else(|| DpopError::MissingOrInvalidJwk("no jwk in header".to_string()))?;
218 let jwk_obj = jwk_json
219 .as_object()
220 .ok_or_else(|| DpopError::MissingOrInvalidJwk("jwk is not a JSON object".to_string()))?;
221 for member in PRIVATE_JWK_MEMBERS {
222 if jwk_obj.contains_key(member) {
223 return Err(DpopError::PrivateOrSymmetricJwk(member.to_string()));
224 }
225 }
226 let jwk: Jwk = serde_json::from_value(jwk_json.clone())
227 .map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
228
229 let algorithm = expected_algorithm(&jwk)?;
230
231 if let AlgorithmParameters::OctetKeyPair(params) = &jwk.algorithm {
235 let signing_input = compact_jws
236 .rsplit_once('.')
237 .map(|(input, _sig)| input)
238 .expect("already validated as header.payload.signature above");
239 let signature_b64 = compact_jws
240 .rsplit_once('.')
241 .map(|(_input, sig)| sig)
242 .expect("already validated as header.payload.signature above");
243
244 verify_ed25519_signature_strict(signing_input.as_bytes(), signature_b64, ¶ms.x)
245 .map_err(|e| match e {
246 authkestra_crypto_util::EdDsaVerifyError::Key(key_err) => {
247 DpopError::WeakKey(key_err.to_string())
248 }
249 authkestra_crypto_util::EdDsaVerifyError::Signature(msg) => {
250 DpopError::BadSignature(msg)
251 }
252 })?;
253 }
254
255 let decoding_key =
256 DecodingKey::from_jwk(&jwk).map_err(|e| DpopError::MissingOrInvalidJwk(e.to_string()))?;
257 let mut validation = Validation::new(algorithm);
258 validation.validate_exp = false;
261 validation.required_spec_claims.clear();
262
263 let data = jsonwebtoken::decode::<DpopClaims>(compact_jws, &decoding_key, &validation)
264 .map_err(|e| DpopError::BadSignature(e.to_string()))?;
265 let claims = data.claims;
266
267 if claims.jti.len() > MAX_JTI_LEN {
268 return Err(DpopError::JtiTooLong(claims.jti.len()));
269 }
270
271 if !claims.htm.eq_ignore_ascii_case(expected_htm) {
272 return Err(DpopError::WrongHtm);
273 }
274 if let Some(expected_htu) = expected_htu {
275 if canonicalize_htu(&claims.htu) != canonicalize_htu(expected_htu) {
276 return Err(DpopError::WrongHtu);
277 }
278 }
279
280 let now = chrono::Utc::now().timestamp();
281 const CLOCK_SKEW_ALLOWANCE_SECS: i64 = 5;
284 if claims.iat > now + CLOCK_SKEW_ALLOWANCE_SECS || now - claims.iat > max_age.num_seconds() {
285 return Err(DpopError::Stale);
286 }
287
288 if let Some(expected) = expected_ath {
289 let actual = claims.ath.as_deref().ok_or(DpopError::AthMismatch)?;
290 if !crate::token::cert_binding::constant_time_eq(actual, expected) {
291 return Err(DpopError::AthMismatch);
292 }
293 }
294
295 let jkt = compute_jwk_thumbprint(&jwk)?;
296
297 Ok(VerifiedDpopProof {
298 jkt,
299 jti: claims.jti,
300 iat: claims.iat,
301 })
302}
303
304pub fn compute_jwk_thumbprint(jwk: &Jwk) -> Result<String, DpopError> {
314 jwk.thumbprint(jsonwebtoken::jwk::ThumbprintHash::SHA256)
315 .map_err(|e| DpopError::ThumbprintFailed(e.to_string()))
316}
317
318fn expected_algorithm(jwk: &Jwk) -> Result<Algorithm, DpopError> {
322 match &jwk.algorithm {
323 AlgorithmParameters::EllipticCurve(params) => match ¶ms.curve {
324 EllipticCurve::P256 => Ok(Algorithm::ES256),
325 EllipticCurve::P384 => Ok(Algorithm::ES384),
326 other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
327 },
328 AlgorithmParameters::RSA(_) => Ok(Algorithm::RS256),
329 AlgorithmParameters::OctetKeyPair(params) => {
330 if params.curve == EllipticCurve::Ed25519 {
331 Ok(Algorithm::EdDSA)
332 } else {
333 Err(DpopError::UnsupportedAlgorithm(format!(
334 "OKP curve {:?}",
335 params.curve
336 )))
337 }
338 }
339 other => Err(DpopError::UnsupportedAlgorithm(format!("{other:?}"))),
340 }
341}
342
343fn canonicalize_htu(uri: &str) -> String {
349 match url::Url::parse(uri) {
350 Ok(mut url) => {
351 url.set_query(None);
352 url.set_fragment(None);
353 url.to_string()
354 }
355 Err(_) => uri.to_string(),
356 }
357}
358
359fn base64_decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
360 use base64::Engine;
361 base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use base64::Engine;
368 use ed25519_dalek::{Signer, SigningKey};
369
370 fn b64(bytes: &[u8]) -> String {
371 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
372 }
373 fn b64_json(v: &Value) -> String {
374 b64(serde_json::to_vec(v).unwrap().as_slice())
375 }
376
377 struct ProofBuilder {
380 signing_key: SigningKey,
381 htm: String,
382 htu: String,
383 iat: i64,
384 jti: String,
385 ath: Option<String>,
386 typ_override: Option<String>,
387 alg_override: Option<String>,
388 }
389
390 impl ProofBuilder {
391 fn new() -> Self {
392 Self {
393 signing_key: SigningKey::from_bytes(&[7u8; 32]),
394 htm: "POST".to_string(),
395 htu: "https://as.example.com/token".to_string(),
396 iat: chrono::Utc::now().timestamp(),
397 jti: "proof-jti-1".to_string(),
398 ath: None,
399 typ_override: None,
400 alg_override: None,
401 }
402 }
403
404 fn build(self) -> String {
405 let verifying = self.signing_key.verifying_key();
406 let jwk = serde_json::json!({
407 "kty": "OKP",
408 "crv": "Ed25519",
409 "x": b64(verifying.as_bytes()),
410 });
411 let header = serde_json::json!({
412 "typ": self.typ_override.unwrap_or_else(|| "dpop+jwt".to_string()),
413 "alg": self.alg_override.unwrap_or_else(|| "EdDSA".to_string()),
414 "jwk": jwk,
415 });
416 let mut payload = serde_json::json!({
417 "htm": self.htm,
418 "htu": self.htu,
419 "iat": self.iat,
420 "jti": self.jti,
421 });
422 if let Some(ath) = self.ath {
423 payload["ath"] = serde_json::Value::String(ath);
424 }
425
426 let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
427 let signature = self.signing_key.sign(signing_input.as_bytes());
428 format!("{signing_input}.{}", b64(&signature.to_bytes()))
429 }
430 }
431
432 #[test]
433 fn accepts_a_genuine_fresh_proof_and_reports_the_correct_jkt() {
434 let builder = ProofBuilder::new();
435 let expected_jkt = compute_jwk_thumbprint(
436 &serde_json::from_value(serde_json::json!({
437 "kty": "OKP",
438 "crv": "Ed25519",
439 "x": b64(builder.signing_key.verifying_key().as_bytes()),
440 }))
441 .unwrap(),
442 )
443 .unwrap();
444 let proof = ProofBuilder::new().build();
445
446 let verified = verify_dpop_proof(
447 &proof,
448 "POST",
449 Some("https://as.example.com/token"),
450 None,
451 chrono::Duration::seconds(60),
452 )
453 .expect("a genuine, fresh proof must be accepted");
454
455 assert_eq!(verified.jkt, expected_jkt);
456 assert_eq!(verified.jti, "proof-jti-1");
457 }
458
459 #[test]
460 fn htm_comparison_is_case_insensitive() {
461 let proof = ProofBuilder::new().build();
462 verify_dpop_proof(
463 &proof,
464 "post",
465 Some("https://as.example.com/token"),
466 None,
467 chrono::Duration::seconds(60),
468 )
469 .expect("htm must compare case-insensitively");
470 }
471
472 #[test]
473 fn rejects_wrong_htm() {
474 let proof = ProofBuilder::new().build();
475 let err = verify_dpop_proof(
476 &proof,
477 "GET",
478 Some("https://as.example.com/token"),
479 None,
480 chrono::Duration::seconds(60),
481 )
482 .expect_err("a mismatched htm must be refused");
483 assert!(matches!(err, DpopError::WrongHtm));
484 }
485
486 #[test]
487 fn htu_comparison_ignores_query_and_fragment() {
488 let proof = ProofBuilder::new().build();
489 verify_dpop_proof(
490 &proof,
491 "POST",
492 Some("https://as.example.com/token?foo=bar#frag"),
493 None,
494 chrono::Duration::seconds(60),
495 )
496 .expect("htu must compare ignoring query/fragment");
497 }
498
499 #[test]
500 fn rejects_wrong_htu() {
501 let proof = ProofBuilder::new().build();
502 let err = verify_dpop_proof(
503 &proof,
504 "POST",
505 Some("https://as.example.com/other-path"),
506 None,
507 chrono::Duration::seconds(60),
508 )
509 .expect_err("a mismatched htu must be refused");
510 assert!(matches!(err, DpopError::WrongHtu));
511 }
512
513 #[test]
519 fn expected_htu_none_skips_the_check_entirely() {
520 let proof = ProofBuilder::new().build();
521 verify_dpop_proof(&proof, "POST", None, None, chrono::Duration::seconds(60))
522 .expect("None must skip the htu check regardless of the proof's own htu");
523 }
524
525 #[test]
526 fn rejects_a_stale_proof() {
527 let mut builder = ProofBuilder::new();
528 builder.iat = chrono::Utc::now().timestamp() - 120;
529 let proof = builder.build();
530
531 let err = verify_dpop_proof(
532 &proof,
533 "POST",
534 Some("https://as.example.com/token"),
535 None,
536 chrono::Duration::seconds(60),
537 )
538 .expect_err("a proof older than max_age must be refused");
539 assert!(matches!(err, DpopError::Stale));
540 }
541
542 #[test]
543 fn rejects_a_proof_too_far_in_the_future() {
544 let mut builder = ProofBuilder::new();
545 builder.iat = chrono::Utc::now().timestamp() + 3600;
546 let proof = builder.build();
547
548 let err = verify_dpop_proof(
549 &proof,
550 "POST",
551 Some("https://as.example.com/token"),
552 None,
553 chrono::Duration::seconds(60),
554 )
555 .expect_err("a proof from the future must be refused");
556 assert!(matches!(err, DpopError::Stale));
557 }
558
559 #[test]
567 fn rejects_malformed_compact_jws_segment_shapes() {
568 let cases: [(&str, &str); 4] = [
569 ("", "empty string has no segments at all"),
570 ("only-one-segment", "missing payload and signature segments"),
571 ("header-only.payload-only", "missing signature segment"),
572 ("a.b.c.d", "more than three segments"),
573 ];
574 for (proof, description) in cases {
575 let err = verify_dpop_proof(
576 proof,
577 "POST",
578 Some("https://as.example.com/token"),
579 None,
580 chrono::Duration::seconds(60),
581 )
582 .expect_err(&format!(
583 "case ({description}) must be rejected as malformed"
584 ));
585 assert!(
586 matches!(err, DpopError::Malformed(_)),
587 "case ({description}): expected Malformed, got {err:?}"
588 );
589 }
590 }
591
592 #[test]
593 fn rejects_a_header_segment_that_is_not_valid_base64url() {
594 let proof = "!!!not-base64url!!!.payload.signature";
596 let err = verify_dpop_proof(
597 proof,
598 "POST",
599 Some("https://as.example.com/token"),
600 None,
601 chrono::Duration::seconds(60),
602 )
603 .expect_err("a header segment that isn't valid base64url must be rejected");
604 assert!(matches!(err, DpopError::Malformed(_)));
605 }
606
607 #[test]
608 fn rejects_a_header_segment_that_decodes_but_is_not_valid_json() {
609 let header_b64 = b64(b"not valid json at all");
610 let proof = format!("{header_b64}.payload.signature");
611 let err = verify_dpop_proof(
612 &proof,
613 "POST",
614 Some("https://as.example.com/token"),
615 None,
616 chrono::Duration::seconds(60),
617 )
618 .expect_err("a header that decodes but isn't valid JSON must be rejected");
619 assert!(matches!(err, DpopError::Malformed(_)));
620 }
621
622 #[test]
623 fn rejects_wrong_typ() {
624 let mut builder = ProofBuilder::new();
625 builder.typ_override = Some("JWT".to_string());
626 let proof = builder.build();
627
628 let err = verify_dpop_proof(
629 &proof,
630 "POST",
631 Some("https://as.example.com/token"),
632 None,
633 chrono::Duration::seconds(60),
634 )
635 .expect_err("a non-dpop+jwt typ must be refused");
636 assert!(matches!(err, DpopError::WrongTyp));
637 }
638
639 #[test]
645 fn rejects_alg_none_and_hmac_algorithms_case_insensitively() {
646 for bad_alg in ["none", "None", "NONE", "HS256", "hs256", "Hs384", "HS512"] {
647 let mut builder = ProofBuilder::new();
648 builder.alg_override = Some(bad_alg.to_string());
649 let proof = builder.build();
650
651 let err = verify_dpop_proof(
652 &proof,
653 "POST",
654 Some("https://as.example.com/token"),
655 None,
656 chrono::Duration::seconds(60),
657 )
658 .expect_err(&format!("alg {bad_alg:?} must be refused"));
659 assert!(
660 matches!(err, DpopError::UnsupportedAlgorithm(_)),
661 "alg {bad_alg:?}: expected UnsupportedAlgorithm, got {err:?}"
662 );
663 }
664 }
665
666 #[test]
667 fn checks_ath_when_requested() {
668 let mut builder = ProofBuilder::new();
669 builder.ath = Some("correct-ath".to_string());
670 let proof = builder.build();
671
672 verify_dpop_proof(
673 &proof,
674 "POST",
675 Some("https://as.example.com/token"),
676 Some("correct-ath"),
677 chrono::Duration::seconds(60),
678 )
679 .expect("a matching ath must be accepted");
680
681 let err = verify_dpop_proof(
682 &proof,
683 "POST",
684 Some("https://as.example.com/token"),
685 Some("wrong-ath"),
686 chrono::Duration::seconds(60),
687 )
688 .expect_err("a mismatched ath must be refused");
689 assert!(matches!(err, DpopError::AthMismatch));
690 }
691
692 #[test]
693 fn requires_ath_when_caller_expects_one() {
694 let proof = ProofBuilder::new().build(); let err = verify_dpop_proof(
696 &proof,
697 "POST",
698 Some("https://as.example.com/token"),
699 Some("expected-ath"),
700 chrono::Duration::seconds(60),
701 )
702 .expect_err("a missing ath must be refused when the caller expects one");
703 assert!(matches!(err, DpopError::AthMismatch));
704 }
705
706 #[test]
707 fn rejects_a_low_order_key() {
708 let identity = {
710 let mut b = [0u8; 32];
711 b[0] = 1;
712 b
713 };
714 let jwk = serde_json::json!({
715 "kty": "OKP",
716 "crv": "Ed25519",
717 "x": b64(&identity),
718 });
719 let header = serde_json::json!({
720 "typ": "dpop+jwt",
721 "alg": "EdDSA",
722 "jwk": jwk,
723 });
724 let payload = serde_json::json!({
725 "htm": "POST",
726 "htu": "https://as.example.com/token",
727 "iat": chrono::Utc::now().timestamp(),
728 "jti": "j1",
729 });
730 let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
731 let mut forged_sig = [0u8; 64];
733 forged_sig[..32].copy_from_slice(&identity);
734 let proof = format!("{signing_input}.{}", b64(&forged_sig));
735
736 let err = verify_dpop_proof(
737 &proof,
738 "POST",
739 Some("https://as.example.com/token"),
740 None,
741 chrono::Duration::seconds(60),
742 )
743 .expect_err("a low-order key must be refused");
744 assert!(matches!(err, DpopError::WeakKey(_)));
745 }
746
747 #[test]
748 fn rejects_a_private_jwk_member() {
749 let builder = ProofBuilder::new();
750 let header = serde_json::json!({
751 "typ": "dpop+jwt",
752 "alg": "EdDSA",
753 "jwk": {
754 "kty": "OKP",
755 "crv": "Ed25519",
756 "x": b64(builder.signing_key.verifying_key().as_bytes()),
757 "d": "smuggled-private-scalar",
758 },
759 });
760 let payload = serde_json::json!({
761 "htm": "POST",
762 "htu": "https://as.example.com/token",
763 "iat": chrono::Utc::now().timestamp(),
764 "jti": "j1",
765 });
766 let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
767 let signature = builder.signing_key.sign(signing_input.as_bytes());
768 let proof = format!("{signing_input}.{}", b64(&signature.to_bytes()));
769
770 let err = verify_dpop_proof(
771 &proof,
772 "POST",
773 Some("https://as.example.com/token"),
774 None,
775 chrono::Duration::seconds(60),
776 )
777 .expect_err("a jwk carrying a private component must be refused");
778 assert!(matches!(err, DpopError::PrivateOrSymmetricJwk(_)));
779 }
780
781 #[test]
782 fn rejects_a_missing_jwk() {
783 let header = serde_json::json!({
784 "typ": "dpop+jwt",
785 "alg": "EdDSA",
786 });
787 let payload = serde_json::json!({
788 "htm": "POST",
789 "htu": "https://as.example.com/token",
790 "iat": chrono::Utc::now().timestamp(),
791 "jti": "j1",
792 });
793 let signing_input = format!("{}.{}", b64_json(&header), b64_json(&payload));
794 let proof = format!("{signing_input}.{}", b64(&[0u8; 64]));
797
798 let err = verify_dpop_proof(
799 &proof,
800 "POST",
801 Some("https://as.example.com/token"),
802 None,
803 chrono::Duration::seconds(60),
804 )
805 .expect_err("a proof with no embedded jwk must be refused");
806 assert!(matches!(err, DpopError::MissingOrInvalidJwk(_)));
807 }
808
809 #[test]
816 fn accepts_a_genuine_es256_proof() {
817 use jsonwebtoken::{Algorithm as JwtAlgorithm, EncodingKey, Header};
818 use p256::ecdsa::SigningKey as P256SigningKey;
819 use p256::elliptic_curve::{JwkEcKey, PublicKey as P256PublicKey};
820 use p256::pkcs8::EncodePrivateKey;
821 use rand_core::OsRng;
822
823 let signing_key = P256SigningKey::random(&mut OsRng);
824 let public_key: P256PublicKey<p256::NistP256> = signing_key.verifying_key().into();
825 let public_jwk: Jwk =
826 serde_json::from_value(serde_json::to_value(JwkEcKey::from(&public_key)).unwrap())
827 .expect("a p256 public JwkEcKey must parse as a jsonwebtoken Jwk");
828
829 let mut header = Header::new(JwtAlgorithm::ES256);
830 header.typ = Some("dpop+jwt".to_string());
831 header.jwk = Some(public_jwk);
832
833 let claims = serde_json::json!({
834 "htm": "POST",
835 "htu": "https://as.example.com/token",
836 "iat": chrono::Utc::now().timestamp(),
837 "jti": "es256-proof-1",
838 });
839
840 let pkcs8_der = signing_key.to_pkcs8_der().unwrap().as_bytes().to_vec();
841 let proof = jsonwebtoken::encode(&header, &claims, &EncodingKey::from_ec_der(&pkcs8_der))
842 .expect("encoding a genuine ES256 JWS must succeed");
843
844 let verified = verify_dpop_proof(
845 &proof,
846 "POST",
847 Some("https://as.example.com/token"),
848 None,
849 chrono::Duration::seconds(60),
850 )
851 .expect("a genuine ES256 proof must be accepted");
852 assert_eq!(verified.jti, "es256-proof-1");
853 }
854
855 #[test]
860 fn rejects_a_jti_longer_than_the_max() {
861 let mut builder = ProofBuilder::new();
862 builder.jti = "j".repeat(MAX_JTI_LEN + 1);
863 let proof = builder.build();
864
865 let err = verify_dpop_proof(
866 &proof,
867 "POST",
868 Some("https://as.example.com/token"),
869 None,
870 chrono::Duration::seconds(60),
871 )
872 .expect_err("an over-long jti must be refused");
873 assert!(matches!(err, DpopError::JtiTooLong(len) if len == MAX_JTI_LEN + 1));
874 }
875
876 #[test]
877 fn a_jti_at_exactly_the_max_length_is_accepted() {
878 let mut builder = ProofBuilder::new();
879 builder.jti = "j".repeat(MAX_JTI_LEN);
880 let proof = builder.build();
881
882 verify_dpop_proof(
883 &proof,
884 "POST",
885 Some("https://as.example.com/token"),
886 None,
887 chrono::Duration::seconds(60),
888 )
889 .expect("a jti at exactly the max length must be accepted");
890 }
891
892 #[test]
898 fn canonicalize_htu_falls_back_to_exact_comparison_for_non_urls() {
899 assert_eq!(canonicalize_htu("not-a-url"), "not-a-url");
900 assert_ne!(canonicalize_htu("not-a-url"), canonicalize_htu("Not-A-Url"));
901 }
902
903 #[test]
904 fn thumbprint_matches_a_known_rfc7638_test_vector() {
905 let jwk: Jwk = serde_json::from_value(serde_json::json!({
907 "kty": "RSA",
908 "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
909 "e": "AQAB",
910 }))
911 .unwrap();
912 assert_eq!(
913 compute_jwk_thumbprint(&jwk).unwrap(),
914 "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
915 );
916 }
917}