1use crate::error::{AuthError, Result};
19use crate::jti::JtiTracker;
20use async_trait::async_trait;
21use base64::Engine as _;
22use ed25519_dalek::{Signature, Verifier, VerifyingKey};
23use sha2::{Digest, Sha256};
24use std::sync::Arc;
25use std::time::Duration;
26
27const DPOP_ALG_EDDSA: &str = "EdDSA";
30
31pub trait DpopReplayStore: Send + Sync {
39 fn check_and_record(&self, jti: &[u8; 16]) -> Result<()>;
40}
41
42impl DpopReplayStore for JtiTracker {
43 fn check_and_record(&self, jti: &[u8; 16]) -> Result<()> {
44 JtiTracker::check_and_record(self, jti)
45 }
46}
47
48#[async_trait]
54pub trait AsyncDpopReplayStore: Send + Sync {
55 async fn check_and_record(&self, jti: &[u8; 16]) -> Result<()>;
56}
57
58#[derive(Clone)]
59enum ReplayStore {
60 Sync(Arc<dyn DpopReplayStore>),
61 Async(Arc<dyn AsyncDpopReplayStore>),
62}
63
64#[derive(Clone)]
66pub struct DpopVerifier {
67 replay_store: ReplayStore,
68 pub allowed_clock_skew: Duration,
69}
70
71impl DpopVerifier {
72 pub fn new(jti_tracker: Arc<dyn DpopReplayStore>) -> Self {
73 Self {
74 replay_store: ReplayStore::Sync(jti_tracker),
75 allowed_clock_skew: Duration::from_secs(30),
76 }
77 }
78
79 pub fn new_async(replay_store: Arc<dyn AsyncDpopReplayStore>) -> Self {
81 Self {
82 replay_store: ReplayStore::Async(replay_store),
83 allowed_clock_skew: Duration::from_secs(30),
84 }
85 }
86
87 pub fn verify(
104 &self,
105 dpop_header: &str,
106 access_token: &str,
107 htm: &str,
108 htu: &str,
109 expected_jkt: &str,
110 ) -> Result<()> {
111 let jti_key = self.verify_proof(dpop_header, access_token, htm, htu, expected_jkt)?;
112 match &self.replay_store {
113 ReplayStore::Sync(store) => store.check_and_record(&jti_key),
114 ReplayStore::Async(_) => Err(AuthError::DpopInvalid(
115 "asynchronous replay store requires verify_async".into(),
116 )),
117 }
118 }
119
120 pub async fn verify_async(
125 &self,
126 dpop_header: &str,
127 access_token: &str,
128 htm: &str,
129 htu: &str,
130 expected_jkt: &str,
131 ) -> Result<()> {
132 let jti_key = self.verify_proof(dpop_header, access_token, htm, htu, expected_jkt)?;
133 match &self.replay_store {
134 ReplayStore::Sync(store) => store.check_and_record(&jti_key),
135 ReplayStore::Async(store) => store.check_and_record(&jti_key).await,
136 }
137 }
138
139 fn verify_proof(
140 &self,
141 dpop_header: &str,
142 access_token: &str,
143 htm: &str,
144 htu: &str,
145 expected_jkt: &str,
146 ) -> Result<[u8; 16]> {
147 let (header, claims, signing_input, signature) = Self::parse_proof(dpop_header)?;
149 let jwk = self.check_header(&header)?;
151 let raw_key = parse_ed25519_jwk(jwk)?;
153 let thumbprint = jwk_thumbprint_ed25519(jwk)?;
154 if !constant_time_eq(thumbprint.as_bytes(), expected_jkt.as_bytes()) {
155 return Err(AuthError::DpopInvalid("jkt mismatch".into()));
156 }
157 verify_dpop_signature(&raw_key, &signing_input, &signature)?;
159 self.check_htm_htu_ath(&claims, htm, htu, access_token)?;
161 let jti_key = jti_to_replay_key(claims.get("jti").and_then(|v| v.as_str()))?;
163 Ok(jti_key)
164 }
165
166 fn parse_proof(
168 dpop_header: &str,
169 ) -> Result<(serde_json::Value, serde_json::Value, Vec<u8>, Vec<u8>)> {
170 let parts: Vec<&str> = dpop_header.split('.').collect();
171 if parts.len() != 3 {
172 return Err(AuthError::DpopInvalid("expected 3 segments".into()));
173 }
174 let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
175 .decode(parts[0])
176 .map_err(|e| AuthError::DpopInvalid(format!("header b64: {}", e)))?;
177 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
178 .map_err(|e| AuthError::DpopInvalid(format!("header json: {}", e)))?;
179 let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
180 .decode(parts[1])
181 .map_err(|e| AuthError::DpopInvalid(format!("claims b64: {}", e)))?;
182 let claims: serde_json::Value = serde_json::from_slice(&claims_bytes)
183 .map_err(|e| AuthError::DpopInvalid(format!("claims json: {}", e)))?;
184 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
185 .decode(parts[2])
186 .map_err(|e| AuthError::DpopInvalid(format!("sig b64: {}", e)))?;
187 let signing_input = format!("{}.{}", parts[0], parts[1]).into_bytes();
188 Ok((header, claims, signing_input, signature))
189 }
190
191 fn check_header<'a>(&self, header: &'a serde_json::Value) -> Result<&'a serde_json::Value> {
194 let header_typ = header.get("typ").and_then(|v| v.as_str()).unwrap_or("");
195 if header_typ != "dpop+jwt" {
196 return Err(AuthError::DpopInvalid(format!(
197 "header typ must be 'dpop+jwt', got {:?}",
198 header_typ
199 )));
200 }
201 let header_alg = header
202 .get("alg")
203 .and_then(|v| v.as_str())
204 .ok_or_else(|| AuthError::DpopInvalid("missing alg".into()))?;
205 if header_alg != DPOP_ALG_EDDSA {
206 return Err(AuthError::DpopInvalid(format!(
207 "alg {:?} not in DPoP whitelist",
208 header_alg
209 )));
210 }
211 header
212 .get("jwk")
213 .ok_or_else(|| AuthError::DpopInvalid("missing jwk in header".into()))
214 }
215
216 fn check_htm_htu_ath(
218 &self,
219 claims: &serde_json::Value,
220 htm: &str,
221 htu: &str,
222 access_token: &str,
223 ) -> Result<()> {
224 let claim_htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or("");
225 if claim_htm != htm {
226 return Err(AuthError::DpopInvalid(format!(
227 "htm mismatch: expected {}, got {}",
228 htm, claim_htm
229 )));
230 }
231 let claim_htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or("");
232 if claim_htu != htu {
233 return Err(AuthError::DpopHtuMismatch {
234 expected: htu.to_string(),
235 actual: claim_htu.to_string(),
236 });
237 }
238 let mut hasher = Sha256::new();
239 hasher.update(access_token.as_bytes());
240 let expected_ath =
241 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
242 let claim_ath = claims.get("ath").and_then(|v| v.as_str()).unwrap_or("");
243 if claim_ath != expected_ath {
244 return Err(AuthError::DpopInvalid("ath mismatch".into()));
245 }
246 Ok(())
247 }
248}
249
250fn verify_dpop_signature(raw_key: &[u8; 32], signing_input: &[u8], signature: &[u8]) -> Result<()> {
253 let vk = VerifyingKey::from_bytes(raw_key)
254 .map_err(|e| AuthError::DpopInvalid(format!("jwk key: {}", e)))?;
255 let sig = Signature::from_slice(signature)
256 .map_err(|e| AuthError::DpopInvalid(format!("ed25519 sig: {}", e)))?;
257 vk.verify(signing_input, &sig)
258 .map_err(|_| AuthError::DpopInvalid("signature verification failed".into()))
259}
260
261fn jti_to_replay_key(jti: Option<&str>) -> Result<[u8; 16]> {
265 let jti_str = jti.ok_or_else(|| AuthError::DpopInvalid("missing jti".into()))?;
266 let mut hasher = Sha256::new();
267 hasher.update(jti_str.as_bytes());
268 let digest = hasher.finalize();
269 let mut key = [0u8; 16];
270 key.copy_from_slice(&digest[..16]);
271 Ok(key)
272}
273
274fn parse_ed25519_jwk(jwk: &serde_json::Value) -> Result<[u8; 32]> {
277 let obj = jwk
278 .as_object()
279 .ok_or_else(|| AuthError::DpopInvalid("jwk must be a JSON object".into()))?;
280 let kty = obj
281 .get("kty")
282 .and_then(|v| v.as_str())
283 .ok_or_else(|| AuthError::DpopInvalid("jwk missing kty".into()))?;
284 if kty != "OKP" {
285 return Err(AuthError::DpopInvalid(format!(
286 "jwk kty must be OKP, got {}",
287 kty
288 )));
289 }
290 let crv = obj
291 .get("crv")
292 .and_then(|v| v.as_str())
293 .ok_or_else(|| AuthError::DpopInvalid("jwk missing crv".into()))?;
294 if crv != "Ed25519" {
295 return Err(AuthError::DpopInvalid(format!(
296 "jwk crv must be Ed25519, got {}",
297 crv
298 )));
299 }
300 let x = obj
301 .get("x")
302 .and_then(|v| v.as_str())
303 .ok_or_else(|| AuthError::DpopInvalid("jwk missing x".into()))?;
304 let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
305 .decode(x)
306 .map_err(|e| AuthError::DpopInvalid(format!("jwk x b64: {}", e)))?;
307 if raw.len() != 32 {
308 return Err(AuthError::DpopInvalid(format!(
309 "jwk x must be 32 bytes, got {}",
310 raw.len()
311 )));
312 }
313 let mut out = [0u8; 32];
314 out.copy_from_slice(&raw);
315 Ok(out)
316}
317
318fn jwk_thumbprint_ed25519(jwk: &serde_json::Value) -> Result<String> {
324 let obj = jwk
325 .as_object()
326 .ok_or_else(|| AuthError::DpopInvalid("jwk not object".into()))?;
327 let crv = obj
328 .get("crv")
329 .and_then(|v| v.as_str())
330 .ok_or_else(|| AuthError::DpopInvalid("jwk missing crv".into()))?;
331 let x = obj
332 .get("x")
333 .and_then(|v| v.as_str())
334 .ok_or_else(|| AuthError::DpopInvalid("jwk missing x".into()))?;
335 Ok(agentguard_core::jwk::thumbprint_ed25519(x, crv))
336}
337
338fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
341 use subtle::ConstantTimeEq;
347 a.ct_eq(b).into()
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use ed25519_dalek::{Signer, SigningKey};
354 use rand::rngs::OsRng;
355 use std::collections::HashSet;
356
357 struct RejectingReplayStore;
358
359 impl DpopReplayStore for RejectingReplayStore {
360 fn check_and_record(&self, _jti: &[u8; 16]) -> Result<()> {
361 Err(AuthError::Other("shared replay store unavailable".into()))
362 }
363 }
364
365 #[derive(Default)]
366 struct AsyncSetReplayStore(std::sync::Mutex<HashSet<[u8; 16]>>);
367
368 #[async_trait]
369 impl AsyncDpopReplayStore for AsyncSetReplayStore {
370 async fn check_and_record(&self, jti: &[u8; 16]) -> Result<()> {
371 let mut seen = self.0.lock().expect("test replay store lock poisoned");
372 if !seen.insert(*jti) {
373 return Err(AuthError::DpopReplay("proof already used".into()));
374 }
375 Ok(())
376 }
377 }
378
379 fn make_dpop(signer: &SigningKey, jwk_x_b64: &str, jti: &str, claims_extras: &str) -> String {
380 let header = serde_json::json!({
381 "alg": "EdDSA",
382 "typ": "dpop+jwt",
383 "jwk": {"kty": "OKP", "crv": "Ed25519", "x": jwk_x_b64},
384 });
385 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
386 .encode(serde_json::to_vec(&header).unwrap());
387 let now = chrono::Utc::now().timestamp();
388 let claims_json = if claims_extras.is_empty() {
389 format!(r#"{{"jti":"{jti}","htm":"POST","htu":"https://example.com/x","iat":{now}}}"#)
390 } else {
391 format!(
392 r#"{{"jti":"{jti}","htm":"POST","htu":"https://example.com/x","iat":{now},{extra}}}"#,
393 extra = claims_extras
394 )
395 };
396 let p = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims_json.as_bytes());
397 let signing_input = format!("{}.{}", h, p);
398 let sig = signer.sign(signing_input.as_bytes());
399 let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
400 format!("{}.{}.{}", h, p, s)
401 }
402
403 fn make_jkt(pub_key: &[u8; 32]) -> String {
404 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key);
405 let canonical = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#, x);
406 let mut h = Sha256::new();
407 h.update(canonical.as_bytes());
408 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h.finalize())
409 }
410
411 #[test]
412 fn valid_dpop_accepted() {
413 let mut csprng = OsRng;
414 let signer = SigningKey::generate(&mut csprng);
415 let pub_bytes = signer.verifying_key().to_bytes();
416 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
417 let jkt = make_jkt(&pub_bytes);
418 let mut hasher = Sha256::new();
419 hasher.update(b"access-token");
420 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
421 let extras = format!(r#""ath":"{}""#, ath);
422 let dpop = make_dpop(&signer, &x, "abc", &extras);
423
424 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
425 let v = DpopVerifier::new(tracker);
426 v.verify(&dpop, "access-token", "POST", "https://example.com/x", &jkt)
427 .unwrap();
428 }
429
430 #[test]
431 fn htu_mismatch_rejected() {
432 let mut csprng = OsRng;
433 let signer = SigningKey::generate(&mut csprng);
434 let pub_bytes = signer.verifying_key().to_bytes();
435 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
436 let jkt = make_jkt(&pub_bytes);
437 let dpop = make_dpop(&signer, &x, "abc", "");
438 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
439 let v = DpopVerifier::new(tracker);
440 let err = v
441 .verify(&dpop, "tok", "POST", "https://example.com/y", &jkt)
442 .unwrap_err();
443 assert!(
444 matches!(err, AuthError::DpopHtuMismatch { .. }),
445 "expected DpopHtuMismatch, got {:?}",
446 err
447 );
448 }
449
450 #[test]
451 fn htm_mismatch_rejected() {
452 let mut csprng = OsRng;
453 let signer = SigningKey::generate(&mut csprng);
454 let pub_bytes = signer.verifying_key().to_bytes();
455 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
456 let jkt = make_jkt(&pub_bytes);
457 let dpop = make_dpop(&signer, &x, "abc", "");
458 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
459 let v = DpopVerifier::new(tracker);
460 let err = v
461 .verify(&dpop, "tok", "GET", "https://example.com/x", &jkt)
462 .unwrap_err();
463 assert!(matches!(err, AuthError::DpopInvalid(_)));
464 }
465
466 #[test]
467 fn ath_mismatch_rejected() {
468 let mut csprng = OsRng;
469 let signer = SigningKey::generate(&mut csprng);
470 let pub_bytes = signer.verifying_key().to_bytes();
471 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
472 let jkt = make_jkt(&pub_bytes);
473 let mut hasher = Sha256::new();
474 hasher.update(b"real-token");
475 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
476 let extras = format!(r#""ath":"{}""#, ath);
477 let dpop = make_dpop(&signer, &x, "abc", &extras);
478 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
479 let v = DpopVerifier::new(tracker);
480 let err = v
481 .verify(
482 &dpop,
483 "different-token",
484 "POST",
485 "https://example.com/x",
486 &jkt,
487 )
488 .unwrap_err();
489 assert!(matches!(err, AuthError::DpopInvalid(_)));
490 }
491
492 #[test]
493 fn missing_jti_rejected() {
494 let mut csprng = OsRng;
495 let signer = SigningKey::generate(&mut csprng);
496 let pub_bytes = signer.verifying_key().to_bytes();
497 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
498 let jkt = make_jkt(&pub_bytes);
499 let header = serde_json::json!({
501 "alg": "EdDSA",
502 "typ": "dpop+jwt",
503 "jwk": {"kty": "OKP", "crv": "Ed25519", "x": x},
504 });
505 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
506 .encode(serde_json::to_vec(&header).unwrap());
507 let claims = serde_json::json!({
508 "htm": "POST",
509 "htu": "https://example.com/x",
510 "iat": chrono::Utc::now().timestamp(),
511 });
512 let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
513 .encode(serde_json::to_vec(&claims).unwrap());
514 let signing_input = format!("{}.{}", h, p);
515 let sig = signer.sign(signing_input.as_bytes());
516 let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
517 let dpop = format!("{}.{}.{}", h, p, s);
518 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
519 let v = DpopVerifier::new(tracker);
520 let err = v
521 .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
522 .unwrap_err();
523 assert!(matches!(err, AuthError::DpopInvalid(_)));
524 }
525
526 #[test]
527 fn malformed_dpop_header_rejected() {
528 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
529 let v = DpopVerifier::new(tracker);
530 let err = v
531 .verify("only.two", "token", "POST", "https://example.com/x", "any")
532 .unwrap_err();
533 assert!(matches!(err, AuthError::DpopInvalid(_)));
534 }
535
536 #[test]
537 fn jkt_mismatch_rejected() {
538 let mut csprng = OsRng;
541 let signer = SigningKey::generate(&mut csprng);
542 let pub_bytes = signer.verifying_key().to_bytes();
543 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
544 let mut hasher = Sha256::new();
545 hasher.update(b"tok");
546 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
547 let extras = format!(r#""ath":"{}""#, ath);
548 let dpop = make_dpop(&signer, &x, "abc", &extras);
549 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
550 let v = DpopVerifier::new(tracker);
551 let wrong_jkt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
552 let err = v
553 .verify(&dpop, "tok", "POST", "https://example.com/x", wrong_jkt)
554 .unwrap_err();
555 assert!(matches!(err, AuthError::DpopInvalid(_)));
556 }
557
558 #[test]
559 fn signature_tamper_rejected() {
560 let mut csprng = OsRng;
564 let signer = SigningKey::generate(&mut csprng);
565 let pub_bytes = signer.verifying_key().to_bytes();
566 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
567 let jkt = make_jkt(&pub_bytes);
568 let mut hasher = Sha256::new();
569 hasher.update(b"tok");
570 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
571 let extras = format!(r#""ath":"{}""#, ath);
572 let mut dpop = make_dpop(&signer, &x, "abc", &extras);
573 let idx = dpop.rfind('.').unwrap() + 1;
575 let mut chars: Vec<char> = dpop.chars().collect();
576 let sig_start = dpop[..idx].chars().count();
577 let c = chars[sig_start];
578 chars[sig_start] = if c == 'A' { 'B' } else { 'A' };
579 dpop = chars.into_iter().collect();
580 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
581 let v = DpopVerifier::new(tracker);
582 let err = v
583 .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
584 .unwrap_err();
585 assert!(matches!(err, AuthError::DpopInvalid(_)));
586 }
587
588 #[test]
589 fn replay_rejected() {
590 let mut csprng = OsRng;
592 let signer = SigningKey::generate(&mut csprng);
593 let pub_bytes = signer.verifying_key().to_bytes();
594 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
595 let jkt = make_jkt(&pub_bytes);
596 let mut hasher = Sha256::new();
597 hasher.update(b"tok");
598 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
599 let extras = format!(r#""ath":"{}""#, ath);
600 let dpop = make_dpop(&signer, &x, "unique-jti", &extras);
601 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
602 let v = DpopVerifier::new(tracker);
603 v.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
604 .unwrap();
605 let err = v
606 .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
607 .unwrap_err();
608 assert!(matches!(err, AuthError::DpopReplay(_)));
609 }
610
611 #[test]
612 fn replay_tracker_capacity_fails_closed_for_new_proofs() {
613 let mut csprng = OsRng;
614 let signer = SigningKey::generate(&mut csprng);
615 let pub_bytes = signer.verifying_key().to_bytes();
616 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
617 let jkt = make_jkt(&pub_bytes);
618 let mut hasher = Sha256::new();
619 hasher.update(b"tok");
620 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
621 let extras = format!(r#""ath":"{}""#, ath);
622 let tracker = Arc::new(JtiTracker::with_capacity(Duration::from_secs(60), 1).unwrap());
623 let verifier = DpopVerifier::new(tracker);
624 let first = make_dpop(&signer, &x, "first-proof", &extras);
625 let second = make_dpop(&signer, &x, "second-proof", &extras);
626
627 verifier
628 .verify(&first, "tok", "POST", "https://example.com/x", &jkt)
629 .unwrap();
630 assert!(matches!(
631 verifier.verify(&second, "tok", "POST", "https://example.com/x", &jkt),
632 Err(AuthError::DpopCapacityExceeded)
633 ));
634 assert!(matches!(
635 verifier.verify(&first, "tok", "POST", "https://example.com/x", &jkt),
636 Err(AuthError::DpopReplay(_))
637 ));
638 }
639
640 #[test]
641 fn verifier_propagates_custom_replay_store_failures() {
642 let mut csprng = OsRng;
643 let signer = SigningKey::generate(&mut csprng);
644 let pub_bytes = signer.verifying_key().to_bytes();
645 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
646 let jkt = make_jkt(&pub_bytes);
647 let mut hasher = Sha256::new();
648 hasher.update(b"tok");
649 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
650 let extras = format!(r#""ath":"{}""#, ath);
651 let dpop = make_dpop(&signer, &x, "store-error-proof", &extras);
652 let verifier = DpopVerifier::new(Arc::new(RejectingReplayStore));
653
654 assert!(matches!(
655 verifier.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt),
656 Err(AuthError::Other(message)) if message == "shared replay store unavailable"
657 ));
658 }
659
660 #[tokio::test]
661 async fn async_verifier_uses_store_and_rejects_replays() {
662 let mut csprng = OsRng;
663 let signer = SigningKey::generate(&mut csprng);
664 let pub_bytes = signer.verifying_key().to_bytes();
665 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
666 let jkt = make_jkt(&pub_bytes);
667 let mut hasher = Sha256::new();
668 hasher.update(b"tok");
669 let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
670 let dpop = make_dpop(&signer, &x, "async-proof", &format!(r#""ath":"{}""#, ath));
671 let verifier = DpopVerifier::new_async(Arc::new(AsyncSetReplayStore::default()));
672
673 verifier
674 .verify_async(&dpop, "tok", "POST", "https://example.com/x", &jkt)
675 .await
676 .unwrap();
677 assert!(matches!(
678 verifier
679 .verify_async(&dpop, "tok", "POST", "https://example.com/x", &jkt)
680 .await,
681 Err(AuthError::DpopReplay(_))
682 ));
683 assert!(matches!(
684 verifier.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt),
685 Err(AuthError::DpopInvalid(message)) if message.contains("verify_async")
686 ));
687 }
688
689 #[test]
690 fn jwk_kty_ec_rejected() {
691 let mut csprng = OsRng;
695 let signer = SigningKey::generate(&mut csprng);
696 let pub_bytes = signer.verifying_key().to_bytes();
697 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
698 let header = serde_json::json!({
699 "alg": "EdDSA",
700 "typ": "dpop+jwt",
701 "jwk": {
702 "kty": "EC",
703 "crv": "Ed25519", "x": x,
705 }
706 });
707 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
708 .encode(serde_json::to_vec(&header).unwrap());
709 let claims = serde_json::json!({
710 "jti": "some-jti",
711 "htm": "POST",
712 "htu": "https://example.com/x",
713 "iat": chrono::Utc::now().timestamp(),
714 "ath": "x",
715 });
716 let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
717 .encode(serde_json::to_vec(&claims).unwrap());
718 let signing_input = format!("{h}.{p}");
719 let sig = signer.sign(signing_input.as_bytes());
720 let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
721 let token = format!("{signing_input}.{s}");
722 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
723 let v = DpopVerifier::new(tracker);
724 let err = v
725 .verify(&token, "x", "POST", "https://example.com/x", "jkt")
726 .unwrap_err();
727 assert!(matches!(err, AuthError::DpopInvalid(_)));
728 }
729
730 #[test]
731 fn alg_hs256_dpop_rejected() {
732 let mut csprng = OsRng;
737 let signer = SigningKey::generate(&mut csprng);
738 let pub_bytes = signer.verifying_key().to_bytes();
739 let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
740 let header = serde_json::json!({
741 "alg": "HS256",
742 "typ": "dpop+jwt",
743 "jwk": {"kty": "OKP", "crv": "Ed25519", "x": x},
744 });
745 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
746 .encode(serde_json::to_vec(&header).unwrap());
747 let claims = serde_json::json!({
748 "jti": "j",
749 "htm": "POST",
750 "htu": "https://example.com/x",
751 "iat": chrono::Utc::now().timestamp(),
752 "ath": "x",
753 });
754 let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
755 .encode(serde_json::to_vec(&claims).unwrap());
756 let signing_input = format!("{h}.{p}");
757 let sig = signer.sign(signing_input.as_bytes());
758 let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
759 let token = format!("{signing_input}.{s}");
760 let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
761 let v = DpopVerifier::new(tracker);
762 assert!(v
763 .verify(&token, "x", "POST", "https://example.com/x", "jkt")
764 .is_err());
765 }
766}