1use crate::errors::{JWKError, JWTError};
7use anyhow::Result;
8use atproto_identity::key::{KeyData, to_public, validate};
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10use elliptic_curve::JwkEcKey;
11use reqwest::header::HeaderValue;
12use reqwest_chain::Chainer;
13use ulid::Ulid;
14
15use crate::{
16 errors::DpopError,
17 jwk::{WrappedJsonWebKey, thumbprint, to_key_data},
18 jwt::{Claims, Header, JoseClaims, mint},
19 pkce::challenge,
20};
21
22#[derive(Clone)]
28pub struct DpopRetry {
29 pub header: Header,
31 pub claims: Claims,
33 pub key_data: KeyData,
35
36 pub check_response_body: bool,
38}
39
40impl DpopRetry {
41 pub fn new(
48 header: Header,
49 claims: Claims,
50 key_data: KeyData,
51 check_response_body: bool,
52 ) -> Self {
53 DpopRetry {
54 header,
55 claims,
56 key_data,
57 check_response_body,
58 }
59 }
60}
61
62#[async_trait::async_trait]
72impl Chainer for DpopRetry {
73 type State = ();
74
75 async fn chain(
87 &self,
88 result: Result<reqwest::Response, reqwest_middleware::Error>,
89 _state: &mut Self::State,
90 request: &mut reqwest::Request,
91 ) -> Result<Option<reqwest::Response>, reqwest_middleware::Error> {
92 let response = result?;
93
94 let status_code = response.status();
95
96 let dpop_status_code = status_code == 400 || status_code == 401;
97 if !dpop_status_code {
98 return Ok(Some(response));
99 };
100
101 let headers = response.headers().clone();
102 let www_authenticate_header = headers.get("WWW-Authenticate");
103 let www_authenticate_value = www_authenticate_header.and_then(|value| value.to_str().ok());
104 let dpop_header_error = www_authenticate_value.is_some_and(is_dpop_error);
105
106 if !dpop_header_error && !self.check_response_body {
107 return Ok(Some(response));
108 };
109
110 if self.check_response_body {
111 let response_body = match response.json::<serde_json::Value>().await {
112 Err(err) => {
113 return Err(reqwest_middleware::Error::Middleware(
114 DpopError::ResponseBodyParsingFailed(err).into(),
115 ));
116 }
117 Ok(value) => value,
118 };
119 if let Some(response_body_obj) = response_body.as_object() {
120 let error_value = response_body_obj
121 .get("error")
122 .and_then(|value| value.as_str())
123 .unwrap_or("placeholder_unknown_error");
124
125 if error_value != "invalid_dpop_proof" && error_value != "use_dpop_nonce" {
126 return Err(reqwest_middleware::Error::Middleware(
127 DpopError::UnexpectedOAuthError {
128 error: error_value.to_string(),
129 }
130 .into(),
131 ));
132 }
133 } else {
134 return Err(reqwest_middleware::Error::Middleware(
135 DpopError::ResponseBodyObjectParsingFailed.into(),
136 ));
137 }
138 };
139
140 let dpop_header = headers
141 .get("DPoP-Nonce")
142 .and_then(|value| value.to_str().ok());
143
144 if dpop_header.is_none() {
145 return Err(reqwest_middleware::Error::Middleware(
146 DpopError::MissingDpopNonceHeader.into(),
147 ));
148 }
149 let dpop_header = dpop_header.unwrap();
150
151 let dpop_proof_header = self.header.clone();
152 let mut dpop_proof_claim = self.claims.clone();
153 dpop_proof_claim
154 .private
155 .insert("nonce".to_string(), dpop_header.to_string().into());
156
157 let dpop_proof_token = mint(&self.key_data, &dpop_proof_header, &dpop_proof_claim)
158 .map_err(|err| {
159 reqwest_middleware::Error::Middleware(DpopError::TokenMintingFailed(err).into())
160 })?;
161
162 request.headers_mut().insert(
163 "DPoP",
164 HeaderValue::from_str(&dpop_proof_token).map_err(|err| {
165 reqwest_middleware::Error::Middleware(DpopError::HeaderCreationFailed(err).into())
166 })?,
167 );
168
169 Ok(None)
170 }
171}
172
173pub fn is_dpop_error(value: &str) -> bool {
206 if !value.trim_start().starts_with("DPoP") {
208 return false;
209 }
210
211 let params_part = value.trim_start().strip_prefix("DPoP").unwrap_or("").trim();
213
214 for part in params_part.split(',') {
216 let trimmed = part.trim();
217
218 if let Some(equals_pos) = trimmed.find('=') {
220 let (key, value_part) = trimmed.split_at(equals_pos);
221 let key = key.trim();
222
223 if key == "error" {
224 let value_part = &value_part[1..]; let value_part = value_part.trim();
227
228 let error_value = if let Some(stripped) = value_part.strip_prefix('"') {
230 if value_part.ends_with('"') && value_part.len() >= 2 {
231 &value_part[1..value_part.len() - 1]
232 } else {
233 stripped }
235 } else if let Some(stripped) = value_part.strip_suffix('"') {
236 stripped } else {
238 value_part
239 };
240
241 return error_value == "invalid_dpop_proof" || error_value == "use_dpop_nonce";
242 }
243 }
244 }
245
246 false
247}
248
249pub fn auth_dpop(
268 key_data: &KeyData,
269 http_method: &str,
270 http_uri: &str,
271) -> anyhow::Result<(String, Header, Claims)> {
272 build_dpop(key_data, http_method, http_uri, None)
273}
274
275pub fn request_dpop(
296 key_data: &KeyData,
297 http_method: &str,
298 http_uri: &str,
299 oauth_access_token: &str,
300) -> anyhow::Result<(String, Header, Claims)> {
301 build_dpop(key_data, http_method, http_uri, Some(oauth_access_token))
302}
303
304fn build_dpop(
305 key_data: &KeyData,
306 http_method: &str,
307 http_uri: &str,
308 access_token: Option<&str>,
309) -> anyhow::Result<(String, Header, Claims)> {
310 let now = chrono::Utc::now();
311
312 let public_key_data = to_public(key_data)?;
313 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
314
315 let header = Header {
316 type_: Some("dpop+jwt".to_string()),
317 algorithm: Some("ES256".to_string()),
318 json_web_key: Some(dpop_jwk),
319 key_id: None,
320 };
321
322 let auth = access_token.map(challenge);
323 let issued_at = Some(now.timestamp() as u64);
324 let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64);
325
326 let claims = Claims::new(JoseClaims {
327 auth,
328 expiration,
329 http_method: Some(http_method.to_string()),
330 http_uri: Some(http_uri.to_string()),
331 issued_at,
332 json_web_token_id: Some(Ulid::new().to_string()),
333 ..Default::default()
334 });
335
336 let token = mint(key_data, &header, &claims)?;
337
338 Ok((token, header, claims))
339}
340
341pub fn extract_jwk_thumbprint(dpop_jwt: &str) -> Result<String> {
372 let parts: Vec<&str> = dpop_jwt.split('.').collect();
374 if parts.len() != 3 {
375 return Err(JWTError::InvalidFormat.into());
376 }
377
378 let encoded_header = parts[0];
379
380 let header_bytes = URL_SAFE_NO_PAD
382 .decode(encoded_header)
383 .map_err(|_| JWTError::InvalidHeader)?;
384
385 let header_json: serde_json::Value =
387 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
388
389 let jwk_value = header_json
391 .get("jwk")
392 .ok_or_else(|| JWTError::MissingClaim {
393 claim: "jwk".to_string(),
394 })?;
395
396 let jwk_object = jwk_value
398 .as_object()
399 .ok_or_else(|| JWKError::MissingField {
400 field: "jwk object".to_string(),
401 })?;
402
403 let mut filtered_jwk = serde_json::Map::new();
405 for field in ["kty", "crv", "x", "y", "d"] {
406 if let Some(value) = jwk_object.get(field) {
407 filtered_jwk.insert(field.to_string(), value.clone());
408 }
409 }
410
411 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
413 .map_err(|e| JWKError::SerializationError {
414 message: e.to_string(),
415 })?;
416
417 let wrapped_jwk = WrappedJsonWebKey {
419 kid: None, alg: None, _use: None, jwk: jwk_ec_key,
423 };
424
425 thumbprint(&wrapped_jwk).map_err(|e| e.into())
427}
428
429#[cfg_attr(debug_assertions, derive(Debug))]
433#[derive(Clone)]
434pub struct DpopValidationConfig {
435 pub expected_http_method: Option<String>,
437 pub expected_http_uri: Option<String>,
439 pub expected_access_token_hash: Option<String>,
441 pub max_age_seconds: u64,
443 pub allow_future_iat: bool,
445 pub clock_skew_tolerance_seconds: u64,
447 pub expected_nonce_values: Vec<String>,
449 pub now: i64,
451}
452
453impl Default for DpopValidationConfig {
454 fn default() -> Self {
455 let now = chrono::Utc::now().timestamp();
456 Self {
457 expected_http_method: None,
458 expected_http_uri: None,
459 expected_access_token_hash: None,
460 max_age_seconds: 60,
461 allow_future_iat: false,
462 clock_skew_tolerance_seconds: 30,
463 expected_nonce_values: Vec::new(),
464 now,
465 }
466 }
467}
468
469impl DpopValidationConfig {
470 pub fn for_authorization(http_method: &str, http_uri: &str) -> Self {
472 Self {
473 expected_http_method: Some(http_method.to_string()),
474 expected_http_uri: Some(http_uri.to_string()),
475 expected_access_token_hash: None,
476 ..Default::default()
477 }
478 }
479
480 pub fn for_resource_request(http_method: &str, http_uri: &str, access_token: &str) -> Self {
482 Self {
483 expected_http_method: Some(http_method.to_string()),
484 expected_http_uri: Some(http_uri.to_string()),
485 expected_access_token_hash: Some(challenge(access_token)),
486 ..Default::default()
487 }
488 }
489}
490
491pub fn validate_dpop_jwt(dpop_jwt: &str, config: &DpopValidationConfig) -> Result<String> {
530 let parts: Vec<&str> = dpop_jwt.split('.').collect();
532 if parts.len() != 3 {
533 return Err(JWTError::InvalidFormat.into());
534 }
535
536 let (encoded_header, encoded_payload, encoded_signature) = (parts[0], parts[1], parts[2]);
537
538 let header_bytes = URL_SAFE_NO_PAD
540 .decode(encoded_header)
541 .map_err(|_| JWTError::InvalidHeader)?;
542
543 let header_json: serde_json::Value =
544 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
545
546 let typ = header_json
548 .get("typ")
549 .and_then(|v| v.as_str())
550 .ok_or_else(|| JWTError::MissingClaim {
551 claim: "typ".to_string(),
552 })?;
553
554 if typ != "dpop+jwt" {
555 return Err(JWTError::InvalidTokenType {
556 expected: "dpop+jwt".to_string(),
557 actual: typ.to_string(),
558 }
559 .into());
560 }
561
562 let alg = header_json
564 .get("alg")
565 .and_then(|v| v.as_str())
566 .ok_or_else(|| JWTError::MissingClaim {
567 claim: "alg".to_string(),
568 })?;
569
570 if !matches!(alg, "ES256" | "ES384" | "ES256K") {
571 return Err(JWTError::UnsupportedAlgorithm {
572 algorithm: alg.to_string(),
573 key_type: "EC".to_string(),
574 }
575 .into());
576 }
577
578 let jwk_value = header_json
580 .get("jwk")
581 .ok_or_else(|| JWTError::MissingClaim {
582 claim: "jwk".to_string(),
583 })?;
584
585 let jwk_object = jwk_value
586 .as_object()
587 .ok_or_else(|| JWKError::MissingField {
588 field: "jwk object".to_string(),
589 })?;
590
591 let mut filtered_jwk = serde_json::Map::new();
593 for field in ["kty", "crv", "x", "y", "d"] {
594 if let Some(value) = jwk_object.get(field) {
595 filtered_jwk.insert(field.to_string(), value.clone());
596 }
597 }
598
599 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
600 .map_err(|e| JWKError::SerializationError {
601 message: e.to_string(),
602 })?;
603
604 let wrapped_jwk = WrappedJsonWebKey {
606 kid: None,
607 alg: Some(alg.to_string()),
608 _use: Some("sig".to_string()),
609 jwk: jwk_ec_key,
610 };
611
612 let key_data = to_key_data(&wrapped_jwk)?;
614
615 let payload_bytes = URL_SAFE_NO_PAD
617 .decode(encoded_payload)
618 .map_err(|_| JWTError::InvalidPayload)?;
619
620 let claims: serde_json::Value =
621 serde_json::from_slice(&payload_bytes).map_err(|_| JWTError::InvalidPayloadJson)?;
622
623 claims
626 .get("jti")
627 .and_then(|v| v.as_str())
628 .ok_or_else(|| JWTError::MissingClaim {
629 claim: "jti".to_string(),
630 })?;
631
632 if let Some(expected_method) = &config.expected_http_method {
633 let htm =
635 claims
636 .get("htm")
637 .and_then(|v| v.as_str())
638 .ok_or_else(|| JWTError::MissingClaim {
639 claim: "htm".to_string(),
640 })?;
641
642 if htm != expected_method {
643 return Err(JWTError::HttpMethodMismatch {
644 expected: expected_method.clone(),
645 actual: htm.to_string(),
646 }
647 .into());
648 }
649 }
650
651 if let Some(expected_uri) = &config.expected_http_uri {
652 let htu =
654 claims
655 .get("htu")
656 .and_then(|v| v.as_str())
657 .ok_or_else(|| JWTError::MissingClaim {
658 claim: "htu".to_string(),
659 })?;
660
661 if htu != expected_uri {
662 return Err(JWTError::HttpUriMismatch {
663 expected: expected_uri.clone(),
664 actual: htu.to_string(),
665 }
666 .into());
667 }
668 }
669
670 let iat = claims
672 .get("iat")
673 .and_then(|v| v.as_u64())
674 .ok_or_else(|| JWTError::MissingClaim {
675 claim: "iat".to_string(),
676 })?;
677
678 if config.now as u64 > iat + config.max_age_seconds + config.clock_skew_tolerance_seconds {
680 return Err(JWTError::InvalidTimestamp {
681 reason: format!(
682 "Token too old: issued at {} but max age is {} seconds",
683 iat, config.max_age_seconds
684 ),
685 }
686 .into());
687 }
688
689 if !config.allow_future_iat && iat > config.now as u64 + config.clock_skew_tolerance_seconds {
691 return Err(JWTError::InvalidTimestamp {
692 reason: format!(
693 "Token from future: issued at {} but current time is {}",
694 iat, config.now
695 ),
696 }
697 .into());
698 }
699
700 if let Some(expected_ath) = &config.expected_access_token_hash {
702 let ath =
703 claims
704 .get("ath")
705 .and_then(|v| v.as_str())
706 .ok_or_else(|| JWTError::MissingClaim {
707 claim: "ath".to_string(),
708 })?;
709
710 if ath != expected_ath {
711 return Err(JWTError::AccessTokenHashMismatch.into());
712 }
713 }
714
715 if !config.expected_nonce_values.is_empty() {
717 let nonce = claims
718 .get("nonce")
719 .and_then(|v| v.as_str())
720 .ok_or_else(|| JWTError::MissingClaim {
721 claim: "nonce".to_string(),
722 })?;
723
724 if !config.expected_nonce_values.contains(&nonce.to_string()) {
725 return Err(JWTError::InvalidNonce {
726 nonce: nonce.to_string(),
727 }
728 .into());
729 }
730 }
731
732 if let Some(exp_value) = claims.get("exp")
734 && let Some(exp) = exp_value.as_u64()
735 && config.now as u64 >= exp
736 {
737 return Err(JWTError::TokenExpired.into());
738 }
739
740 let content = format!("{}.{}", encoded_header, encoded_payload);
742 let signature_bytes = URL_SAFE_NO_PAD
743 .decode(encoded_signature)
744 .map_err(|_| JWTError::InvalidSignature)?;
745
746 validate(&key_data, &signature_bytes, content.as_bytes())
747 .map_err(|_| JWTError::SignatureVerificationFailed)?;
748
749 thumbprint(&wrapped_jwk).map_err(|e| e.into())
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn test_is_dpop_error_invalid_dpop_proof() {
759 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#;
760 assert!(is_dpop_error(header));
761 }
762
763 #[test]
764 fn test_is_dpop_error_use_dpop_nonce() {
765 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#;
766 assert!(is_dpop_error(header));
767 }
768
769 #[test]
770 fn test_is_dpop_error_other_error() {
771 let header =
772 r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
773 assert!(!is_dpop_error(header));
774 }
775
776 #[test]
777 fn test_is_dpop_error_no_error_field() {
778 let header = r#"DPoP algs="ES256", error_description="Some description""#;
779 assert!(!is_dpop_error(header));
780 }
781
782 #[test]
783 fn test_is_dpop_error_not_dpop_header() {
784 let header = r#"Bearer error="invalid_token""#;
785 assert!(!is_dpop_error(header));
786 }
787
788 #[test]
789 fn test_is_dpop_error_empty_string() {
790 assert!(!is_dpop_error(""));
791 }
792
793 #[test]
794 fn test_is_dpop_error_minimal_valid() {
795 let header = r#"DPoP error="invalid_dpop_proof""#;
796 assert!(is_dpop_error(header));
797 }
798
799 #[test]
800 fn test_is_dpop_error_unquoted_value() {
801 let header = r#"DPoP error=invalid_dpop_proof"#;
802 assert!(is_dpop_error(header));
803 }
804
805 #[test]
806 fn test_is_dpop_error_whitespace_handling() {
807 let header =
808 r#" DPoP algs="ES256" , error="use_dpop_nonce" , error_description="test" "#;
809 assert!(is_dpop_error(header));
810 }
811
812 #[test]
813 fn test_is_dpop_error_case_sensitive_scheme() {
814 let header = r#"dpop error="invalid_dpop_proof""#;
815 assert!(!is_dpop_error(header));
816 }
817
818 #[test]
819 fn test_is_dpop_error_case_sensitive_error_value() {
820 let header = r#"DPoP error="INVALID_DPOP_PROOF""#;
821 assert!(!is_dpop_error(header));
822 }
823
824 #[test]
825 fn test_is_dpop_error_malformed_quotes() {
826 let header = r#"DPoP error="invalid_dpop_proof"#;
827 assert!(is_dpop_error(header));
828 }
829
830 #[test]
831 fn test_is_dpop_error_multiple_error_fields() {
832 let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#;
833 assert!(!is_dpop_error(header));
835 }
836
837 #[test]
838 fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> {
839 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
841
842 let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
843
844 assert_eq!(thumbprint.len(), 43); assert!(!thumbprint.contains('=')); assert!(!thumbprint.contains('+')); assert!(!thumbprint.contains('/')); let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?;
852 assert_eq!(thumbprint, thumbprint2);
853
854 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
855
856 Ok(())
857 }
858
859 #[test]
860 fn test_extract_jwk_thumbprint_invalid_jwt_format() {
861 let invalid_jwt = "invalid.jwt";
863 let result = extract_jwk_thumbprint(invalid_jwt);
864 assert!(result.is_err());
865 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
866 }
867
868 #[test]
869 fn test_extract_jwk_thumbprint_invalid_header() {
870 let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature";
872 let result = extract_jwk_thumbprint(invalid_jwt);
873 assert!(result.is_err());
874 assert!(
875 result
876 .unwrap_err()
877 .to_string()
878 .contains("Invalid JWT header")
879 );
880 }
881
882 #[test]
883 fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> {
884 let header = serde_json::json!({
886 "alg": "ES256",
887 "typ": "dpop+jwt"
888 });
889 let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
890 .encode(serde_json::to_string(&header)?.as_bytes());
891
892 let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
893 let result = extract_jwk_thumbprint(&jwt_without_jwk);
894 assert!(result.is_err());
895 assert!(
896 result
897 .unwrap_err()
898 .to_string()
899 .contains("Missing required claim: jwk")
900 );
901
902 Ok(())
903 }
904
905 #[test]
906 fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> {
907 use atproto_identity::key::{KeyType, generate_key};
909
910 let key_data = generate_key(KeyType::P256Private)?;
911 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
912
913 let thumbprint = extract_jwk_thumbprint(&dpop_token)?;
914
915 assert_eq!(thumbprint.len(), 43);
917 assert!(!thumbprint.contains('='));
918 assert!(!thumbprint.contains('+'));
919 assert!(!thumbprint.contains('/'));
920
921 let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?;
923 assert_eq!(thumbprint, thumbprint2);
924
925 Ok(())
926 }
927
928 #[test]
929 fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> {
930 use atproto_identity::key::{KeyType, generate_key};
932
933 let key1 = generate_key(KeyType::P256Private)?;
934 let key2 = generate_key(KeyType::P256Private)?;
935
936 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
937 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
938
939 let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?;
940 let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?;
941
942 assert_ne!(thumbprint1, thumbprint2);
943
944 Ok(())
945 }
946
947 #[test]
950 fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> {
951 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
954
955 let config = DpopValidationConfig {
957 expected_http_method: Some("POST".to_string()),
958 expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()),
959 expected_access_token_hash: None,
960 max_age_seconds: 365 * 24 * 60 * 60, allow_future_iat: true,
962 clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, expected_nonce_values: Vec::new(),
964 now: 1,
965 };
966
967 let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
968
969 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
971 assert_eq!(thumbprint.len(), 43);
972
973 Ok(())
974 }
975
976 #[test]
977 fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> {
978 use atproto_identity::key::{KeyType, generate_key};
980
981 let key_data = generate_key(KeyType::P256Private)?;
982 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
983
984 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
985 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
986
987 assert_eq!(thumbprint.len(), 43);
989 assert!(!thumbprint.contains('='));
990 assert!(!thumbprint.contains('+'));
991 assert!(!thumbprint.contains('/'));
992
993 Ok(())
994 }
995
996 #[test]
997 fn test_validate_dpop_jwt_invalid_format() {
998 let config = DpopValidationConfig::default();
999
1000 let result = validate_dpop_jwt("invalid.jwt", &config);
1002 assert!(result.is_err());
1003 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
1004 }
1005
1006 #[test]
1007 fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> {
1008 let header = serde_json::json!({
1010 "alg": "ES256",
1011 "typ": "JWT", "jwk": {
1013 "kty": "EC",
1014 "crv": "P-256",
1015 "x": "test",
1016 "y": "test"
1017 }
1018 });
1019 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1020 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1021
1022 let config = DpopValidationConfig::default();
1023 let result = validate_dpop_jwt(&jwt, &config);
1024 assert!(result.is_err());
1025 assert!(
1026 result
1027 .unwrap_err()
1028 .to_string()
1029 .contains("Invalid token type")
1030 );
1031
1032 Ok(())
1033 }
1034
1035 #[test]
1036 fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> {
1037 let header = serde_json::json!({
1039 "alg": "HS256", "typ": "dpop+jwt",
1041 "jwk": {
1042 "kty": "EC",
1043 "crv": "P-256",
1044 "x": "test",
1045 "y": "test"
1046 }
1047 });
1048 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1049 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1050
1051 let config = DpopValidationConfig::default();
1052 let result = validate_dpop_jwt(&jwt, &config);
1053 assert!(result.is_err());
1054 assert!(
1055 result
1056 .unwrap_err()
1057 .to_string()
1058 .contains("Unsupported JWT algorithm")
1059 );
1060
1061 Ok(())
1062 }
1063
1064 #[test]
1065 fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> {
1066 let header = serde_json::json!({
1068 "alg": "ES256",
1069 "typ": "dpop+jwt"
1070 });
1072 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1073 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1074
1075 let config = DpopValidationConfig::default();
1076 let result = validate_dpop_jwt(&jwt, &config);
1077 assert!(result.is_err());
1078 assert!(
1079 result
1080 .unwrap_err()
1081 .to_string()
1082 .contains("Missing required claim: jwk")
1083 );
1084
1085 Ok(())
1086 }
1087
1088 #[test]
1089 fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> {
1090 use atproto_identity::key::{KeyType, generate_key};
1091
1092 let key_data = generate_key(KeyType::P256Private)?;
1093 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1094
1095 let parts: Vec<&str> = dpop_token.split('.').collect();
1097 let payload = serde_json::json!({
1098 "htm": "POST",
1100 "htu": "https://example.com/token",
1101 "iat": chrono::Utc::now().timestamp()
1102 });
1103 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1104 let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]);
1105
1106 let config = DpopValidationConfig::default();
1107 let result = validate_dpop_jwt(&modified_jwt, &config);
1108 assert!(result.is_err());
1109 assert!(
1110 result
1111 .unwrap_err()
1112 .to_string()
1113 .contains("Missing required claim: jti")
1114 );
1115
1116 Ok(())
1117 }
1118
1119 #[test]
1120 fn test_validate_dpop_jwt_http_method_mismatch() -> anyhow::Result<()> {
1121 use atproto_identity::key::{KeyType, generate_key};
1122
1123 let key_data = generate_key(KeyType::P256Private)?;
1124 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1125
1126 let config = DpopValidationConfig::for_authorization("GET", "https://example.com/token");
1128 let result = validate_dpop_jwt(&dpop_token, &config);
1129 assert!(result.is_err());
1130 assert!(
1131 result
1132 .unwrap_err()
1133 .to_string()
1134 .contains("HTTP method mismatch")
1135 );
1136
1137 Ok(())
1138 }
1139
1140 #[test]
1141 fn test_validate_dpop_jwt_http_uri_mismatch() -> anyhow::Result<()> {
1142 use atproto_identity::key::{KeyType, generate_key};
1143
1144 let key_data = generate_key(KeyType::P256Private)?;
1145 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1146
1147 let config = DpopValidationConfig::for_authorization("POST", "https://different.com/token");
1149 let result = validate_dpop_jwt(&dpop_token, &config);
1150 assert!(result.is_err());
1151 assert!(
1152 result
1153 .unwrap_err()
1154 .to_string()
1155 .contains("HTTP URI mismatch")
1156 );
1157
1158 Ok(())
1159 }
1160
1161 #[test]
1162 fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> {
1163 use atproto_identity::key::{KeyType, generate_key};
1164
1165 let key_data = generate_key(KeyType::P256Private)?;
1166 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1167
1168 let config = DpopValidationConfig::for_resource_request(
1170 "POST",
1171 "https://example.com/token",
1172 "access_token",
1173 );
1174 let result = validate_dpop_jwt(&dpop_token, &config);
1175 assert!(result.is_err());
1176 assert!(
1177 result
1178 .unwrap_err()
1179 .to_string()
1180 .contains("Missing required claim: ath")
1181 );
1182
1183 Ok(())
1184 }
1185
1186 #[test]
1187 fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> {
1188 use atproto_identity::key::{KeyType, generate_key};
1189
1190 let key_data = generate_key(KeyType::P256Private)?;
1191 let access_token = "test_access_token";
1192 let (dpop_token, _, _) = request_dpop(
1193 &key_data,
1194 "GET",
1195 "https://example.com/resource",
1196 access_token,
1197 )?;
1198
1199 let config = DpopValidationConfig::for_resource_request(
1201 "GET",
1202 "https://example.com/resource",
1203 access_token,
1204 );
1205 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1206
1207 assert_eq!(thumbprint.len(), 43);
1208
1209 Ok(())
1210 }
1211
1212 #[test]
1213 fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> {
1214 use atproto_identity::key::{KeyType, generate_key};
1215
1216 let key_data = generate_key(KeyType::P256Private)?;
1217
1218 let old_time = chrono::Utc::now().timestamp() as u64 - 3600; let public_key_data = to_public(&key_data)?;
1222 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
1223
1224 let header = Header {
1225 type_: Some("dpop+jwt".to_string()),
1226 algorithm: Some("ES256".to_string()),
1227 json_web_key: Some(dpop_jwk),
1228 key_id: None,
1229 };
1230
1231 let claims = Claims::new(JoseClaims {
1232 json_web_token_id: Some(Ulid::new().to_string()),
1233 http_method: Some("POST".to_string()),
1234 http_uri: Some("https://example.com/token".to_string()),
1235 issued_at: Some(old_time),
1236 ..Default::default()
1237 });
1238
1239 let old_token = mint(&key_data, &header, &claims)?;
1240
1241 let config = DpopValidationConfig {
1243 expected_http_method: Some("POST".to_string()),
1244 expected_http_uri: Some("https://example.com/token".to_string()),
1245 max_age_seconds: 60, ..Default::default()
1247 };
1248
1249 let result = validate_dpop_jwt(&old_token, &config);
1250 assert!(result.is_err());
1251 assert!(result.unwrap_err().to_string().contains("Token too old"));
1252
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> {
1258 use atproto_identity::key::{KeyType, generate_key};
1259
1260 let key1 = generate_key(KeyType::P256Private)?;
1261 let key2 = generate_key(KeyType::P256Private)?;
1262
1263 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
1264 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
1265
1266 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1267
1268 let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?;
1269 let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?;
1270
1271 assert_ne!(thumbprint1, thumbprint2);
1272
1273 Ok(())
1274 }
1275
1276 #[test]
1277 fn test_validate_dpop_jwt_config_for_authorization() {
1278 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth");
1279
1280 assert_eq!(config.expected_http_method, Some("POST".to_string()));
1281 assert_eq!(
1282 config.expected_http_uri,
1283 Some("https://example.com/auth".to_string())
1284 );
1285 assert_eq!(config.max_age_seconds, 60);
1286 assert!(!config.allow_future_iat);
1287 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1288 }
1289
1290 #[test]
1291 fn test_validate_dpop_jwt_config_for_resource_request() {
1292 let config = DpopValidationConfig::for_resource_request(
1293 "GET",
1294 "https://example.com/resource",
1295 "access_token",
1296 );
1297
1298 assert_eq!(config.expected_http_method, Some("GET".to_string()));
1299 assert_eq!(
1300 config.expected_http_uri,
1301 Some("https://example.com/resource".to_string())
1302 );
1303 assert_eq!(config.max_age_seconds, 60);
1304 assert!(!config.allow_future_iat);
1305 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1306 }
1307
1308 #[test]
1309 fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> {
1310 use atproto_identity::key::{KeyType, generate_key};
1311
1312 let key_data = generate_key(KeyType::P256Private)?;
1313 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1314
1315 let now = chrono::Utc::now().timestamp();
1316
1317 let config = DpopValidationConfig {
1319 expected_http_method: None,
1320 expected_http_uri: None,
1321 expected_access_token_hash: None,
1322 max_age_seconds: 3600,
1323 allow_future_iat: true,
1324 clock_skew_tolerance_seconds: 300,
1325 expected_nonce_values: Vec::new(),
1326 now,
1327 };
1328
1329 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1330 assert_eq!(thumbprint.len(), 43);
1331
1332 Ok(())
1333 }
1334
1335 #[test]
1336 fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> {
1337 use atproto_identity::key::{KeyType, generate_key};
1338
1339 let key_data = generate_key(KeyType::P256Private)?;
1340
1341 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1343
1344 let test_nonce = "test_nonce_12345";
1346 claims
1347 .private
1348 .insert("nonce".to_string(), test_nonce.into());
1349
1350 let dpop_token = mint(&key_data, &header, &claims)?;
1352
1353 let mut config =
1355 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1356 config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()];
1357
1358 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1359 assert_eq!(thumbprint.len(), 43);
1360
1361 Ok(())
1362 }
1363
1364 #[test]
1365 fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> {
1366 use atproto_identity::key::{KeyType, generate_key};
1367
1368 let key_data = generate_key(KeyType::P256Private)?;
1369
1370 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1372
1373 let token_nonce = "token_nonce_that_wont_match";
1375 claims
1376 .private
1377 .insert("nonce".to_string(), token_nonce.into());
1378
1379 let dpop_token = mint(&key_data, &header, &claims)?;
1381
1382 let mut config =
1384 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1385 config.expected_nonce_values = vec![
1386 "expected_nonce_1".to_string(),
1387 "expected_nonce_2".to_string(),
1388 ];
1389
1390 let result = validate_dpop_jwt(&dpop_token, &config);
1391 assert!(result.is_err());
1392 let error_msg = result.unwrap_err().to_string();
1393 assert!(error_msg.contains("Invalid nonce"));
1394
1395 Ok(())
1396 }
1397
1398 #[test]
1399 fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> {
1400 use atproto_identity::key::{KeyType, generate_key};
1401
1402 let key_data = generate_key(KeyType::P256Private)?;
1403 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1404
1405 let parts: Vec<&str> = dpop_token.split('.').collect();
1407 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
1408 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
1409
1410 payload.as_object_mut().unwrap().remove("nonce");
1412
1413 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1414 let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
1415
1416 let mut config =
1418 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1419 config.expected_nonce_values = vec!["required_nonce".to_string()];
1420
1421 let result = validate_dpop_jwt(&modified_jwt, &config);
1422 assert!(result.is_err());
1423 assert!(
1424 result
1425 .unwrap_err()
1426 .to_string()
1427 .contains("Missing required claim: nonce")
1428 );
1429
1430 Ok(())
1431 }
1432
1433 #[test]
1434 fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> {
1435 use atproto_identity::key::{KeyType, generate_key};
1436
1437 let key_data = generate_key(KeyType::P256Private)?;
1438 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1439
1440 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1442 assert!(config.expected_nonce_values.is_empty());
1443
1444 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1445 assert_eq!(thumbprint.len(), 43);
1446
1447 Ok(())
1448 }
1449}