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 serde::Deserialize;
14use ulid::Ulid;
15
16use crate::{
17 errors::DpopError,
18 jwk::{WrappedJsonWebKey, thumbprint, to_key_data},
19 jwt::{Claims, Header, JoseClaims, mint},
20 pkce::challenge,
21};
22
23#[cfg_attr(debug_assertions, derive(Debug))]
25#[derive(Clone, Deserialize)]
26struct SimpleError {
27 pub error: Option<String>,
29}
30
31impl std::fmt::Display for SimpleError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 if let Some(value) = &self.error {
35 write!(f, "{}", value)
36 } else {
37 write!(f, "unknown")
38 }
39 }
40}
41
42#[derive(Clone)]
48pub struct DpopRetry {
49 pub header: Header,
51 pub claims: Claims,
53 pub key_data: KeyData,
55
56 pub check_response_body: bool,
58}
59
60impl DpopRetry {
61 pub fn new(
68 header: Header,
69 claims: Claims,
70 key_data: KeyData,
71 check_response_body: bool,
72 ) -> Self {
73 DpopRetry {
74 header,
75 claims,
76 key_data,
77 check_response_body,
78 }
79 }
80}
81
82#[async_trait::async_trait]
92impl Chainer for DpopRetry {
93 type State = ();
94
95 async fn chain(
107 &self,
108 result: Result<reqwest::Response, reqwest_middleware::Error>,
109 _state: &mut Self::State,
110 request: &mut reqwest::Request,
111 ) -> Result<Option<reqwest::Response>, reqwest_middleware::Error> {
112 let response = result?;
113
114 let status_code = response.status();
115
116 let dpop_status_code = status_code == 400 || status_code == 401;
117 if !dpop_status_code {
118 return Ok(Some(response));
119 };
120
121 let headers = response.headers().clone();
122 let www_authenticate_header = headers.get("WWW-Authenticate");
123 let www_authenticate_value = www_authenticate_header.and_then(|value| value.to_str().ok());
124 let dpop_header_error = www_authenticate_value.is_some_and(is_dpop_error);
125
126 if !dpop_header_error && !self.check_response_body {
127 return Ok(Some(response));
128 };
129
130 if self.check_response_body {
131 let response_body = match response.json::<serde_json::Value>().await {
132 Err(err) => {
133 return Err(reqwest_middleware::Error::Middleware(
134 DpopError::ResponseBodyParsingFailed(err).into(),
135 ));
136 }
137 Ok(value) => value,
138 };
139 if let Some(response_body_obj) = response_body.as_object() {
140 let error_value = response_body_obj
141 .get("error")
142 .and_then(|value| value.as_str())
143 .unwrap_or("placeholder_unknown_error");
144
145 if error_value != "invalid_dpop_proof" && error_value != "use_dpop_nonce" {
146 return Err(reqwest_middleware::Error::Middleware(
147 DpopError::UnexpectedOAuthError {
148 error: error_value.to_string(),
149 }
150 .into(),
151 ));
152 }
153 } else {
154 return Err(reqwest_middleware::Error::Middleware(
155 DpopError::ResponseBodyObjectParsingFailed.into(),
156 ));
157 }
158 };
159
160 let dpop_header = headers
161 .get("DPoP-Nonce")
162 .and_then(|value| value.to_str().ok());
163
164 if dpop_header.is_none() {
165 return Err(reqwest_middleware::Error::Middleware(
166 DpopError::MissingDpopNonceHeader.into(),
167 ));
168 }
169 let dpop_header = dpop_header.unwrap();
170
171 let dpop_proof_header = self.header.clone();
172 let mut dpop_proof_claim = self.claims.clone();
173 dpop_proof_claim
174 .private
175 .insert("nonce".to_string(), dpop_header.to_string().into());
176
177 let dpop_proof_token = mint(&self.key_data, &dpop_proof_header, &dpop_proof_claim)
178 .map_err(|err| {
179 reqwest_middleware::Error::Middleware(DpopError::TokenMintingFailed(err).into())
180 })?;
181
182 request.headers_mut().insert(
183 "DPoP",
184 HeaderValue::from_str(&dpop_proof_token).map_err(|err| {
185 reqwest_middleware::Error::Middleware(DpopError::HeaderCreationFailed(err).into())
186 })?,
187 );
188
189 Ok(None)
190 }
191}
192
193pub fn is_dpop_error(value: &str) -> bool {
226 if !value.trim_start().starts_with("DPoP") {
228 return false;
229 }
230
231 let params_part = value.trim_start().strip_prefix("DPoP").unwrap_or("").trim();
233
234 for part in params_part.split(',') {
236 let trimmed = part.trim();
237
238 if let Some(equals_pos) = trimmed.find('=') {
240 let (key, value_part) = trimmed.split_at(equals_pos);
241 let key = key.trim();
242
243 if key == "error" {
244 let value_part = &value_part[1..]; let value_part = value_part.trim();
247
248 let error_value = if let Some(stripped) = value_part.strip_prefix('"') {
250 if value_part.ends_with('"') && value_part.len() >= 2 {
251 &value_part[1..value_part.len() - 1]
252 } else {
253 stripped }
255 } else if let Some(stripped) = value_part.strip_suffix('"') {
256 stripped } else {
258 value_part
259 };
260
261 return error_value == "invalid_dpop_proof" || error_value == "use_dpop_nonce";
262 }
263 }
264 }
265
266 false
267}
268
269pub fn auth_dpop(
288 key_data: &KeyData,
289 http_method: &str,
290 http_uri: &str,
291) -> anyhow::Result<(String, Header, Claims)> {
292 build_dpop(key_data, http_method, http_uri, None)
293}
294
295pub fn request_dpop(
316 key_data: &KeyData,
317 http_method: &str,
318 http_uri: &str,
319 oauth_access_token: &str,
320) -> anyhow::Result<(String, Header, Claims)> {
321 build_dpop(key_data, http_method, http_uri, Some(oauth_access_token))
322}
323
324fn build_dpop(
325 key_data: &KeyData,
326 http_method: &str,
327 http_uri: &str,
328 access_token: Option<&str>,
329) -> anyhow::Result<(String, Header, Claims)> {
330 let now = chrono::Utc::now();
331
332 let public_key_data = to_public(key_data)?;
333 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
334
335 let header = Header {
336 type_: Some("dpop+jwt".to_string()),
337 algorithm: Some("ES256".to_string()),
338 json_web_key: Some(dpop_jwk),
339 key_id: None,
340 };
341
342 let auth = access_token.map(challenge);
343 let issued_at = Some(now.timestamp() as u64);
344 let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64);
345
346 let claims = Claims::new(JoseClaims {
347 auth,
348 expiration,
349 http_method: Some(http_method.to_string()),
350 http_uri: Some(http_uri.to_string()),
351 issued_at,
352 json_web_token_id: Some(Ulid::new().to_string()),
353 ..Default::default()
354 });
355
356 let token = mint(key_data, &header, &claims)?;
357
358 Ok((token, header, claims))
359}
360
361pub fn extract_jwk_thumbprint(dpop_jwt: &str) -> Result<String> {
392 let parts: Vec<&str> = dpop_jwt.split('.').collect();
394 if parts.len() != 3 {
395 return Err(JWTError::InvalidFormat.into());
396 }
397
398 let encoded_header = parts[0];
399
400 let header_bytes = URL_SAFE_NO_PAD
402 .decode(encoded_header)
403 .map_err(|_| JWTError::InvalidHeader)?;
404
405 let header_json: serde_json::Value =
407 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
408
409 let jwk_value = header_json
411 .get("jwk")
412 .ok_or_else(|| JWTError::MissingClaim {
413 claim: "jwk".to_string(),
414 })?;
415
416 let jwk_object = jwk_value
418 .as_object()
419 .ok_or_else(|| JWKError::MissingField {
420 field: "jwk object".to_string(),
421 })?;
422
423 let mut filtered_jwk = serde_json::Map::new();
425 for field in ["kty", "crv", "x", "y", "d"] {
426 if let Some(value) = jwk_object.get(field) {
427 filtered_jwk.insert(field.to_string(), value.clone());
428 }
429 }
430
431 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
433 .map_err(|e| JWKError::SerializationError {
434 message: e.to_string(),
435 })?;
436
437 let wrapped_jwk = WrappedJsonWebKey {
439 kid: None, alg: None, _use: None, jwk: jwk_ec_key,
443 };
444
445 thumbprint(&wrapped_jwk).map_err(|e| e.into())
447}
448
449#[cfg_attr(debug_assertions, derive(Debug))]
453#[derive(Clone)]
454pub struct DpopValidationConfig {
455 pub expected_http_method: Option<String>,
457 pub expected_http_uri: Option<String>,
459 pub expected_access_token_hash: Option<String>,
461 pub max_age_seconds: u64,
463 pub allow_future_iat: bool,
465 pub clock_skew_tolerance_seconds: u64,
467 pub expected_nonce_values: Vec<String>,
469 pub now: i64,
471}
472
473impl Default for DpopValidationConfig {
474 fn default() -> Self {
475 let now = chrono::Utc::now().timestamp();
476 Self {
477 expected_http_method: None,
478 expected_http_uri: None,
479 expected_access_token_hash: None,
480 max_age_seconds: 60,
481 allow_future_iat: false,
482 clock_skew_tolerance_seconds: 30,
483 expected_nonce_values: Vec::new(),
484 now,
485 }
486 }
487}
488
489impl DpopValidationConfig {
490 pub fn for_authorization(http_method: &str, http_uri: &str) -> Self {
492 Self {
493 expected_http_method: Some(http_method.to_string()),
494 expected_http_uri: Some(http_uri.to_string()),
495 expected_access_token_hash: None,
496 ..Default::default()
497 }
498 }
499
500 pub fn for_resource_request(http_method: &str, http_uri: &str, access_token: &str) -> Self {
502 Self {
503 expected_http_method: Some(http_method.to_string()),
504 expected_http_uri: Some(http_uri.to_string()),
505 expected_access_token_hash: Some(challenge(access_token)),
506 ..Default::default()
507 }
508 }
509}
510
511pub fn validate_dpop_jwt(dpop_jwt: &str, config: &DpopValidationConfig) -> Result<String> {
550 let parts: Vec<&str> = dpop_jwt.split('.').collect();
552 if parts.len() != 3 {
553 return Err(JWTError::InvalidFormat.into());
554 }
555
556 let (encoded_header, encoded_payload, encoded_signature) = (parts[0], parts[1], parts[2]);
557
558 let header_bytes = URL_SAFE_NO_PAD
560 .decode(encoded_header)
561 .map_err(|_| JWTError::InvalidHeader)?;
562
563 let header_json: serde_json::Value =
564 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
565
566 let typ = header_json
568 .get("typ")
569 .and_then(|v| v.as_str())
570 .ok_or_else(|| JWTError::MissingClaim {
571 claim: "typ".to_string(),
572 })?;
573
574 if typ != "dpop+jwt" {
575 return Err(JWTError::InvalidTokenType {
576 expected: "dpop+jwt".to_string(),
577 actual: typ.to_string(),
578 }
579 .into());
580 }
581
582 let alg = header_json
584 .get("alg")
585 .and_then(|v| v.as_str())
586 .ok_or_else(|| JWTError::MissingClaim {
587 claim: "alg".to_string(),
588 })?;
589
590 if !matches!(alg, "ES256" | "ES384" | "ES256K") {
591 return Err(JWTError::UnsupportedAlgorithm {
592 algorithm: alg.to_string(),
593 key_type: "EC".to_string(),
594 }
595 .into());
596 }
597
598 let jwk_value = header_json
600 .get("jwk")
601 .ok_or_else(|| JWTError::MissingClaim {
602 claim: "jwk".to_string(),
603 })?;
604
605 let jwk_object = jwk_value
606 .as_object()
607 .ok_or_else(|| JWKError::MissingField {
608 field: "jwk object".to_string(),
609 })?;
610
611 let mut filtered_jwk = serde_json::Map::new();
613 for field in ["kty", "crv", "x", "y", "d"] {
614 if let Some(value) = jwk_object.get(field) {
615 filtered_jwk.insert(field.to_string(), value.clone());
616 }
617 }
618
619 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
620 .map_err(|e| JWKError::SerializationError {
621 message: e.to_string(),
622 })?;
623
624 let wrapped_jwk = WrappedJsonWebKey {
626 kid: None,
627 alg: Some(alg.to_string()),
628 _use: Some("sig".to_string()),
629 jwk: jwk_ec_key,
630 };
631
632 let key_data = to_key_data(&wrapped_jwk)?;
634
635 let payload_bytes = URL_SAFE_NO_PAD
637 .decode(encoded_payload)
638 .map_err(|_| JWTError::InvalidPayload)?;
639
640 let claims: serde_json::Value =
641 serde_json::from_slice(&payload_bytes).map_err(|_| JWTError::InvalidPayloadJson)?;
642
643 claims
646 .get("jti")
647 .and_then(|v| v.as_str())
648 .ok_or_else(|| JWTError::MissingClaim {
649 claim: "jti".to_string(),
650 })?;
651
652 if let Some(expected_method) = &config.expected_http_method {
653 let htm =
655 claims
656 .get("htm")
657 .and_then(|v| v.as_str())
658 .ok_or_else(|| JWTError::MissingClaim {
659 claim: "htm".to_string(),
660 })?;
661
662 if htm != expected_method {
663 return Err(JWTError::HttpMethodMismatch {
664 expected: expected_method.clone(),
665 actual: htm.to_string(),
666 }
667 .into());
668 }
669 }
670
671 if let Some(expected_uri) = &config.expected_http_uri {
672 let htu =
674 claims
675 .get("htu")
676 .and_then(|v| v.as_str())
677 .ok_or_else(|| JWTError::MissingClaim {
678 claim: "htu".to_string(),
679 })?;
680
681 if htu != expected_uri {
682 return Err(JWTError::HttpUriMismatch {
683 expected: expected_uri.clone(),
684 actual: htu.to_string(),
685 }
686 .into());
687 }
688 }
689
690 let iat = claims
692 .get("iat")
693 .and_then(|v| v.as_u64())
694 .ok_or_else(|| JWTError::MissingClaim {
695 claim: "iat".to_string(),
696 })?;
697
698 if config.now as u64 > iat + config.max_age_seconds + config.clock_skew_tolerance_seconds {
700 return Err(JWTError::InvalidTimestamp {
701 reason: format!(
702 "Token too old: issued at {} but max age is {} seconds",
703 iat, config.max_age_seconds
704 ),
705 }
706 .into());
707 }
708
709 if !config.allow_future_iat && iat > config.now as u64 + config.clock_skew_tolerance_seconds {
711 return Err(JWTError::InvalidTimestamp {
712 reason: format!(
713 "Token from future: issued at {} but current time is {}",
714 iat, config.now
715 ),
716 }
717 .into());
718 }
719
720 if let Some(expected_ath) = &config.expected_access_token_hash {
722 let ath =
723 claims
724 .get("ath")
725 .and_then(|v| v.as_str())
726 .ok_or_else(|| JWTError::MissingClaim {
727 claim: "ath".to_string(),
728 })?;
729
730 if ath != expected_ath {
731 return Err(JWTError::AccessTokenHashMismatch.into());
732 }
733 }
734
735 if !config.expected_nonce_values.is_empty() {
737 let nonce = claims
738 .get("nonce")
739 .and_then(|v| v.as_str())
740 .ok_or_else(|| JWTError::MissingClaim {
741 claim: "nonce".to_string(),
742 })?;
743
744 if !config.expected_nonce_values.contains(&nonce.to_string()) {
745 return Err(JWTError::InvalidNonce {
746 nonce: nonce.to_string(),
747 }
748 .into());
749 }
750 }
751
752 if let Some(exp_value) = claims.get("exp")
754 && let Some(exp) = exp_value.as_u64()
755 && config.now as u64 >= exp {
756 return Err(JWTError::TokenExpired.into());
757 }
758
759 let content = format!("{}.{}", encoded_header, encoded_payload);
761 let signature_bytes = URL_SAFE_NO_PAD
762 .decode(encoded_signature)
763 .map_err(|_| JWTError::InvalidSignature)?;
764
765 validate(&key_data, &signature_bytes, content.as_bytes())
766 .map_err(|_| JWTError::SignatureVerificationFailed)?;
767
768 thumbprint(&wrapped_jwk).map_err(|e| e.into())
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn test_is_dpop_error_invalid_dpop_proof() {
778 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#;
779 assert!(is_dpop_error(header));
780 }
781
782 #[test]
783 fn test_is_dpop_error_use_dpop_nonce() {
784 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""#;
785 assert!(is_dpop_error(header));
786 }
787
788 #[test]
789 fn test_is_dpop_error_other_error() {
790 let header =
791 r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
792 assert!(!is_dpop_error(header));
793 }
794
795 #[test]
796 fn test_is_dpop_error_no_error_field() {
797 let header = r#"DPoP algs="ES256", error_description="Some description""#;
798 assert!(!is_dpop_error(header));
799 }
800
801 #[test]
802 fn test_is_dpop_error_not_dpop_header() {
803 let header = r#"Bearer error="invalid_token""#;
804 assert!(!is_dpop_error(header));
805 }
806
807 #[test]
808 fn test_is_dpop_error_empty_string() {
809 assert!(!is_dpop_error(""));
810 }
811
812 #[test]
813 fn test_is_dpop_error_minimal_valid() {
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_unquoted_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_whitespace_handling() {
826 let header =
827 r#" DPoP algs="ES256" , error="use_dpop_nonce" , error_description="test" "#;
828 assert!(is_dpop_error(header));
829 }
830
831 #[test]
832 fn test_is_dpop_error_case_sensitive_scheme() {
833 let header = r#"dpop error="invalid_dpop_proof""#;
834 assert!(!is_dpop_error(header));
835 }
836
837 #[test]
838 fn test_is_dpop_error_case_sensitive_error_value() {
839 let header = r#"DPoP error="INVALID_DPOP_PROOF""#;
840 assert!(!is_dpop_error(header));
841 }
842
843 #[test]
844 fn test_is_dpop_error_malformed_quotes() {
845 let header = r#"DPoP error="invalid_dpop_proof"#;
846 assert!(is_dpop_error(header));
847 }
848
849 #[test]
850 fn test_is_dpop_error_multiple_error_fields() {
851 let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#;
852 assert!(!is_dpop_error(header));
854 }
855
856 #[test]
857 fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> {
858 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
860
861 let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
862
863 assert_eq!(thumbprint.len(), 43); assert!(!thumbprint.contains('=')); assert!(!thumbprint.contains('+')); assert!(!thumbprint.contains('/')); let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?;
871 assert_eq!(thumbprint, thumbprint2);
872
873 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
874
875 Ok(())
876 }
877
878 #[test]
879 fn test_extract_jwk_thumbprint_invalid_jwt_format() {
880 let invalid_jwt = "invalid.jwt";
882 let result = extract_jwk_thumbprint(invalid_jwt);
883 assert!(result.is_err());
884 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
885 }
886
887 #[test]
888 fn test_extract_jwk_thumbprint_invalid_header() {
889 let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature";
891 let result = extract_jwk_thumbprint(invalid_jwt);
892 assert!(result.is_err());
893 assert!(
894 result
895 .unwrap_err()
896 .to_string()
897 .contains("Invalid JWT header")
898 );
899 }
900
901 #[test]
902 fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> {
903 let header = serde_json::json!({
905 "alg": "ES256",
906 "typ": "dpop+jwt"
907 });
908 let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
909 .encode(serde_json::to_string(&header)?.as_bytes());
910
911 let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
912 let result = extract_jwk_thumbprint(&jwt_without_jwk);
913 assert!(result.is_err());
914 assert!(
915 result
916 .unwrap_err()
917 .to_string()
918 .contains("Missing required claim: jwk")
919 );
920
921 Ok(())
922 }
923
924 #[test]
925 fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> {
926 use atproto_identity::key::{KeyType, generate_key};
928
929 let key_data = generate_key(KeyType::P256Private)?;
930 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
931
932 let thumbprint = extract_jwk_thumbprint(&dpop_token)?;
933
934 assert_eq!(thumbprint.len(), 43);
936 assert!(!thumbprint.contains('='));
937 assert!(!thumbprint.contains('+'));
938 assert!(!thumbprint.contains('/'));
939
940 let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?;
942 assert_eq!(thumbprint, thumbprint2);
943
944 Ok(())
945 }
946
947 #[test]
948 fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> {
949 use atproto_identity::key::{KeyType, generate_key};
951
952 let key1 = generate_key(KeyType::P256Private)?;
953 let key2 = generate_key(KeyType::P256Private)?;
954
955 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
956 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
957
958 let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?;
959 let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?;
960
961 assert_ne!(thumbprint1, thumbprint2);
962
963 Ok(())
964 }
965
966 #[test]
969 fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> {
970 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
973
974 let config = DpopValidationConfig {
976 expected_http_method: Some("POST".to_string()),
977 expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()),
978 expected_access_token_hash: None,
979 max_age_seconds: 365 * 24 * 60 * 60, allow_future_iat: true,
981 clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, expected_nonce_values: Vec::new(),
983 now: 1,
984 };
985
986 let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
987
988 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
990 assert_eq!(thumbprint.len(), 43);
991
992 Ok(())
993 }
994
995 #[test]
996 fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> {
997 use atproto_identity::key::{KeyType, generate_key};
999
1000 let key_data = generate_key(KeyType::P256Private)?;
1001 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1002
1003 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1004 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1005
1006 assert_eq!(thumbprint.len(), 43);
1008 assert!(!thumbprint.contains('='));
1009 assert!(!thumbprint.contains('+'));
1010 assert!(!thumbprint.contains('/'));
1011
1012 Ok(())
1013 }
1014
1015 #[test]
1016 fn test_validate_dpop_jwt_invalid_format() {
1017 let config = DpopValidationConfig::default();
1018
1019 let result = validate_dpop_jwt("invalid.jwt", &config);
1021 assert!(result.is_err());
1022 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
1023 }
1024
1025 #[test]
1026 fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> {
1027 let header = serde_json::json!({
1029 "alg": "ES256",
1030 "typ": "JWT", "jwk": {
1032 "kty": "EC",
1033 "crv": "P-256",
1034 "x": "test",
1035 "y": "test"
1036 }
1037 });
1038 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1039 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1040
1041 let config = DpopValidationConfig::default();
1042 let result = validate_dpop_jwt(&jwt, &config);
1043 assert!(result.is_err());
1044 assert!(
1045 result
1046 .unwrap_err()
1047 .to_string()
1048 .contains("Invalid token type")
1049 );
1050
1051 Ok(())
1052 }
1053
1054 #[test]
1055 fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> {
1056 let header = serde_json::json!({
1058 "alg": "HS256", "typ": "dpop+jwt",
1060 "jwk": {
1061 "kty": "EC",
1062 "crv": "P-256",
1063 "x": "test",
1064 "y": "test"
1065 }
1066 });
1067 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1068 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1069
1070 let config = DpopValidationConfig::default();
1071 let result = validate_dpop_jwt(&jwt, &config);
1072 assert!(result.is_err());
1073 assert!(
1074 result
1075 .unwrap_err()
1076 .to_string()
1077 .contains("Unsupported JWT algorithm")
1078 );
1079
1080 Ok(())
1081 }
1082
1083 #[test]
1084 fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> {
1085 let header = serde_json::json!({
1087 "alg": "ES256",
1088 "typ": "dpop+jwt"
1089 });
1091 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1092 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1093
1094 let config = DpopValidationConfig::default();
1095 let result = validate_dpop_jwt(&jwt, &config);
1096 assert!(result.is_err());
1097 assert!(
1098 result
1099 .unwrap_err()
1100 .to_string()
1101 .contains("Missing required claim: jwk")
1102 );
1103
1104 Ok(())
1105 }
1106
1107 #[test]
1108 fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> {
1109 use atproto_identity::key::{KeyType, generate_key};
1110
1111 let key_data = generate_key(KeyType::P256Private)?;
1112 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1113
1114 let parts: Vec<&str> = dpop_token.split('.').collect();
1116 let payload = serde_json::json!({
1117 "htm": "POST",
1119 "htu": "https://example.com/token",
1120 "iat": chrono::Utc::now().timestamp()
1121 });
1122 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1123 let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]);
1124
1125 let config = DpopValidationConfig::default();
1126 let result = validate_dpop_jwt(&modified_jwt, &config);
1127 assert!(result.is_err());
1128 assert!(
1129 result
1130 .unwrap_err()
1131 .to_string()
1132 .contains("Missing required claim: jti")
1133 );
1134
1135 Ok(())
1136 }
1137
1138 #[test]
1139 fn test_validate_dpop_jwt_http_method_mismatch() -> anyhow::Result<()> {
1140 use atproto_identity::key::{KeyType, generate_key};
1141
1142 let key_data = generate_key(KeyType::P256Private)?;
1143 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1144
1145 let config = DpopValidationConfig::for_authorization("GET", "https://example.com/token");
1147 let result = validate_dpop_jwt(&dpop_token, &config);
1148 assert!(result.is_err());
1149 assert!(
1150 result
1151 .unwrap_err()
1152 .to_string()
1153 .contains("HTTP method mismatch")
1154 );
1155
1156 Ok(())
1157 }
1158
1159 #[test]
1160 fn test_validate_dpop_jwt_http_uri_mismatch() -> anyhow::Result<()> {
1161 use atproto_identity::key::{KeyType, generate_key};
1162
1163 let key_data = generate_key(KeyType::P256Private)?;
1164 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1165
1166 let config = DpopValidationConfig::for_authorization("POST", "https://different.com/token");
1168 let result = validate_dpop_jwt(&dpop_token, &config);
1169 assert!(result.is_err());
1170 assert!(
1171 result
1172 .unwrap_err()
1173 .to_string()
1174 .contains("HTTP URI mismatch")
1175 );
1176
1177 Ok(())
1178 }
1179
1180 #[test]
1181 fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> {
1182 use atproto_identity::key::{KeyType, generate_key};
1183
1184 let key_data = generate_key(KeyType::P256Private)?;
1185 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1186
1187 let config = DpopValidationConfig::for_resource_request(
1189 "POST",
1190 "https://example.com/token",
1191 "access_token",
1192 );
1193 let result = validate_dpop_jwt(&dpop_token, &config);
1194 assert!(result.is_err());
1195 assert!(
1196 result
1197 .unwrap_err()
1198 .to_string()
1199 .contains("Missing required claim: ath")
1200 );
1201
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> {
1207 use atproto_identity::key::{KeyType, generate_key};
1208
1209 let key_data = generate_key(KeyType::P256Private)?;
1210 let access_token = "test_access_token";
1211 let (dpop_token, _, _) = request_dpop(
1212 &key_data,
1213 "GET",
1214 "https://example.com/resource",
1215 access_token,
1216 )?;
1217
1218 let config = DpopValidationConfig::for_resource_request(
1220 "GET",
1221 "https://example.com/resource",
1222 access_token,
1223 );
1224 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1225
1226 assert_eq!(thumbprint.len(), 43);
1227
1228 Ok(())
1229 }
1230
1231 #[test]
1232 fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> {
1233 use atproto_identity::key::{KeyType, generate_key};
1234
1235 let key_data = generate_key(KeyType::P256Private)?;
1236
1237 let old_time = chrono::Utc::now().timestamp() as u64 - 3600; let public_key_data = to_public(&key_data)?;
1241 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
1242
1243 let header = Header {
1244 type_: Some("dpop+jwt".to_string()),
1245 algorithm: Some("ES256".to_string()),
1246 json_web_key: Some(dpop_jwk),
1247 key_id: None,
1248 };
1249
1250 let claims = Claims::new(JoseClaims {
1251 json_web_token_id: Some(Ulid::new().to_string()),
1252 http_method: Some("POST".to_string()),
1253 http_uri: Some("https://example.com/token".to_string()),
1254 issued_at: Some(old_time),
1255 ..Default::default()
1256 });
1257
1258 let old_token = mint(&key_data, &header, &claims)?;
1259
1260 let config = DpopValidationConfig {
1262 expected_http_method: Some("POST".to_string()),
1263 expected_http_uri: Some("https://example.com/token".to_string()),
1264 max_age_seconds: 60, ..Default::default()
1266 };
1267
1268 let result = validate_dpop_jwt(&old_token, &config);
1269 assert!(result.is_err());
1270 assert!(result.unwrap_err().to_string().contains("Token too old"));
1271
1272 Ok(())
1273 }
1274
1275 #[test]
1276 fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> {
1277 use atproto_identity::key::{KeyType, generate_key};
1278
1279 let key1 = generate_key(KeyType::P256Private)?;
1280 let key2 = generate_key(KeyType::P256Private)?;
1281
1282 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
1283 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
1284
1285 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1286
1287 let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?;
1288 let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?;
1289
1290 assert_ne!(thumbprint1, thumbprint2);
1291
1292 Ok(())
1293 }
1294
1295 #[test]
1296 fn test_validate_dpop_jwt_config_for_authorization() {
1297 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth");
1298
1299 assert_eq!(config.expected_http_method, Some("POST".to_string()));
1300 assert_eq!(
1301 config.expected_http_uri,
1302 Some("https://example.com/auth".to_string())
1303 );
1304 assert_eq!(config.max_age_seconds, 60);
1305 assert!(!config.allow_future_iat);
1306 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1307 }
1308
1309 #[test]
1310 fn test_validate_dpop_jwt_config_for_resource_request() {
1311 let config = DpopValidationConfig::for_resource_request(
1312 "GET",
1313 "https://example.com/resource",
1314 "access_token",
1315 );
1316
1317 assert_eq!(config.expected_http_method, Some("GET".to_string()));
1318 assert_eq!(
1319 config.expected_http_uri,
1320 Some("https://example.com/resource".to_string())
1321 );
1322 assert_eq!(config.max_age_seconds, 60);
1323 assert!(!config.allow_future_iat);
1324 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1325 }
1326
1327 #[test]
1328 fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> {
1329 use atproto_identity::key::{KeyType, generate_key};
1330
1331 let key_data = generate_key(KeyType::P256Private)?;
1332 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1333
1334 let now = chrono::Utc::now().timestamp();
1335
1336 let config = DpopValidationConfig {
1338 expected_http_method: None,
1339 expected_http_uri: None,
1340 expected_access_token_hash: None,
1341 max_age_seconds: 3600,
1342 allow_future_iat: true,
1343 clock_skew_tolerance_seconds: 300,
1344 expected_nonce_values: Vec::new(),
1345 now,
1346 };
1347
1348 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1349 assert_eq!(thumbprint.len(), 43);
1350
1351 Ok(())
1352 }
1353
1354 #[test]
1355 fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> {
1356 use atproto_identity::key::{KeyType, generate_key};
1357
1358 let key_data = generate_key(KeyType::P256Private)?;
1359
1360 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1362
1363 let test_nonce = "test_nonce_12345";
1365 claims
1366 .private
1367 .insert("nonce".to_string(), test_nonce.into());
1368
1369 let dpop_token = mint(&key_data, &header, &claims)?;
1371
1372 let mut config =
1374 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1375 config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()];
1376
1377 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1378 assert_eq!(thumbprint.len(), 43);
1379
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> {
1385 use atproto_identity::key::{KeyType, generate_key};
1386
1387 let key_data = generate_key(KeyType::P256Private)?;
1388
1389 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1391
1392 let token_nonce = "token_nonce_that_wont_match";
1394 claims
1395 .private
1396 .insert("nonce".to_string(), token_nonce.into());
1397
1398 let dpop_token = mint(&key_data, &header, &claims)?;
1400
1401 let mut config =
1403 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1404 config.expected_nonce_values = vec![
1405 "expected_nonce_1".to_string(),
1406 "expected_nonce_2".to_string(),
1407 ];
1408
1409 let result = validate_dpop_jwt(&dpop_token, &config);
1410 assert!(result.is_err());
1411 let error_msg = result.unwrap_err().to_string();
1412 assert!(error_msg.contains("Invalid nonce"));
1413
1414 Ok(())
1415 }
1416
1417 #[test]
1418 fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> {
1419 use atproto_identity::key::{KeyType, generate_key};
1420
1421 let key_data = generate_key(KeyType::P256Private)?;
1422 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1423
1424 let parts: Vec<&str> = dpop_token.split('.').collect();
1426 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
1427 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
1428
1429 payload.as_object_mut().unwrap().remove("nonce");
1431
1432 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1433 let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
1434
1435 let mut config =
1437 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1438 config.expected_nonce_values = vec!["required_nonce".to_string()];
1439
1440 let result = validate_dpop_jwt(&modified_jwt, &config);
1441 assert!(result.is_err());
1442 assert!(
1443 result
1444 .unwrap_err()
1445 .to_string()
1446 .contains("Missing required claim: nonce")
1447 );
1448
1449 Ok(())
1450 }
1451
1452 #[test]
1453 fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> {
1454 use atproto_identity::key::{KeyType, generate_key};
1455
1456 let key_data = generate_key(KeyType::P256Private)?;
1457 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1458
1459 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1461 assert!(config.expected_nonce_values.is_empty());
1462
1463 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1464 assert_eq!(thumbprint.len(), 43);
1465
1466 Ok(())
1467 }
1468}