1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use jsonwebtoken::jwk::Jwk;
4use jsonwebtoken::{
5 Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use sha2::{Digest, Sha256};
10use uuid::Uuid;
11
12use crate::constants::{dpop_claims, jwk_params};
13use crate::errors::validation_error;
14use crate::{AuthplaneError, VerifierError};
15
16pub const SUPPORTED_DPOP_ALGORITHMS: &[Algorithm] = &[Algorithm::ES256, Algorithm::RS256];
24
25pub(crate) fn ensure_supported_dpop_alg(algorithm: Algorithm) -> Result<(), AuthplaneError> {
40 if SUPPORTED_DPOP_ALGORITHMS.contains(&algorithm) {
41 return Ok(());
42 }
43 Err(validation_error(&format!(
44 "DPoP algorithm {algorithm:?} is not supported; use one of {SUPPORTED_DPOP_ALGORITHMS:?}"
45 )))
46}
47
48#[derive(Debug, Clone)]
49pub struct DpopProofOptions {
50 pub private_key_pem: String,
51 pub public_jwk: Value,
52 pub algorithm: Algorithm,
53 pub key_id: Option<String>,
54 pub nonce: Option<String>,
55 pub proof_ttl_seconds: Option<u64>,
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct DpopVerificationOptions<'a> {
62 pub expected_access_token: Option<&'a str>,
63 pub expected_nonce: Option<&'a str>,
64 pub allowed_algorithms: &'a [Algorithm],
65 pub clock_skew_seconds: u64,
66 pub max_age_seconds: u64,
67}
68
69#[derive(Debug, Clone)]
72pub struct DpopVerificationOptionsOwned {
73 pub expected_access_token: Option<String>,
74 pub expected_nonce: Option<String>,
75 pub allowed_algorithms: Vec<Algorithm>,
76 pub clock_skew_seconds: u64,
77 pub max_age_seconds: u64,
78}
79
80impl DpopVerificationOptionsOwned {
81 pub fn as_ref(&self) -> DpopVerificationOptions<'_> {
82 DpopVerificationOptions {
83 expected_access_token: self.expected_access_token.as_deref(),
84 expected_nonce: self.expected_nonce.as_deref(),
85 allowed_algorithms: &self.allowed_algorithms,
86 clock_skew_seconds: self.clock_skew_seconds,
87 max_age_seconds: self.max_age_seconds,
88 }
89 }
90}
91
92#[derive(Clone, Debug)]
127pub struct DpopRequestContext {
128 pub(crate) method: String,
129 pub(crate) url: String,
130 pub(crate) proof: Option<String>,
131 pub(crate) nonce: Option<String>,
132}
133
134impl DpopRequestContext {
135 pub fn new(method: &str, url: &str, proof: Option<&str>, nonce: Option<&str>) -> Self {
146 Self {
147 method: method.to_ascii_uppercase(),
148 url: url.to_string(),
149 proof: proof
150 .map(|s| s.trim().to_string())
151 .filter(|s| !s.is_empty()),
152 nonce: nonce
153 .map(|s| s.trim().to_string())
154 .filter(|s| !s.is_empty()),
155 }
156 }
157
158 pub fn method(&self) -> &str {
160 &self.method
161 }
162
163 pub fn url(&self) -> &str {
165 &self.url
166 }
167
168 pub fn proof(&self) -> Option<&str> {
170 self.proof.as_deref()
171 }
172
173 pub fn nonce(&self) -> Option<&str> {
175 self.nonce.as_deref()
176 }
177
178 pub fn from_header_values<I, T>(
198 method: &str,
199 url: &str,
200 proofs: I,
201 nonce: Option<&str>,
202 ) -> Result<Self, VerifierError>
203 where
204 I: IntoIterator<Item = T>,
205 T: AsRef<str>,
206 {
207 let mut proof: Option<String> = None;
208 for value in proofs {
209 for piece in value.as_ref().split(',') {
215 let piece = piece.trim();
216 if piece.is_empty() {
217 continue;
218 }
219 if proof.is_some() {
220 return Err(VerifierError::DpopMultipleProofs);
221 }
222 proof = Some(piece.to_string());
223 }
224 }
225 Ok(Self::new(method, url, proof.as_deref(), nonce))
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct VerifiedDpopProof {
231 pub jti: String,
232 pub iat: i64,
233 pub method: String,
234 pub url: String,
235 pub nonce: Option<String>,
236 pub ath: Option<String>,
237 pub jkt: String,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241struct DpopClaims {
242 htm: String,
243 htu: String,
244 iat: i64,
245 jti: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 nonce: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 ath: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
253 exp: Option<i64>,
254}
255
256pub fn dpop_ath(access_token: &str) -> String {
257 let mut hasher = Sha256::new();
258 hasher.update(access_token.as_bytes());
259 URL_SAFE_NO_PAD.encode(hasher.finalize())
260}
261
262pub fn create_dpop_proof(
263 method: &str,
264 target_url: &str,
265 access_token: Option<&str>,
266 options: &DpopProofOptions,
267) -> Result<String, AuthplaneError> {
268 ensure_supported_dpop_alg(options.algorithm)?;
269 let normalized_method = method.trim().to_ascii_uppercase();
270 if normalized_method.is_empty() {
271 return Err(validation_error("DPoP method must not be empty"));
272 }
273 let normalized_url = normalize_htu(target_url).map_err(|message| validation_error(&message))?;
274 let mut header = Header::new(options.algorithm);
275 header.typ = Some(dpop_claims::TYP_DPOP_JWT.to_string());
276 header.kid = options.key_id.clone();
277 header.jwk = Some(
278 serde_json::from_value::<Jwk>(options.public_jwk.clone())
279 .map_err(|error| validation_error(&format!("invalid DPoP public_jwk: {error}")))?,
280 );
281
282 let iat = unix_now();
283 let ttl = options.proof_ttl_seconds.unwrap_or(300);
284 let claims = DpopClaims {
285 htm: normalized_method,
286 htu: normalized_url,
287 iat,
288 jti: Uuid::new_v4().to_string(),
289 nonce: options.nonce.clone(),
290 ath: access_token.map(dpop_ath),
291 exp: Some(iat + ttl as i64),
292 };
293
294 let encoding_key = build_encoding_key(options)?;
295 jsonwebtoken::encode(&header, &claims, &encoding_key)
296 .map_err(|error| validation_error(&format!("failed to sign DPoP proof: {error}")))
297}
298
299pub fn verify_dpop_proof(
300 proof: &str,
301 expected_method: &str,
302 expected_url: &str,
303 options: DpopVerificationOptions<'_>,
304) -> Result<VerifiedDpopProof, VerifierError> {
305 let header = decode_header(proof).map_err(|error| VerifierError::InvalidClaims {
306 message: format!("invalid DPoP header: {error}"),
307 })?;
308 let typ = header.typ.ok_or_else(|| VerifierError::InvalidClaims {
309 message: "DPoP header missing typ".to_string(),
310 })?;
311 if typ != dpop_claims::TYP_DPOP_JWT {
312 return Err(VerifierError::InvalidClaims {
313 message: format!(
314 "DPoP typ must be {}, got {typ:?}",
315 dpop_claims::TYP_DPOP_JWT
316 ),
317 });
318 }
319 if !options.allowed_algorithms.contains(&header.alg) {
320 return Err(VerifierError::InvalidClaims {
321 message: format!("DPoP algorithm {:?} is not allowed", header.alg),
322 });
323 }
324 if !SUPPORTED_DPOP_ALGORITHMS.contains(&header.alg) {
325 return Err(VerifierError::InvalidClaims {
326 message: format!(
327 "DPoP algorithm {:?} is not in supported set {:?}",
328 header.alg, SUPPORTED_DPOP_ALGORITHMS
329 ),
330 });
331 }
332 let jwk = header.jwk.ok_or_else(|| VerifierError::InvalidClaims {
333 message: "DPoP header missing jwk".to_string(),
334 })?;
335 let decoding_key =
336 DecodingKey::from_jwk(&jwk).map_err(|error| VerifierError::InvalidClaims {
337 message: format!("invalid DPoP jwk: {error}"),
338 })?;
339
340 let mut validation = Validation::new(header.alg);
341 validation.validate_exp = false;
342 validation.validate_nbf = false;
343 validation.required_spec_claims.clear();
344 let decoded = decode::<DpopClaims>(proof, &decoding_key, &validation).map_err(|error| {
345 VerifierError::InvalidSignature {
346 message: format!("DPoP signature validation failed: {error}"),
347 }
348 })?;
349 let claims = decoded.claims;
350
351 let expected_method = expected_method.trim().to_ascii_uppercase();
352 if claims.htm != expected_method {
353 return Err(VerifierError::InvalidClaims {
354 message: format!(
355 "DPoP htm mismatch: expected {expected_method:?}, got {:?}",
356 claims.htm
357 ),
358 });
359 }
360 let expected_url =
361 normalize_htu(expected_url).map_err(|message| VerifierError::InvalidClaims { message })?;
362 if claims.htu != expected_url {
363 return Err(VerifierError::InvalidClaims {
364 message: format!(
365 "DPoP htu mismatch: expected {expected_url:?}, got {:?}",
366 claims.htu
367 ),
368 });
369 }
370
371 let now = unix_now();
372 let max_age = options.max_age_seconds as i64;
373 let skew = options.clock_skew_seconds as i64;
374 if claims.iat > now + skew {
375 return Err(VerifierError::InvalidClaims {
376 message: format!(
377 "DPoP iat is in the future (iat={}, now={}, leeway={}s)",
378 claims.iat, now, options.clock_skew_seconds
379 ),
380 });
381 }
382 if now - claims.iat > max_age + skew {
383 return Err(VerifierError::InvalidClaims {
384 message: format!(
385 "DPoP proof is too old (iat={}, now={}, max_age={}s, skew={}s)",
386 claims.iat, now, options.max_age_seconds, options.clock_skew_seconds
387 ),
388 });
389 }
390 if let Some(exp) = claims.exp
392 && exp < now - skew
393 {
394 return Err(VerifierError::InvalidClaims {
395 message: format!("DPoP proof has expired (exp={exp}, now={now}, skew={skew}s)"),
396 });
397 }
398 if claims.jti.trim().is_empty() {
399 return Err(VerifierError::InvalidClaims {
400 message: "DPoP jti must not be empty".to_string(),
401 });
402 }
403
404 match (options.expected_nonce, claims.nonce.as_deref()) {
405 (Some(expected), Some(actual)) if expected == actual => {}
406 (Some(expected), Some(actual)) => {
407 return Err(VerifierError::InvalidClaims {
408 message: format!("DPoP nonce mismatch: expected {expected:?}, got {actual:?}"),
409 });
410 }
411 (Some(expected), None) => {
412 return Err(VerifierError::InvalidClaims {
413 message: format!("DPoP nonce {expected:?} required but missing"),
414 });
415 }
416 (None, _) => {}
417 }
418
419 if let Some(token) = options.expected_access_token {
420 let expected_ath = dpop_ath(token);
421 let actual_ath = claims
422 .ath
423 .as_deref()
424 .ok_or_else(|| VerifierError::InvalidClaims {
425 message: "DPoP ath is required for token-bound verification".to_string(),
426 })?;
427 if expected_ath != actual_ath {
428 return Err(VerifierError::InvalidClaims {
429 message: "DPoP ath mismatch".to_string(),
430 });
431 }
432 }
433
434 let jwk_json =
435 extract_jwk_header_json(proof).map_err(|message| VerifierError::InvalidClaims {
436 message: format!("invalid DPoP jwk header: {message}"),
437 })?;
438 let jkt = jwk_thumbprint_sha256(&jwk_json).map_err(|message| VerifierError::InvalidClaims {
439 message: format!("invalid DPoP jwk thumbprint: {message}"),
440 })?;
441
442 Ok(VerifiedDpopProof {
443 jti: claims.jti,
444 iat: claims.iat,
445 method: claims.htm,
446 url: claims.htu,
447 nonce: claims.nonce,
448 ath: claims.ath,
449 jkt,
450 })
451}
452
453pub async fn verify_dpop_proof_with_replay(
473 proof: &str,
474 expected_method: &str,
475 expected_url: &str,
476 options: DpopVerificationOptions<'_>,
477 replay_store: &dyn crate::dpop_replay::DpopReplayStore,
478) -> Result<VerifiedDpopProof, VerifierError> {
479 let verified = verify_dpop_proof(proof, expected_method, expected_url, options)?;
480 let expires_at = verified.iat + options.max_age_seconds as i64;
481 let stored = replay_store
482 .check_and_store(&verified.jti, expires_at)
483 .await?;
484 if !stored {
485 return Err(VerifierError::DpopReplayDetected);
486 }
487 Ok(verified)
488}
489
490pub async fn verify_dpop_proof_with_jkt_and_replay(
502 proof: &str,
503 expected_method: &str,
504 expected_url: &str,
505 options: DpopVerificationOptions<'_>,
506 expected_jkt: &str,
507 replay_store: &dyn crate::dpop_replay::DpopReplayStore,
508) -> Result<VerifiedDpopProof, VerifierError> {
509 let verified = verify_dpop_proof(proof, expected_method, expected_url, options)?;
510 if verified.jkt != expected_jkt {
511 return Err(VerifierError::DpopBindingMismatch {
512 message: format!(
513 "DPoP cnf.jkt mismatch: token expects {:?}, proof has {:?}",
514 expected_jkt, verified.jkt
515 ),
516 });
517 }
518 let expires_at = verified.iat + options.max_age_seconds as i64;
519 let stored = replay_store
520 .check_and_store(&verified.jti, expires_at)
521 .await?;
522 if !stored {
523 return Err(VerifierError::DpopReplayDetected);
524 }
525 Ok(verified)
526}
527
528pub fn jwk_thumbprint_sha256(jwk: &Value) -> Result<String, String> {
529 let kty = jwk
530 .get(jwk_params::KTY)
531 .and_then(Value::as_str)
532 .ok_or_else(|| "jwk missing kty".to_string())?;
533 let canonical = match kty {
534 jwk_params::KTY_RSA => canonical_jwk_json(&[
535 (jwk_params::E, get_required_jwk_field(jwk, jwk_params::E)?),
536 (jwk_params::KTY, jwk_params::KTY_RSA),
537 (jwk_params::N, get_required_jwk_field(jwk, jwk_params::N)?),
538 ])?,
539 jwk_params::KTY_EC => canonical_jwk_json(&[
540 (
541 jwk_params::CRV,
542 get_required_jwk_field(jwk, jwk_params::CRV)?,
543 ),
544 (jwk_params::KTY, jwk_params::KTY_EC),
545 (jwk_params::X, get_required_jwk_field(jwk, jwk_params::X)?),
546 (jwk_params::Y, get_required_jwk_field(jwk, jwk_params::Y)?),
547 ])?,
548 jwk_params::KTY_OKP => canonical_jwk_json(&[
549 (
550 jwk_params::CRV,
551 get_required_jwk_field(jwk, jwk_params::CRV)?,
552 ),
553 (jwk_params::KTY, jwk_params::KTY_OKP),
554 (jwk_params::X, get_required_jwk_field(jwk, jwk_params::X)?),
555 ])?,
556 _ => return Err(format!("unsupported jwk kty {kty:?} for thumbprint")),
557 };
558
559 let mut hasher = Sha256::new();
560 hasher.update(canonical.as_bytes());
561 Ok(URL_SAFE_NO_PAD.encode(hasher.finalize()))
562}
563
564fn get_required_jwk_field<'a>(jwk: &'a Value, field: &str) -> Result<&'a str, String> {
565 jwk.get(field)
566 .and_then(Value::as_str)
567 .filter(|value| !value.is_empty())
568 .ok_or_else(|| format!("jwk missing {field}"))
569}
570
571fn build_encoding_key(options: &DpopProofOptions) -> Result<EncodingKey, AuthplaneError> {
572 let key = match options.algorithm {
573 Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => {
574 EncodingKey::from_rsa_pem(options.private_key_pem.as_bytes())
575 }
576 Algorithm::ES256 | Algorithm::ES384 => {
577 EncodingKey::from_ec_pem(options.private_key_pem.as_bytes())
578 }
579 Algorithm::EdDSA => EncodingKey::from_ed_pem(options.private_key_pem.as_bytes()),
580 _ => {
581 return Err(validation_error(&format!(
582 "unsupported DPoP signing algorithm {:?}",
583 options.algorithm
584 )));
585 }
586 };
587 key.map_err(|error| validation_error(&format!("invalid DPoP private key: {error}")))
588}
589
590fn canonical_jwk_json(fields: &[(&str, &str)]) -> Result<String, String> {
591 let object = fields
592 .iter()
593 .map(|(key, value)| ((*key).to_string(), Value::String((*value).to_string())))
594 .collect::<Map<String, Value>>();
595 serde_json::to_string(&object).map_err(|error| error.to_string())
596}
597
598fn extract_jwk_header_json(proof: &str) -> Result<Value, String> {
599 let header_segment = proof
600 .split('.')
601 .next()
602 .ok_or_else(|| "malformed compact jwt".to_string())?;
603 let decoded = URL_SAFE_NO_PAD
604 .decode(header_segment)
605 .map_err(|error| error.to_string())?;
606 let header_json: Value = serde_json::from_slice(&decoded).map_err(|error| error.to_string())?;
607 header_json
608 .get("jwk")
609 .cloned()
610 .ok_or_else(|| "header missing jwk".to_string())
611}
612
613fn normalize_htu(raw: &str) -> Result<String, String> {
614 let mut url = url::Url::parse(raw).map_err(|error| error.to_string())?;
615 url.set_query(None);
621 url.set_fragment(None);
622 let _ = url.set_username("");
628 let _ = url.set_password(None);
629 Ok(url.to_string())
630}
631
632use crate::time_utils::unix_now_secs_i64 as unix_now;
633
634#[cfg(test)]
635mod tests {
636 use super::{
637 DpopProofOptions, DpopRequestContext, DpopVerificationOptions, create_dpop_proof, dpop_ath,
638 ensure_supported_dpop_alg, jwk_thumbprint_sha256, verify_dpop_proof,
639 verify_dpop_proof_with_jkt_and_replay,
640 };
641 use crate::dpop_replay::{DpopReplayStore, InMemoryDpopReplayStore};
642 use crate::{AuthplaneError, VerifierError};
643 use jsonwebtoken::Algorithm;
644 use serde_json::json;
645
646 const TEST_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-private.pem");
647 const TEST_EC_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-ec-private.pem");
648 const TEST_RSA_N: &str = "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ";
649 const TEST_RSA_E: &str = "AQAB";
650 const TEST_EC_X: &str = "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ";
651 const TEST_EC_Y: &str = "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4";
652
653 fn rsa_options(nonce: Option<&str>) -> DpopProofOptions {
654 DpopProofOptions {
655 private_key_pem: TEST_PRIVATE_PEM.to_string(),
656 public_jwk: json!({
657 "kty": "RSA",
658 "kid": "test-kid",
659 "use": "sig",
660 "alg": "RS256",
661 "n": TEST_RSA_N,
662 "e": TEST_RSA_E
663 }),
664 algorithm: Algorithm::RS256,
665 key_id: Some("test-kid".to_string()),
666 nonce: nonce.map(ToString::to_string),
667 proof_ttl_seconds: None,
668 }
669 }
670
671 #[test]
672 fn ath_is_base64url_sha256() {
673 let ath = dpop_ath("abc123");
674 assert_eq!(ath, "bKE9UspwyIPg8LsQHkJaiehiTeUdstI5JZOvaoQRgJA");
675 }
676
677 #[test]
678 fn jwk_thumbprint_matches_expected_shape() {
679 let jwk = json!({
680 "kty": "RSA",
681 "n": TEST_RSA_N,
682 "e": TEST_RSA_E
683 });
684 let thumbprint = jwk_thumbprint_sha256(&jwk).expect("thumbprint");
685 assert!(!thumbprint.is_empty());
686 }
687
688 #[test]
689 fn create_dpop_proof_signs_compact_jwt() {
690 let proof = create_dpop_proof(
691 "POST",
692 "https://api.example.com/mcp",
693 Some("token-value"),
694 &rsa_options(Some("nonce-1")),
695 )
696 .expect("proof");
697 assert_eq!(proof.split('.').count(), 3);
698 }
699
700 #[test]
701 fn create_dpop_proof_supports_es256() {
702 let proof = create_dpop_proof(
703 "POST",
704 "https://api.example.com/mcp",
705 None,
706 &DpopProofOptions {
707 private_key_pem: TEST_EC_PRIVATE_PEM.to_string(),
708 public_jwk: json!({
709 "kty": "EC",
710 "kid": "ec-test-kid",
711 "use": "sig",
712 "alg": "ES256",
713 "crv": "P-256",
714 "x": TEST_EC_X,
715 "y": TEST_EC_Y
716 }),
717 algorithm: Algorithm::ES256,
718 key_id: Some("ec-test-kid".to_string()),
719 nonce: None,
720 proof_ttl_seconds: None,
721 },
722 )
723 .expect("proof");
724 assert_eq!(proof.split('.').count(), 3);
725 }
726
727 #[test]
728 fn verify_dpop_proof_accepts_valid_proof() {
729 let proof = create_dpop_proof(
730 "POST",
731 "https://api.example.com/mcp",
732 Some("token-value"),
733 &rsa_options(Some("nonce-1")),
734 )
735 .expect("proof");
736
737 let verified = verify_dpop_proof(
738 &proof,
739 "POST",
740 "https://api.example.com/mcp",
741 DpopVerificationOptions {
742 expected_access_token: Some("token-value"),
743 expected_nonce: Some("nonce-1"),
744 allowed_algorithms: &[Algorithm::RS256],
745 clock_skew_seconds: 30,
746 max_age_seconds: 300,
747 },
748 )
749 .expect("valid proof");
750
751 assert_eq!(verified.method, "POST");
752 assert_eq!(verified.url, "https://api.example.com/mcp");
753 assert_eq!(verified.nonce.as_deref(), Some("nonce-1"));
754 assert_eq!(
755 verified.ath.as_deref(),
756 Some(dpop_ath("token-value").as_str())
757 );
758 assert!(!verified.jkt.is_empty());
759 }
760
761 #[test]
762 fn verify_dpop_proof_rejects_wrong_nonce() {
763 let proof = create_dpop_proof(
764 "POST",
765 "https://api.example.com/mcp",
766 Some("token-value"),
767 &rsa_options(Some("nonce-1")),
768 )
769 .expect("proof");
770
771 let err = verify_dpop_proof(
772 &proof,
773 "POST",
774 "https://api.example.com/mcp",
775 DpopVerificationOptions {
776 expected_access_token: Some("token-value"),
777 expected_nonce: Some("nonce-2"),
778 allowed_algorithms: &[Algorithm::RS256],
779 clock_skew_seconds: 30,
780 max_age_seconds: 300,
781 },
782 )
783 .expect_err("nonce mismatch should fail");
784 assert!(err.to_string().contains("nonce mismatch"));
785 }
786
787 #[test]
788 fn verify_dpop_proof_rejects_wrong_method() {
789 let proof = create_dpop_proof(
790 "POST",
791 "https://api.example.com/mcp",
792 Some("token-value"),
793 &rsa_options(None),
794 )
795 .expect("proof");
796
797 let err = verify_dpop_proof(
798 &proof,
799 "GET",
800 "https://api.example.com/mcp",
801 DpopVerificationOptions {
802 expected_access_token: Some("token-value"),
803 expected_nonce: None,
804 allowed_algorithms: &[Algorithm::RS256],
805 clock_skew_seconds: 30,
806 max_age_seconds: 300,
807 },
808 )
809 .expect_err("method mismatch should fail");
810 assert!(err.to_string().contains("htm mismatch"));
811 }
812
813 #[test]
814 fn verify_dpop_proof_rejects_wrong_ath() {
815 let proof = create_dpop_proof(
816 "POST",
817 "https://api.example.com/mcp",
818 Some("token-value"),
819 &rsa_options(None),
820 )
821 .expect("proof");
822
823 let err = verify_dpop_proof(
824 &proof,
825 "POST",
826 "https://api.example.com/mcp",
827 DpopVerificationOptions {
828 expected_access_token: Some("other-token"),
829 expected_nonce: None,
830 allowed_algorithms: &[Algorithm::RS256],
831 clock_skew_seconds: 30,
832 max_age_seconds: 300,
833 },
834 )
835 .expect_err("ath mismatch should fail");
836 assert!(err.to_string().contains("ath mismatch"));
837 }
838
839 #[test]
840 fn create_dpop_proof_rejects_empty_method() {
841 let err = create_dpop_proof("", "https://api.example.com/mcp", None, &rsa_options(None))
842 .expect_err("empty method should fail");
843 assert!(err.to_string().contains("method must not be empty"));
844 }
845
846 #[test]
847 fn verify_dpop_proof_rejects_missing_required_nonce() {
848 let proof = create_dpop_proof(
849 "POST",
850 "https://api.example.com/mcp",
851 Some("token-value"),
852 &rsa_options(None),
853 )
854 .expect("proof");
855
856 let err = verify_dpop_proof(
857 &proof,
858 "POST",
859 "https://api.example.com/mcp",
860 DpopVerificationOptions {
861 expected_access_token: Some("token-value"),
862 expected_nonce: Some("nonce-required"),
863 allowed_algorithms: &[Algorithm::RS256],
864 clock_skew_seconds: 30,
865 max_age_seconds: 300,
866 },
867 )
868 .expect_err("missing nonce should fail");
869 assert!(err.to_string().contains("required but missing"));
870 }
871
872 #[test]
873 fn verify_dpop_proof_rejects_disallowed_algorithm() {
874 let proof = create_dpop_proof(
875 "POST",
876 "https://api.example.com/mcp",
877 Some("token-value"),
878 &rsa_options(None),
879 )
880 .expect("proof");
881
882 let err = verify_dpop_proof(
883 &proof,
884 "POST",
885 "https://api.example.com/mcp",
886 DpopVerificationOptions {
887 expected_access_token: Some("token-value"),
888 expected_nonce: None,
889 allowed_algorithms: &[Algorithm::ES256],
890 clock_skew_seconds: 30,
891 max_age_seconds: 300,
892 },
893 )
894 .expect_err("disallowed alg should fail");
895 assert!(err.to_string().contains("not allowed"));
896 }
897
898 #[test]
899 fn ensure_supported_dpop_alg_accepts_es256_and_rs256() {
900 ensure_supported_dpop_alg(Algorithm::ES256).expect("ES256 supported");
901 ensure_supported_dpop_alg(Algorithm::RS256).expect("RS256 supported");
902 }
903
904 #[test]
905 fn ensure_supported_dpop_alg_rejects_other_algorithms() {
906 let err = ensure_supported_dpop_alg(Algorithm::HS256)
907 .expect_err("HS256 must be rejected for outbound DPoP");
908 let AuthplaneError::Auth(auth_error) = err else {
909 panic!("expected auth error");
910 };
911 assert_eq!(auth_error.code, "validation_error");
912 assert!(auth_error.message.contains("HS256"));
913 assert!(auth_error.message.contains("ES256"));
914 assert!(auth_error.message.contains("RS256"));
915 }
916
917 #[tokio::test]
925 async fn verify_dpop_proof_with_jkt_and_replay_leaves_jti_slot_free_on_jkt_mismatch() {
926 let proof = create_dpop_proof(
927 "POST",
928 "https://api.example.com/mcp",
929 Some("token-value"),
930 &rsa_options(None),
931 )
932 .expect("proof");
933
934 let peeked = verify_dpop_proof(
936 &proof,
937 "POST",
938 "https://api.example.com/mcp",
939 DpopVerificationOptions {
940 expected_access_token: Some("token-value"),
941 expected_nonce: None,
942 allowed_algorithms: &[Algorithm::RS256],
943 clock_skew_seconds: 30,
944 max_age_seconds: 300,
945 },
946 )
947 .expect("peek");
948
949 let replay_store = InMemoryDpopReplayStore::new();
950
951 let err = verify_dpop_proof_with_jkt_and_replay(
953 &proof,
954 "POST",
955 "https://api.example.com/mcp",
956 DpopVerificationOptions {
957 expected_access_token: Some("token-value"),
958 expected_nonce: None,
959 allowed_algorithms: &[Algorithm::RS256],
960 clock_skew_seconds: 30,
961 max_age_seconds: 300,
962 },
963 "definitely-not-the-real-jkt",
964 &replay_store,
965 )
966 .await
967 .expect_err("wrong jkt must reject");
968
969 assert!(matches!(err, VerifierError::DpopBindingMismatch { .. }));
970
971 let stored = replay_store
976 .check_and_store(&peeked.jti, peeked.iat + 300)
977 .await
978 .expect("check_and_store");
979 assert!(
980 stored,
981 "wrong-jkt proof leaked its jti into the replay store"
982 );
983 }
984
985 #[test]
986 fn from_header_values_zero_proofs_is_bearer_path() {
987 let ctx = DpopRequestContext::from_header_values(
988 "post",
989 "https://api.example.com/mcp",
990 Vec::<&str>::new(),
991 None,
992 )
993 .expect("zero proofs is valid");
994 assert_eq!(ctx.method, "POST");
995 assert_eq!(ctx.url, "https://api.example.com/mcp");
996 assert_eq!(ctx.proof, None);
997 assert_eq!(ctx.nonce, None);
998 }
999
1000 #[test]
1001 fn from_header_values_single_proof_is_trimmed() {
1002 let ctx = DpopRequestContext::from_header_values(
1003 "POST",
1004 "https://api.example.com/mcp",
1005 [" proof-jwt "],
1006 Some(" server-nonce "),
1007 )
1008 .expect("one proof is valid");
1009 assert_eq!(ctx.proof.as_deref(), Some("proof-jwt"));
1010 assert_eq!(ctx.nonce.as_deref(), Some("server-nonce"));
1011 }
1012
1013 #[test]
1014 fn from_header_values_multiple_proofs_rejected() {
1015 let err = DpopRequestContext::from_header_values(
1018 "POST",
1019 "https://api.example.com/mcp",
1020 ["first", "second"],
1021 None,
1022 )
1023 .expect_err("two proofs must reject");
1024 assert!(matches!(err, VerifierError::DpopMultipleProofs));
1025 }
1026
1027 #[test]
1028 fn from_header_values_comma_folded_proofs_rejected() {
1029 for folded in ["first,second", "first, second", ",,first,second"] {
1034 let err = DpopRequestContext::from_header_values(
1035 "POST",
1036 "https://api.example.com/mcp",
1037 [folded],
1038 None,
1039 )
1040 .expect_err("comma-folded proofs must reject");
1041 assert!(matches!(err, VerifierError::DpopMultipleProofs));
1042 }
1043 }
1044
1045 #[test]
1046 fn from_header_values_blank_line_does_not_count_as_proof() {
1047 let ctx = DpopRequestContext::from_header_values(
1050 "POST",
1051 "https://api.example.com/mcp",
1052 ["", "proof-jwt"],
1053 None,
1054 )
1055 .expect("blank line plus one proof is a single-proof request");
1056 assert_eq!(ctx.proof(), Some("proof-jwt"));
1057 }
1058
1059 #[test]
1060 fn from_header_values_whitespace_only_proof_is_bearer_path() {
1061 let ctx = DpopRequestContext::from_header_values(
1065 "POST",
1066 "https://api.example.com/mcp",
1067 [" "],
1068 None,
1069 )
1070 .expect("whitespace-only header is proof-absent");
1071 assert_eq!(ctx.proof(), None);
1072 }
1073
1074 #[test]
1075 fn new_normalizes_blank_proof_to_none() {
1076 let ctx = DpopRequestContext::new("post", "https://api.example.com/mcp", Some(" "), None);
1077 assert_eq!(ctx.proof(), None);
1078 assert_eq!(ctx.method, "POST");
1079 }
1080}