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